From c1ae993c3fa671b041b195d68b97b8530d8a3a28 Mon Sep 17 00:00:00 2001 From: Ananth Bhaskararaman Date: Fri, 8 Mar 2024 02:56:48 +0530 Subject: [PATCH] feat: Issue cmd and cert validities Implemented issue cmd to issue mTLS certs locally. Cert validity parsing from timespec for notBefore and notAfter. --- cmd/bf/flags.go | 30 +++++++++++- cmd/bf/issue.go | 111 +++++++++++++++++++++++++++----------------- cmd/bf/new.go | 87 +++++++++++++++++++++++----------- tinyca/ca.go | 33 ++++++------- tinyca/templates.go | 32 +++++++++++++ tinyca/validity.go | 54 +++++++++++++++++++++ 6 files changed, 256 insertions(+), 91 deletions(-) create mode 100644 tinyca/templates.go create mode 100644 tinyca/validity.go diff --git a/cmd/bf/flags.go b/cmd/bf/flags.go index 073e4c3..f9b5c3f 100644 --- a/cmd/bf/flags.go +++ b/cmd/bf/flags.go @@ -44,6 +44,34 @@ var ( Destination: &caPrivKeyUri, } + clientPrivKeyUri string + clientPrivKeyFlag = &cli.StringFlag{ + Name: "client-private-key", + Usage: "read CA private key from `FILE`", + Aliases: []string{"client-key"}, + EnvVars: []string{"CLIENT_PRIVKEY", "CLIENT_KEY"}, + TakesFile: true, + Value: "client-key.pem", + Destination: &clientPrivKeyUri, + } + + notBeforeTime string + notBeforeFlag = &cli.StringFlag{ + Name: "not-before", + Usage: "certificate valid from `TIMESPEC` (default: \"now\")", + Aliases: []string{"before"}, + EnvVars: []string{"NOT_BEFORE"}, + Destination: ¬BeforeTime, + } + notAfterTime string + notAfterFlag = &cli.StringFlag{ + Name: "not-after", + Usage: "certificate valid until `TIMESPEC` (default: \"+1h\")", + Aliases: []string{"after"}, + EnvVars: []string{"NOT_AFTER"}, + Destination: ¬AfterTime, + } + outputFile string outputFlag = &cli.StringFlag{ Name: "output", @@ -56,7 +84,7 @@ var ( ) func getOutputWriter() (io.Writer, error) { - if outputFile == "-" { + if outputFile == "" || outputFile == "-" { return os.Stdout, nil } return os.Create(outputFile) diff --git a/cmd/bf/issue.go b/cmd/bf/issue.go index dba503a..771c82f 100644 --- a/cmd/bf/issue.go +++ b/cmd/bf/issue.go @@ -1,54 +1,79 @@ package main import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" - "time" "github.com/RealImage/bifrost/cafiles" "github.com/RealImage/bifrost/tinyca" "github.com/urfave/cli/v2" ) -var ( - notBefore cli.Timestamp - notAfter cli.Timestamp - - issueCmd = &cli.Command{ - Name: "issue", - Flags: []cli.Flag{ - caCertFlag, - caPrivKeyFlag, - &cli.TimestampFlag{ - Name: "not-before", - Usage: "issue certificates valid from `TIMESTAMP`", - Aliases: []string{"before"}, - EnvVars: []string{"NOT_BEFORE"}, - Value: cli.NewTimestamp(time.Now()), - Destination: ¬Before, - }, - &cli.TimestampFlag{ - Name: "not-after", - Usage: "issue certificates valid until `TIMESTAMP`", - Aliases: []string{"after"}, - EnvVars: []string{"NOT_AFTER"}, - Value: cli.NewTimestamp(time.Now().AddDate(0, 0, 1)), - Destination: ¬After, +var issueCmd = &cli.Command{ + Name: "issue", + Flags: []cli.Flag{ + caCertFlag, + caPrivKeyFlag, + clientPrivKeyFlag, + notBeforeFlag, + notAfterFlag, + outputFlag, + }, + + Action: func(cliCtx *cli.Context) error { + ctx := cliCtx.Context + caCert, caKey, err := cafiles.GetCertKey(ctx, caCertUri, caPrivKeyUri) + if err != nil { + return cli.Exit(fmt.Sprintf("Error reading cert/key: %s", err), 1) + } + + ca, err := tinyca.New(caCert, caKey) + if err != nil { + return cli.Exit(fmt.Sprintf("Error creating CA: %s", err), 1) + } + + clientKey, err := cafiles.GetPrivateKey(ctx, clientPrivKeyUri) + if err != nil { + return cli.Exit(fmt.Sprintf("Error reading client key: %s", err), 1) + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{ + Organization: []string{caCert.Namespace.String()}, + CommonName: clientKey.UUID(caCert.Namespace).String(), }, - }, - - Action: func(cliCtx *cli.Context) error { - ctx := cliCtx.Context - cert, key, err := cafiles.GetCertKey(ctx, caCertUri, caPrivKeyUri) - if err != nil { - return cli.Exit(fmt.Sprintf("Error reading cert/key: %s", err), 1) - } - - _, err = tinyca.New(cert, key) - if err != nil { - return cli.Exit(fmt.Sprintf("Error creating CA: %s", err), 1) - } - - return nil - }, - } -) + }, clientKey) + if err != nil { + return cli.Exit(fmt.Sprintf("Error creating certificate request: %s", err), 1) + } + + notBefore, notAfter, err := tinyca.ParseValidity(notBeforeTime, notAfterTime) + if err != nil { + return cli.Exit(fmt.Sprintf("Error parsing validity: %s", err), 1) + } + + template := tinyca.TLSClientCertTemplate(notBefore, notAfter) + + cert, err := ca.IssueCertificate(csr, template) + if err != nil { + return cli.Exit(fmt.Sprintf("Error issuing certificate: %s", err), 1) + } + + out, err := getOutputWriter() + if err != nil { + return cli.Exit(fmt.Sprintf("Error getting output writer: %s", err), 1) + } + + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + } + + fmt.Fprint(out, string(pem.EncodeToMemory(block))) + + return nil + }, +} diff --git a/cmd/bf/new.go b/cmd/bf/new.go index c7e5815..de5ad38 100644 --- a/cmd/bf/new.go +++ b/cmd/bf/new.go @@ -6,11 +6,10 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" - "math/big" - "time" "github.com/RealImage/bifrost" "github.com/RealImage/bifrost/cafiles" + "github.com/RealImage/bifrost/tinyca" "github.com/google/uuid" "github.com/urfave/cli/v2" ) @@ -38,8 +37,8 @@ var newCmd = &cli.Command{ }, }, { - Name: "identity", - Aliases: []string{"id"}, + Name: "private-key", + Aliases: []string{"key", "pk", "pkey"}, Usage: "Create a new identity", Flags: []cli.Flag{ outputFlag, @@ -66,6 +65,49 @@ var newCmd = &cli.Command{ return nil }, }, + { + Name: "certificate-request", + Aliases: []string{"csr", "req"}, + Flags: []cli.Flag{ + nsFlag, + clientPrivKeyFlag, + outputFlag, + }, + Usage: "Create a new certificate request", + Action: func(c *cli.Context) error { + if namespace == uuid.Nil { + return fmt.Errorf("namespace is required") + } + + key, err := cafiles.GetPrivateKey(c.Context, clientPrivKeyUri) + if err != nil { + return err + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{ + Organization: []string{namespace.String()}, + CommonName: key.UUID(namespace).String(), + }, + }, key) + if err != nil { + return err + } + + out, err := getOutputWriter() + if err != nil { + return err + } + + block := &pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csr, + } + fmt.Fprint(out, string(pem.EncodeToMemory(block))) + + return nil + }, + }, { Name: "ca-certificate", Aliases: []string{"ca-cert", "ca"}, @@ -73,41 +115,32 @@ var newCmd = &cli.Command{ nsFlag, caPrivKeyFlag, outputFlag, - &cli.DurationFlag{ - Name: "validity", - Usage: "certificate `VALIDITY`", - Value: time.Hour * 24 * 365, - }, + notBeforeFlag, + notAfterFlag, }, Usage: "Create a new certificate authority signing certificate", Action: func(c *cli.Context) error { + if namespace == uuid.Nil { + return fmt.Errorf("namespace is required") + } + key, err := cafiles.GetPrivateKey(c.Context, caPrivKeyUri) if err != nil { return err } - notBefore := time.Now() - notAfter := notBefore.Add(c.Duration("validity")) - - // Create root certificate. - template := x509.Certificate{ - SerialNumber: big.NewInt(2), - Subject: pkix.Name{ - CommonName: key.UUID(namespace).String(), - Organization: []string{namespace.String()}, - }, - NotBefore: notBefore, - NotAfter: notAfter, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - BasicConstraintsValid: true, - IsCA: true, - MaxPathLenZero: true, + id := key.UUID(namespace) + notBefore, notAfter, err := tinyca.ParseValidity(notBeforeTime, notAfterTime) + if err != nil { + return err } + template := tinyca.CACertTemplate(notBefore, notAfter, namespace, id) + certDer, err := x509.CreateCertificate( rand.Reader, - &template, - &template, + template, + template, key.PublicKey().PublicKey, key, ) diff --git a/tinyca/ca.go b/tinyca/ca.go index 13e8415..e3b6ee2 100644 --- a/tinyca/ca.go +++ b/tinyca/ca.go @@ -70,22 +70,19 @@ func (ca CA) ServeHTTP(w http.ResponseWriter, r *http.Request) { ca.requestsTotal.Inc() startTime := time.Now() - notBefore := time.Now() - if nb := r.URL.Query().Get("not-before"); nb != "" { - var err error - if notBefore, err = time.Parse(time.RFC3339, nb); err != nil { - http.Error(w, "invalid not-before query parameter", http.StatusBadRequest) - return - } + nb := r.URL.Query().Get("not-before") + if nb == "" { + nb = "now" + } + na := r.URL.Query().Get("not-after") + if na == "" { + na = "+1h" } - notAfter := notBefore.AddDate(0, 0, 1) - if na := r.URL.Query().Get("not-after"); na != "" { - var err error - if notAfter, err = time.Parse(time.RFC3339, na); err != nil { - http.Error(w, "invalid not-after query parameter", http.StatusBadRequest) - return - } + notBefore, notAfter, err := ParseValidity(nb, na) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } contentType, _, err := webapp.GetContentType(r.Header, webapp.MimeTypeText) @@ -112,12 +109,8 @@ func (ca CA) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - template := &x509.Certificate{ - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - NotBefore: notBefore, - NotAfter: notAfter, - } + template := TLSClientCertTemplate(notBefore, notAfter) + cert, err := ca.IssueCertificate(csr, template) if err != nil { statusCode := http.StatusInternalServerError diff --git a/tinyca/templates.go b/tinyca/templates.go new file mode 100644 index 0000000..a60f277 --- /dev/null +++ b/tinyca/templates.go @@ -0,0 +1,32 @@ +package tinyca + +import ( + "crypto/x509" + "crypto/x509/pkix" + "time" + + "github.com/google/uuid" +) + +func TLSClientCertTemplate(nb, na time.Time) *x509.Certificate { + return &x509.Certificate{ + NotBefore: nb, + NotAfter: na, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } +} + +func CACertTemplate(nb, na time.Time, ns, id uuid.UUID) *x509.Certificate { + return &x509.Certificate{ + Subject: pkix.Name{ + Organization: []string{ns.String()}, + CommonName: id.String(), + }, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } +} diff --git a/tinyca/validity.go b/tinyca/validity.go new file mode 100644 index 0000000..4da3144 --- /dev/null +++ b/tinyca/validity.go @@ -0,0 +1,54 @@ +package tinyca + +import ( + "errors" + "strings" + "time" +) + +// ParseValidity parses the notBefore and notAfter strings into time.Time values. +// The notBefore and notAfter strings can be in RFC3339 format, or a duration +// from the current time. +// Durations are prefixed with either '+' or '-'. +// If notBefore is empty, it defaults to the current time. +// If notAfter is empty, it defaults to one hour from the current time. +// If notBefore is "now", it is set to the current time. +// The minimum validity period is one minute. +func ParseValidity(nb string, na string) (time.Time, time.Time, error) { + now := time.Now() + notBefore := now + if nb != "" && nb != "now" { + var err error + if notBefore, err = parseTimeOrOffset(nb); err != nil { + return time.Time{}, time.Time{}, err + } + } + notAfter := notBefore.Add(time.Hour) + if na != "" { + var err error + if notAfter, err = parseTimeOrOffset(na); err != nil { + return time.Time{}, time.Time{}, err + } + } + + if notBefore.After(notAfter) { + return time.Time{}, time.Time{}, errors.New("negative validity period") + } + + if notAfter.Sub(notBefore) < time.Minute { + return time.Time{}, time.Time{}, errors.New("validity period is too short") + } + + return notBefore, notAfter, nil +} + +func parseTimeOrOffset(t string) (time.Time, error) { + if strings.HasPrefix(t, "+") { + d, err := time.ParseDuration(t[1:]) + if err != nil { + return time.Time{}, err + } + return time.Now().Add(d), nil + } + return time.Parse(time.RFC3339, t) +}