diff --git a/authority/authority.go b/authority/authority.go index 48b7a566..80242e8b 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/hex" "log" + "strings" "sync" "time" @@ -33,13 +34,14 @@ import ( // Authority implements the Certificate Authority internal interface. type Authority struct { - config *config.Config - keyManager kms.KeyManager - provisioners *provisioner.Collection - admins *administrator.Collection - db db.AuthDB - adminDB admin.DB - templates *templates.Templates + config *config.Config + keyManager kms.KeyManager + provisioners *provisioner.Collection + admins *administrator.Collection + db db.AuthDB + adminDB admin.DB + templates *templates.Templates + linkedCAToken string // X509 CA x509CAService cas.CertificateAuthorityService @@ -442,17 +444,24 @@ func (a *Authority) init() error { // Initialize step-ca Admin Database if it's not already initialized using // WithAdminDB. if a.adminDB == nil { - if a.config.AuthorityConfig.AuthorityID == "" { + if a.linkedCAToken == "" { // Check if AuthConfig already exists a.adminDB, err = adminDBNosql.New(a.db.(nosql.DB), admin.DefaultAuthorityID) if err != nil { return err } } else { - a.adminDB, err = createLinkedCAClient(a.config.AuthorityConfig.AuthorityID, "localhost:6040") + // Use the linkedca client as the admindb. + client, err := newLinkedCAClient(a.linkedCAToken) if err != nil { return err } + // If authorityId is configured make sure it matches the one in the token + if id := a.config.AuthorityConfig.AuthorityID; id != "" && !strings.EqualFold(id, client.authorityID) { + return errors.New("error initializing linkedca: token authority and configured authority do not match") + } + client.Run() + a.adminDB = client } } @@ -534,6 +543,9 @@ func (a *Authority) CloseForReload() { if err := a.keyManager.Close(); err != nil { log.Printf("error closing the key manager: %v", err) } + if client, ok := a.adminDB.(*linkedCaClient); ok { + client.Stop() + } } // requiresDecrypter returns whether the Authority diff --git a/authority/linkedca.go b/authority/linkedca.go index 0d9a748f..4e67f246 100644 --- a/authority/linkedca.go +++ b/authority/linkedca.go @@ -2,59 +2,126 @@ package authority import ( "context" + "crypto" + "crypto/sha256" "crypto/tls" "crypto/x509" - "io/ioutil" - "path/filepath" + "encoding/hex" + "encoding/pem" + "fmt" + "net/url" + "regexp" + "strings" + "time" "github.com/pkg/errors" "github.com/smallstep/certificates/errs" - "go.step.sm/cli-utils/config" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/tlsutil" + "go.step.sm/crypto/x509util" "go.step.sm/linkedca" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) +const uuidPattern = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + type linkedCaClient struct { + renewer *tlsutil.Renewer client linkedca.MajordomoClient authorityID string } -func createLinkedCAClient(authorityID, endpoint string) (*linkedCaClient, error) { - base := filepath.Join(config.StepPath(), "linkedca") - rootFile := filepath.Join(base, "root_ca.crt") - certFile := filepath.Join(base, "linkedca.crt") - keyFile := filepath.Join(base, "linkedca.key") +type linkedCAClaims struct { + jose.Claims + SANs []string `json:"sans"` + SHA string `json:"sha"` +} + +func newLinkedCAClient(token string) (*linkedCaClient, error) { + tok, err := jose.ParseSigned(token) + if err != nil { + return nil, errors.Wrap(err, "error parsing token") + } - b, err := ioutil.ReadFile(rootFile) + var claims linkedCAClaims + if err := tok.UnsafeClaimsWithoutVerification(&claims); err != nil { + return nil, errors.Wrap(err, "error parsing token") + } + // Validate claims + if len(claims.Audience) != 1 { + return nil, errors.New("error parsing token: invalid aud claim") + } + if claims.SHA == "" { + return nil, errors.New("error parsing token: invalid sha claim") + } + // Get linkedCA endpoint from audience. + u, err := url.Parse(claims.Audience[0]) + if err != nil { + return nil, errors.New("error parsing token: invalid aud claim") + } + // Get authority from SANs + authority, err := getAuthority(claims.SANs) if err != nil { - return nil, errors.Wrap(err, "error reading linkedca root") + return nil, err } + + // Create csr to login with + signer, err := keyutil.GenerateDefaultSigner() + if err != nil { + return nil, err + } + csr, err := x509util.CreateCertificateRequest(claims.Subject, claims.SANs, signer) + if err != nil { + return nil, err + } + + // Get and verify root certificate + root, err := getRootCertificate(u.Host, claims.SHA) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(b) { - return nil, errors.Errorf("error reading %s: no certificates were found", rootFile) + pool.AddCert(root) + + // Login with majordomo and get certificates + cert, tlsConfig, err := login(authority, token, csr, signer, u.Host, pool) + if err != nil { + return nil, err } - conn, err := grpc.Dial(endpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ - RootCAs: pool, - GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, errors.Wrap(err, "error reading linkedca certificate") - } - return &cert, nil - }, - }))) + // Start TLS renewer and set the GetClientCertificate callback to it. + renewer, err := tlsutil.NewRenewer(cert, tlsConfig, func() (*tls.Certificate, *tls.Config, error) { + return login(authority, token, csr, signer, u.Host, pool) + }) if err != nil { - return nil, errors.Wrapf(err, "error connecting %s", endpoint) + return nil, err + } + tlsConfig.GetClientCertificate = renewer.GetClientCertificate + + // Start mTLS client + conn, err := grpc.Dial(u.Host, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + if err != nil { + return nil, errors.Wrapf(err, "error connecting %s", u.Host) } return &linkedCaClient{ + renewer: renewer, client: linkedca.NewMajordomoClient(conn), - authorityID: authorityID, + authorityID: authority, }, nil } +func (c *linkedCaClient) Run() { + c.renewer.Run() +} + +func (c *linkedCaClient) Stop() { + c.renewer.Stop() +} + func (c *linkedCaClient) CreateProvisioner(ctx context.Context, prov *linkedca.Provisioner) error { resp, err := c.client.CreateProvisioner(ctx, &linkedca.CreateProvisionerRequest{ Type: prov.Type, @@ -169,3 +236,154 @@ func (c *linkedCaClient) DeleteAdmin(ctx context.Context, id string) error { }) return errors.Wrap(err, "error deleting admin") } + +func getAuthority(sans []string) (string, error) { + for _, s := range sans { + if strings.HasPrefix(s, "urn:smallstep:authority:") { + if regexp.MustCompile(uuidPattern).MatchString(s[24:]) { + return s[24:], nil + } + } + } + return "", fmt.Errorf("error parsing token: invalid sans claim") +} + +// getRootCertificate creates an insecure majordomo client and returns the +// verified root certificate. +func getRootCertificate(endpoint, fingerprint string) (*x509.Certificate, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + conn, err := grpc.DialContext(ctx, endpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + InsecureSkipVerify: true, + }))) + if err != nil { + return nil, errors.Wrapf(err, "error connecting %s", endpoint) + } + + ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := linkedca.NewMajordomoClient(conn) + resp, err := client.GetRootCertificate(ctx, &linkedca.GetRootCertificateRequest{ + Fingerprint: fingerprint, + }) + if err != nil { + return nil, fmt.Errorf("error getting root certificate: %w", err) + } + + var block *pem.Block + b := []byte(resp.PemCertificate) + for len(b) > 0 { + block, b = pem.Decode(b) + if block == nil { + break + } + if block.Type != "CERTIFICATE" || len(block.Headers) != 0 { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("error parsing certificate: %w", err) + } + + // verify the sha256 + sum := sha256.Sum256(cert.Raw) + if !strings.EqualFold(fingerprint, hex.EncodeToString(sum[:])) { + return nil, fmt.Errorf("error verifying certificate: SHA256 fingerprint does not match") + } + + return cert, nil + } + + return nil, fmt.Errorf("error getting root certificate: certificate not found") +} + +// login creates a new majordomo client with just the root ca pool and returns +// the signed certificate and tls configuration. +func login(authority, token string, csr *x509.CertificateRequest, signer crypto.PrivateKey, endpoint string, rootCAs *x509.CertPool) (*tls.Certificate, *tls.Config, error) { + // Connect to majordomo + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + conn, err := grpc.DialContext(ctx, endpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + RootCAs: rootCAs, + }))) + if err != nil { + return nil, nil, errors.Wrapf(err, "error connecting %s", endpoint) + } + + // Login to get the signed certificate + ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := linkedca.NewMajordomoClient(conn) + resp, err := client.Login(ctx, &linkedca.LoginRequest{ + AuthorityId: authority, + Token: token, + PemCertificateRequest: string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csr.Raw, + })), + }) + if err != nil { + return nil, nil, errors.Wrapf(err, "error logging in %s", endpoint) + } + + // Parse login response + var block *pem.Block + var bundle []*x509.Certificate + rest := []byte(resp.PemCertificateChain) + for { + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + return nil, nil, errors.New("error decoding login response: pemCertificateChain is not a certificate bundle") + } + crt, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, errors.Wrap(err, "error parsing login response") + } + bundle = append(bundle, crt) + } + if len(bundle) == 0 { + return nil, nil, errors.New("error decoding login response: pemCertificateChain should not be empty") + } + + // Build tls.Certificate with PemCertificate and intermediates in the + // PemCertificateChain + cert := &tls.Certificate{ + PrivateKey: signer, + } + rest = []byte(resp.PemCertificate) + for { + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, errors.Wrap(err, "error parsing pemCertificate") + } + cert.Certificate = append(cert.Certificate, block.Bytes) + cert.Leaf = leaf + } + } + + // Add intermediates to the tls.Certificate + last := len(bundle) - 1 + for i := 0; i < last; i++ { + cert.Certificate = append(cert.Certificate, bundle[i].Raw) + } + + // Add root to the pool if it's not there yet + rootCAs.AddCert(bundle[last]) + + return cert, &tls.Config{ + RootCAs: rootCAs, + }, nil +} diff --git a/authority/options.go b/authority/options.go index 4e9fbdbc..6baeb2bc 100644 --- a/authority/options.go +++ b/authority/options.go @@ -196,6 +196,15 @@ func WithAdminDB(db admin.DB) Option { } } +// WithLinkedCAToken is an option to set the authentication token used to enable +// linked ca. +func WithLinkedCAToken(token string) Option { + return func(a *Authority) error { + a.linkedCAToken = token + return nil + } +} + func readCertificateBundle(pemCerts []byte) ([]*x509.Certificate, error) { var block *pem.Block var certs []*x509.Certificate diff --git a/ca/ca.go b/ca/ca.go index 4551286b..51d15bec 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -30,6 +30,7 @@ import ( type options struct { configFile string + linkedCAToken string password []byte issuerPassword []byte database db.AuthDB @@ -75,6 +76,13 @@ func WithDatabase(db db.AuthDB) Option { } } +// WithLinkedCAToken sets the token used to authenticate with the linkedca. +func WithLinkedCAToken(token string) Option { + return func(o *options) { + o.linkedCAToken = token + } +} + // CA is the type used to build the complete certificate authority. It builds // the HTTP server, set ups the middlewares and the HTTP handlers. type CA struct { @@ -111,6 +119,10 @@ func (ca *CA) Init(config *config.Config) (*CA, error) { } var opts []authority.Option + if ca.opts.linkedCAToken != "" { + opts = append(opts, authority.WithLinkedCAToken(ca.opts.linkedCAToken)) + } + if ca.opts.database != nil { opts = append(opts, authority.WithDatabase(ca.opts.database)) } @@ -326,6 +338,7 @@ func (ca *CA) Reload() error { newCA, err := New(config, WithPassword(ca.opts.password), WithIssuerPassword(ca.opts.issuerPassword), + WithLinkedCAToken(ca.opts.linkedCAToken), WithConfigFile(ca.opts.configFile), WithDatabase(ca.auth.GetDatabase()), ) diff --git a/commands/app.go b/commands/app.go index 8833726c..3b874ae8 100644 --- a/commands/app.go +++ b/commands/app.go @@ -38,6 +38,10 @@ certificate issuer private key used in the RA mode.`, Name: "resolver", Usage: "address of a DNS resolver to be used instead of the default.", }, + cli.StringFlag{ + Name: "token", + Usage: "token used to enable the linked ca.", + }, }, } @@ -46,6 +50,7 @@ func appAction(ctx *cli.Context) error { passFile := ctx.String("password-file") issuerPassFile := ctx.String("issuer-password-file") resolver := ctx.String("resolver") + token := ctx.String("token") // If zero cmd line args show help, if >1 cmd line args show error. if ctx.NArg() == 0 { @@ -88,7 +93,8 @@ func appAction(ctx *cli.Context) error { srv, err := ca.New(config, ca.WithConfigFile(configFile), ca.WithPassword(password), - ca.WithIssuerPassword(issuerPassword)) + ca.WithIssuerPassword(issuerPassword), + ca.WithLinkedCAToken(token)) if err != nil { fatal(err) } diff --git a/commands/login.go b/commands/login.go index 0206d073..8c0049ee 100644 --- a/commands/login.go +++ b/commands/login.go @@ -2,13 +2,18 @@ package commands import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/pem" + "fmt" "io/ioutil" + "net/url" "os" "path/filepath" "regexp" + "strings" "time" "github.com/pkg/errors" @@ -32,13 +37,14 @@ const uuidPattern = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4 type linkedCAClaims struct { jose.Claims SANs []string `json:"sans"` + SHA string `json:"sha"` } func init() { command.Register(cli.Command{ Name: "login", Usage: "create the certificates to authorize your Linked CA instance", - UsageText: `**step-ca login** **--token*= + UsageText: `**step-ca login** **--token*= [**--linkedca**=] [**--root**=]`, Action: loginAction, Description: `**step-ca login** ... @@ -50,16 +56,7 @@ func init() { Flags: []cli.Flag{ cli.StringFlag{ Name: "token", - Usage: "The one-time used to authenticate with the Linked CA in order to create the initial credentials", - }, - cli.StringFlag{ - Name: "linkedca", - Usage: "The linkedca to connect to.", - Value: loginEndpoint, - }, - cli.StringFlag{ - Name: "root", - Usage: "The root certificate used to authenticate with the linkedca endpoint.", + Usage: "The used to authenticate with the Linked CA in order to create the initial credentials", }, }, }) @@ -70,18 +67,9 @@ func loginAction(ctx *cli.Context) error { return err } - args := ctx.Args() - authority := args[0] token := ctx.String("token") - endpoint := ctx.String("linkedca") - rx := regexp.MustCompile(uuidPattern) - switch { - case !rx.MatchString(authority): - return errors.Errorf("positional argument %s is not a valid uuid", authority) - case token == "": + if token == "" { return errs.RequiredFlag(ctx, "token") - case endpoint == "": - return errs.RequiredFlag(ctx, "linkedca") } var claims linkedCAClaims @@ -90,50 +78,58 @@ func loginAction(ctx *cli.Context) error { return errors.Wrap(err, "error parsing token") } if err := tok.UnsafeClaimsWithoutVerification(&claims); err != nil { - return errors.Wrap(err, "error parsing payload") + return errors.Wrap(err, "error parsing token") } - - signer, err := keyutil.GenerateDefaultSigner() + if len(claims.Audience) != 0 { + return errors.Wrap(err, "error parsing token: invalid aud claim") + } + u, err := url.Parse(claims.Audience[0]) if err != nil { - return err + return errors.Wrap(err, "error parsing token: invalid aud claim") } - - csr, err := x509util.CreateCertificateRequest(claims.Subject, claims.SANs, signer) + if claims.SHA == "" { + return errors.Wrap(err, "error parsing token: invalid sha claim") + } + authority, err := getAuthority(claims.SANs) if err != nil { return err } - block, err := pemutil.Serialize(csr) + + // Get and verify root certificate + root, err := getRootCertificate(u.Host, claims.SHA) if err != nil { return err } - var options []grpc.DialOption - if root := ctx.String("root"); root != "" { - b, err := ioutil.ReadFile(root) - if err != nil { - return errors.Wrap(err, "error reading file") - } + pool := x509.NewCertPool() + pool.AddCert(root) - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(b) { - return errors.Errorf("error reading %s: no certificates were found", root) - } + gctx, cancel := context.WithCancel(context.Background()) + defer cancel() - options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ - RootCAs: pool, - }))) - } else { - options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + conn, err := grpc.DialContext(gctx, u.Host, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + RootCAs: pool, + }))) + if err != nil { + return errors.Wrapf(err, "error connecting %s", u.Host) } - gctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() + // Create csr + signer, err := keyutil.GenerateDefaultSigner() + if err != nil { + return err + } - conn, err := grpc.DialContext(gctx, endpoint, options...) + csr, err := x509util.CreateCertificateRequest(claims.Subject, claims.SANs, signer) + if err != nil { + return err + } + block, err := pemutil.Serialize(csr) if err != nil { - return errors.Wrapf(err, "error connecting %s", endpoint) + return err } + // Perform login and get signed certificate client := linkedca.NewMajordomoClient(conn) gctx, cancel = context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -180,6 +176,67 @@ func loginAction(ctx *cli.Context) error { return nil } +func getAuthority(sans []string) (string, error) { + for _, s := range sans { + if strings.HasPrefix(s, "urn:smallstep:authority:") { + if regexp.MustCompile(uuidPattern).MatchString(s[24:]) { + return s[24:], nil + } + } + } + return "", fmt.Errorf("error parsing token: invalid sans claim") +} + +func getRootCertificate(endpoint, fingerprint string) (*x509.Certificate, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + conn, err := grpc.DialContext(ctx, endpoint, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + InsecureSkipVerify: true, + }))) + if err != nil { + return nil, errors.Wrapf(err, "error connecting %s", endpoint) + } + + ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := linkedca.NewMajordomoClient(conn) + resp, err := client.GetRootCertificate(ctx, &linkedca.GetRootCertificateRequest{ + Fingerprint: fingerprint, + }) + if err != nil { + return nil, fmt.Errorf("error getting root certificate: %w", err) + } + + var block *pem.Block + b := []byte(resp.PemCertificate) + for len(b) > 0 { + block, b = pem.Decode(b) + if block == nil { + break + } + if block.Type != "CERTIFICATE" || len(block.Headers) != 0 { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("error parsing certificate: %w", err) + } + + // verify the sha256 + sum := sha256.Sum256(cert.Raw) + if !strings.EqualFold(fingerprint, hex.EncodeToString(sum[:])) { + return nil, fmt.Errorf("error verifying certificate: SHA256 fingerprint does not match") + } + + return cert, nil + } + + return nil, fmt.Errorf("error getting root certificate: certificate not found") +} + func parseLoginResponse(resp *linkedca.LoginResponse) ([]byte, []byte, error) { var block *pem.Block var bundle []*x509.Certificate diff --git a/go.mod b/go.mod index 591c35ce..86e0db73 100644 --- a/go.mod +++ b/go.mod @@ -40,9 +40,8 @@ require ( ) // replace github.com/smallstep/nosql => ../nosql - //replace go.step.sm/crypto => ../crypto - //replace go.step.sm/cli-utils => ../cli-utils +replace go.step.sm/linkedca => ../linkedca replace go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 => github.com/omorsi/pkcs7 v0.0.0-20210217142924-a7b80a2a8568