diff --git a/CHANGELOG.md b/CHANGELOG.md index 59829021..cb552de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. X.509 certificates. - Added provisioner webhooks for augmenting template data and authorizing certificate requests before signing. - Added automatic migration of provisioners when enabling remote managment. +- Added experimental support for CRLs. ### Fixed - MySQL DSN parsing issues fixed with upgrade to [smallstep/nosql@v0.5.0](https://github.com/smallstep/nosql/releases/tag/v0.5.0). diff --git a/api/api.go b/api/api.go index 8bf7b209..fda27c42 100644 --- a/api/api.go +++ b/api/api.go @@ -49,6 +49,7 @@ type Authority interface { GetRoots() ([]*x509.Certificate, error) GetFederation() ([]*x509.Certificate, error) Version() authority.Version + GetCertificateRevocationList() ([]byte, error) } // mustAuthority will be replaced on unit tests. @@ -267,6 +268,7 @@ func Route(r Router) { r.MethodFunc("POST", "/renew", Renew) r.MethodFunc("POST", "/rekey", Rekey) r.MethodFunc("POST", "/revoke", Revoke) + r.MethodFunc("GET", "/crl", CRL) r.MethodFunc("GET", "/provisioners", Provisioners) r.MethodFunc("GET", "/provisioners/{kid}/encrypted-key", ProvisionerKey) r.MethodFunc("GET", "/roots", Roots) diff --git a/api/api_test.go b/api/api_test.go index 4c84871a..abbbbd5b 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -199,6 +199,7 @@ type mockAuthority struct { getEncryptedKey func(kid string) (string, error) getRoots func() ([]*x509.Certificate, error) getFederation func() ([]*x509.Certificate, error) + getCRL func() ([]byte, error) signSSH func(ctx context.Context, key ssh.PublicKey, opts provisioner.SignSSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) signSSHAddUser func(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) renewSSH func(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error) @@ -212,6 +213,14 @@ type mockAuthority struct { version func() authority.Version } +func (m *mockAuthority) GetCertificateRevocationList() ([]byte, error) { + if m.getCRL != nil { + return m.getCRL() + } + + return m.ret1.([]byte), m.err +} + // TODO: remove once Authorize is deprecated. func (m *mockAuthority) Authorize(ctx context.Context, ott string) ([]provisioner.SignOption, error) { if m.authorize != nil { @@ -772,6 +781,45 @@ func (m *mockProvisioner) AuthorizeSSHRekey(ctx context.Context, token string) ( return m.ret1.(*ssh.Certificate), m.ret2.([]provisioner.SignOption), m.err } +func Test_CRLGeneration(t *testing.T) { + tests := []struct { + name string + err error + statusCode int + expected []byte + }{ + {"empty", nil, http.StatusOK, nil}, + } + + chiCtx := chi.NewRouteContext() + req := httptest.NewRequest("GET", "http://example.com/crl", nil) + req = req.WithContext(context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockMustAuthority(t, &mockAuthority{ret1: tt.expected, err: tt.err}) + w := httptest.NewRecorder() + CRL(w, req) + res := w.Result() + + if res.StatusCode != tt.statusCode { + t.Errorf("caHandler.CRL StatusCode = %d, wants %d", res.StatusCode, tt.statusCode) + } + + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Errorf("caHandler.Root unexpected error = %v", err) + } + if tt.statusCode == 200 { + if !bytes.Equal(bytes.TrimSpace(body), tt.expected) { + t.Errorf("caHandler.Root CRL = %s, wants %s", body, tt.expected) + } + } + }) + } +} + func Test_caHandler_Route(t *testing.T) { type fields struct { Authority Authority diff --git a/api/crl.go b/api/crl.go new file mode 100644 index 00000000..1a4d309a --- /dev/null +++ b/api/crl.go @@ -0,0 +1,32 @@ +package api + +import ( + "encoding/pem" + "net/http" + + "github.com/smallstep/certificates/api/render" +) + +// CRL is an HTTP handler that returns the current CRL in DER or PEM format +func CRL(w http.ResponseWriter, r *http.Request) { + crlBytes, err := mustAuthority(r.Context()).GetCertificateRevocationList() + if err != nil { + render.Error(w, err) + return + } + + _, formatAsPEM := r.URL.Query()["pem"] + if formatAsPEM { + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "X509 CRL", + Bytes: crlBytes, + }) + w.Header().Add("Content-Type", "application/x-pem-file") + w.Header().Add("Content-Disposition", "attachment; filename=\"crl.pem\"") + w.Write(pemBytes) + } else { + w.Header().Add("Content-Type", "application/pkix-crl") + w.Header().Add("Content-Disposition", "attachment; filename=\"crl.der\"") + w.Write(crlBytes) + } +} diff --git a/authority/authority.go b/authority/authority.go index 1efe1a7c..3fb6f51f 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -73,6 +73,11 @@ type Authority struct { sshCAUserFederatedCerts []ssh.PublicKey sshCAHostFederatedCerts []ssh.PublicKey + // CRL vars + crlTicker *time.Ticker + crlStopper chan struct{} + crlMutex sync.Mutex + // If true, do not re-initialize initOnce bool startTime time.Time @@ -711,6 +716,18 @@ func (a *Authority) init() error { a.templates.Data["Step"] = tmplVars } + // Start the CRL generator, we can assume the configuration is validated. + if a.config.CRL.IsEnabled() { + // Default cache duration to the default one + if v := a.config.CRL.CacheDuration; v == nil || v.Duration <= 0 { + a.config.CRL.CacheDuration = config.DefaultCRLCacheDuration + } + // Start CRL generator + if err := a.startCRLGenerator(); err != nil { + return err + } + } + // JWT numeric dates are seconds. a.startTime = time.Now().Truncate(time.Second) // Set flag indicating that initialization has been completed, and should @@ -777,6 +794,11 @@ func (a *Authority) IsAdminAPIEnabled() bool { // Shutdown safely shuts down any clients, databases, etc. held by the Authority. func (a *Authority) Shutdown() error { + if a.crlTicker != nil { + a.crlTicker.Stop() + close(a.crlStopper) + } + if err := a.keyManager.Close(); err != nil { log.Printf("error closing the key manager: %v", err) } @@ -785,6 +807,11 @@ func (a *Authority) Shutdown() error { // CloseForReload closes internal services, to allow a safe reload. func (a *Authority) CloseForReload() { + if a.crlTicker != nil { + a.crlTicker.Stop() + close(a.crlStopper) + } + if err := a.keyManager.Close(); err != nil { log.Printf("error closing the key manager: %v", err) } @@ -825,11 +852,49 @@ func (a *Authority) requiresSCEPService() bool { return false } -// GetSCEPService returns the configured SCEP Service -// TODO: this function is intended to exist temporarily -// in order to make SCEP work more easily. It can be -// made more correct by using the right interfaces/abstractions -// after it works as expected. +// GetSCEPService returns the configured SCEP Service. +// +// TODO: this function is intended to exist temporarily in order to make SCEP +// work more easily. It can be made more correct by using the right +// interfaces/abstractions after it works as expected. func (a *Authority) GetSCEPService() *scep.Service { return a.scepService } + +func (a *Authority) startCRLGenerator() error { + if !a.config.CRL.IsEnabled() { + return nil + } + + // Check that there is a valid CRL in the DB right now. If it doesn't exist + // or is expired, generate one now + _, ok := a.db.(db.CertificateRevocationListDB) + if !ok { + return errors.Errorf("CRL Generation requested, but database does not support CRL generation") + } + + // Always create a new CRL on startup in case the CA has been down and the + // time to next expected CRL update is less than the cache duration. + if err := a.GenerateCertificateRevocationList(); err != nil { + return errors.Wrap(err, "could not generate a CRL") + } + + a.crlStopper = make(chan struct{}, 1) + a.crlTicker = time.NewTicker(a.config.CRL.TickerDuration()) + + go func() { + for { + select { + case <-a.crlTicker.C: + log.Println("Regenerating CRL") + if err := a.GenerateCertificateRevocationList(); err != nil { + log.Printf("error regenerating the CRL: %v", err) + } + case <-a.crlStopper: + return + } + } + }() + + return nil +} diff --git a/authority/config/config.go b/authority/config/config.go index 7ec1d12e..f58a3354 100644 --- a/authority/config/config.go +++ b/authority/config/config.go @@ -35,8 +35,13 @@ var ( // DefaultEnableSSHCA enable SSH CA features per provisioner or globally // for all provisioners. DefaultEnableSSHCA = false - // GlobalProvisionerClaims default claims for the Authority. Can be overridden - // by provisioner specific claims. + // DefaultCRLCacheDuration is the default cache duration for the CRL. + DefaultCRLCacheDuration = &provisioner.Duration{Duration: 24 * time.Hour} + // DefaultCRLExpiredDuration is the default duration in which expired + // certificates will remain in the CRL after expiration. + DefaultCRLExpiredDuration = time.Hour + // GlobalProvisionerClaims is the default duration that expired certificates + // remain in the CRL after expiration. GlobalProvisionerClaims = provisioner.Claims{ MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute}, // TLS certs MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, @@ -72,12 +77,62 @@ type Config struct { Password string `json:"password,omitempty"` Templates *templates.Templates `json:"templates,omitempty"` CommonName string `json:"commonName,omitempty"` + CRL *CRLConfig `json:"crl,omitempty"` SkipValidation bool `json:"-"` // Keeps record of the filename the Config is read from loadedFromFilepath string } +// CRLConfig represents config options for CRL generation +type CRLConfig struct { + Enabled bool `json:"enabled"` + GenerateOnRevoke bool `json:"generateOnRevoke,omitempty"` + CacheDuration *provisioner.Duration `json:"cacheDuration,omitempty"` + RenewPeriod *provisioner.Duration `json:"renewPeriod,omitempty"` +} + +// IsEnabled returns if the CRL is enabled. +func (c *CRLConfig) IsEnabled() bool { + return c != nil && c.Enabled +} + +// Validate validates the CRL configuration. +func (c *CRLConfig) Validate() error { + if c == nil { + return nil + } + + if c.CacheDuration != nil && c.CacheDuration.Duration < 0 { + return errors.New("crl.cacheDuration must be greater than or equal to 0") + } + + if c.RenewPeriod != nil && c.RenewPeriod.Duration < 0 { + return errors.New("crl.renewPeriod must be greater than or equal to 0") + } + + if c.RenewPeriod != nil && c.CacheDuration != nil && + c.RenewPeriod.Duration > c.CacheDuration.Duration { + return errors.New("crl.cacheDuration must be greater than or equal to crl.renewPeriod") + } + + return nil +} + +// TickerDuration the renewal ticker duration. This is set by renewPeriod, of it +// is not set is ~2/3 of cacheDuration. +func (c *CRLConfig) TickerDuration() time.Duration { + if !c.IsEnabled() { + return 0 + } + + if c.RenewPeriod != nil && c.RenewPeriod.Duration > 0 { + return c.RenewPeriod.Duration + } + + return (c.CacheDuration.Duration / 3) * 2 +} + // ASN1DN contains ASN1.DN attributes that are used in Subject and Issuer // x509 Certificate blocks. type ASN1DN struct { @@ -190,6 +245,9 @@ func (c *Config) Init() { if c.CommonName == "" { c.CommonName = "Step Online CA" } + if c.CRL != nil && c.CRL.Enabled && c.CRL.CacheDuration == nil { + c.CRL.CacheDuration = DefaultCRLCacheDuration + } c.AuthorityConfig.init() } @@ -300,6 +358,11 @@ func (c *Config) Validate() error { return err } + // Validate crl config: nil is ok + if err := c.CRL.Validate(); err != nil { + return err + } + return c.AuthorityConfig.Validate(c.GetAudiences()) } diff --git a/authority/tls.go b/authority/tls.go index 6c176f06..b5d85074 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -5,11 +5,13 @@ import ( "crypto" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/asn1" "encoding/base64" "encoding/json" "encoding/pem" "fmt" + "math/big" "net" "net/http" "strings" @@ -29,6 +31,7 @@ import ( "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/webhook" + "github.com/smallstep/nosql/database" ) // GetTLSOptions returns the tls options configured. @@ -36,8 +39,11 @@ func (a *Authority) GetTLSOptions() *config.TLSOptions { return a.config.TLS } -var oidAuthorityKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 35} -var oidSubjectKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 14} +var ( + oidAuthorityKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 35} + oidSubjectKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 14} + oidExtensionIssuingDistributionPoint = asn1.ObjectIdentifier{2, 5, 29, 28} +) func withDefaultASN1DN(def *config.ASN1DN) provisioner.CertificateModifierFunc { return func(crt *x509.Certificate, opts provisioner.SignOptions) error { @@ -512,16 +518,23 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error RevokedAt: time.Now().UTC(), } - var ( - p provisioner.Interface - err error - ) + // For X509 CRLs attempt to get the expiration date of the certificate. + if provisioner.MethodFromContext(ctx) == provisioner.RevokeMethod { + if revokeOpts.Crt == nil { + cert, err := a.db.GetCertificate(revokeOpts.Serial) + if err == nil { + rci.ExpiresAt = cert.NotAfter + } + } else { + rci.ExpiresAt = revokeOpts.Crt.NotAfter + } + } + // If not mTLS nor ACME, then get the TokenID of the token. if !(revokeOpts.MTLS || revokeOpts.ACME) { token, err := jose.ParseSigned(revokeOpts.OTT) if err != nil { - return errs.Wrap(http.StatusUnauthorized, err, - "authority.Revoke; error parsing token", opts...) + return errs.Wrap(http.StatusUnauthorized, err, "authority.Revoke; error parsing token", opts...) } // Get claims w/out verification. @@ -531,28 +544,43 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error } // This method will also validate the audiences for JWK provisioners. - p, err = a.LoadProvisionerByToken(token, &claims.Claims) + p, err := a.LoadProvisionerByToken(token, &claims.Claims) if err != nil { return err } rci.ProvisionerID = p.GetID() rci.TokenID, err = p.GetTokenID(revokeOpts.OTT) if err != nil && !errors.Is(err, provisioner.ErrAllowTokenReuse) { - return errs.Wrap(http.StatusInternalServerError, err, - "authority.Revoke; could not get ID for token") + return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke; could not get ID for token") } opts = append(opts, errs.WithKeyVal("provisionerID", rci.ProvisionerID), errs.WithKeyVal("tokenID", rci.TokenID), ) - } else if p, err = a.LoadProvisionerByCertificate(revokeOpts.Crt); err == nil { + } else if p, err := a.LoadProvisionerByCertificate(revokeOpts.Crt); err == nil { // Load the Certificate provisioner if one exists. rci.ProvisionerID = p.GetID() opts = append(opts, errs.WithKeyVal("provisionerID", rci.ProvisionerID)) } + failRevoke := func(err error) error { + switch { + case errors.Is(err, db.ErrNotImplemented): + return errs.NotImplemented("authority.Revoke; no persistence layer configured", opts...) + case errors.Is(err, db.ErrAlreadyExists): + return errs.ApplyOptions( + errs.BadRequest("certificate with serial number '%s' is already revoked", rci.Serial), + opts..., + ) + default: + return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke", opts...) + } + } + if provisioner.MethodFromContext(ctx) == provisioner.SSHRevokeMethod { - err = a.revokeSSH(nil, rci) + if err := a.revokeSSH(nil, rci); err != nil { + return failRevoke(err) + } } else { // Revoke an X.509 certificate using CAS. If the certificate is not // provided we will try to read it from the db. If the read fails we @@ -567,7 +595,7 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error // CAS operation, note that SoftCAS (default) is a noop. // The revoke happens when this is stored in the db. - _, err = a.x509CAService.RevokeCertificate(&casapi.RevokeCertificateRequest{ + _, err := a.x509CAService.RevokeCertificate(&casapi.RevokeCertificateRequest{ Certificate: revokedCert, SerialNumber: rci.Serial, Reason: rci.Reason, @@ -579,21 +607,20 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error } // Save as revoked in the Db. - err = a.revoke(revokedCert, rci) - } - switch { - case err == nil: - return nil - case errors.Is(err, db.ErrNotImplemented): - return errs.NotImplemented("authority.Revoke; no persistence layer configured", opts...) - case errors.Is(err, db.ErrAlreadyExists): - return errs.ApplyOptions( - errs.BadRequest("certificate with serial number '%s' is already revoked", rci.Serial), - opts..., - ) - default: - return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke", opts...) + if err := a.revoke(revokedCert, rci); err != nil { + return failRevoke(err) + } + + // Generate a new CRL so CRL requesters will always get an up-to-date + // CRL whenever they request it. + if a.config.CRL.IsEnabled() && a.config.CRL.GenerateOnRevoke { + if err := a.GenerateCertificateRevocationList(); err != nil { + return errs.Wrap(http.StatusInternalServerError, err, "authority.Revoke", opts...) + } + } } + + return nil } func (a *Authority) revoke(crt *x509.Certificate, rci *db.RevokedCertificateInfo) error { @@ -614,6 +641,137 @@ func (a *Authority) revokeSSH(crt *ssh.Certificate, rci *db.RevokedCertificateIn return a.db.RevokeSSH(rci) } +// GetCertificateRevocationList will return the currently generated CRL from the DB, or a not implemented +// error if the underlying AuthDB does not support CRLs +func (a *Authority) GetCertificateRevocationList() ([]byte, error) { + if !a.config.CRL.IsEnabled() { + return nil, errs.Wrap(http.StatusNotFound, errors.Errorf("Certificate Revocation Lists are not enabled"), "authority.GetCertificateRevocationList") + } + + crlDB, ok := a.db.(db.CertificateRevocationListDB) + if !ok { + return nil, errs.Wrap(http.StatusNotImplemented, errors.Errorf("Database does not support Certificate Revocation Lists"), "authority.GetCertificateRevocationList") + } + + crlInfo, err := crlDB.GetCRL() + if err != nil { + return nil, errs.Wrap(http.StatusInternalServerError, err, "authority.GetCertificateRevocationList") + } + + return crlInfo.DER, nil +} + +// GenerateCertificateRevocationList generates a DER representation of a signed CRL and stores it in the +// database. Returns nil if CRL generation has been disabled in the config +func (a *Authority) GenerateCertificateRevocationList() error { + if !a.config.CRL.IsEnabled() { + return nil + } + + crlDB, ok := a.db.(db.CertificateRevocationListDB) + if !ok { + return errors.Errorf("Database does not support CRL generation") + } + + // some CAS may not implement the CRLGenerator interface, so check before we proceed + caCRLGenerator, ok := a.x509CAService.(casapi.CertificateAuthorityCRLGenerator) + if !ok { + return errors.Errorf("CA does not support CRL Generation") + } + + // use a mutex to ensure only one CRL is generated at a time to avoid + // concurrency issues + a.crlMutex.Lock() + defer a.crlMutex.Unlock() + + crlInfo, err := crlDB.GetCRL() + if err != nil && !database.IsErrNotFound(err) { + return errors.Wrap(err, "could not retrieve CRL from database") + } + + now := time.Now().Truncate(time.Second).UTC() + revokedList, err := crlDB.GetRevokedCertificates() + if err != nil { + return errors.Wrap(err, "could not retrieve revoked certificates list from database") + } + + // Number is a monotonically increasing integer (essentially the CRL version + // number) that we need to keep track of and increase every time we generate + // a new CRL + var bn big.Int + if crlInfo != nil { + bn.SetInt64(crlInfo.Number + 1) + } + + // Convert our database db.RevokedCertificateInfo types into the pkix + // representation ready for the CAS to sign it + var revokedCertificates []pkix.RevokedCertificate + skipExpiredTime := now.Add(-config.DefaultCRLExpiredDuration) + for _, revokedCert := range *revokedList { + // skip expired certificates + if !revokedCert.ExpiresAt.IsZero() && revokedCert.ExpiresAt.Before(skipExpiredTime) { + continue + } + + var sn big.Int + sn.SetString(revokedCert.Serial, 10) + revokedCertificates = append(revokedCertificates, pkix.RevokedCertificate{ + SerialNumber: &sn, + RevocationTime: revokedCert.RevokedAt, + Extensions: nil, + }) + } + + var updateDuration time.Duration + if a.config.CRL.CacheDuration != nil { + updateDuration = a.config.CRL.CacheDuration.Duration + } else if crlInfo != nil { + updateDuration = crlInfo.Duration + } + + // Create a RevocationList representation ready for the CAS to sign + // TODO: allow SignatureAlgorithm to be specified? + revocationList := x509.RevocationList{ + SignatureAlgorithm: 0, + RevokedCertificates: revokedCertificates, + Number: &bn, + ThisUpdate: now, + NextUpdate: now.Add(updateDuration), + } + + // Add distribution point. + // + // Note that this is currently using the port 443 by default. + fullName := a.config.Audience("/1.0/crl")[0] + if b, err := marshalDistributionPoint(fullName, false); err == nil { + revocationList.ExtraExtensions = []pkix.Extension{ + {Id: oidExtensionIssuingDistributionPoint, Value: b}, + } + } + + certificateRevocationList, err := caCRLGenerator.CreateCRL(&casapi.CreateCRLRequest{RevocationList: &revocationList}) + if err != nil { + return errors.Wrap(err, "could not create CRL") + } + + // Create a new db.CertificateRevocationListInfo, which stores the new Number we just generated, the + // expiry time, duration, and the DER-encoded CRL + newCRLInfo := db.CertificateRevocationListInfo{ + Number: bn.Int64(), + ExpiresAt: revocationList.NextUpdate, + DER: certificateRevocationList.CRL, + Duration: updateDuration, + } + + // Store the CRL in the database ready for retrieval by api endpoints + err = crlDB.StoreCRL(&newCRLInfo) + if err != nil { + return errors.Wrap(err, "could not store CRL in database") + } + + return nil +} + // GetTLSCertificate creates a new leaf certificate to be used by the CA HTTPS server. func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { fatal := func(err error) (*tls.Certificate, error) { @@ -707,6 +865,33 @@ func (a *Authority) GetTLSCertificate() (*tls.Certificate, error) { return &tlsCrt, nil } +// RFC 5280, 5.2.5 +type distributionPoint struct { + DistributionPoint distributionPointName `asn1:"optional,tag:0"` + OnlyContainsUserCerts bool `asn1:"optional,tag:1"` + OnlyContainsCACerts bool `asn1:"optional,tag:2"` + OnlySomeReasons asn1.BitString `asn1:"optional,tag:3"` + IndirectCRL bool `asn1:"optional,tag:4"` + OnlyContainsAttributeCerts bool `asn1:"optional,tag:5"` +} + +type distributionPointName struct { + FullName []asn1.RawValue `asn1:"optional,tag:0"` + RelativeName pkix.RDNSequence `asn1:"optional,tag:1"` +} + +func marshalDistributionPoint(fullName string, isCA bool) ([]byte, error) { + return asn1.Marshal(distributionPoint{ + DistributionPoint: distributionPointName{ + FullName: []asn1.RawValue{ + {Class: 2, Tag: 6, Bytes: []byte(fullName)}, + }, + }, + OnlyContainsUserCerts: !isCA, + OnlyContainsCACerts: isCA, + }) +} + // templatingError tries to extract more information about the cause of // an error related to (most probably) malformed template data and adds // this to the error message. diff --git a/authority/tls_test.go b/authority/tls_test.go index 53d0401a..918adbdc 100644 --- a/authority/tls_test.go +++ b/authority/tls_test.go @@ -26,11 +26,13 @@ import ( "github.com/smallstep/assert" "github.com/smallstep/certificates/api/render" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/policy" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/cas/softcas" "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" + "github.com/smallstep/nosql/database" ) var ( @@ -1498,7 +1500,7 @@ func TestAuthority_Revoke(t *testing.T) { return true, nil }, MGetCertificate: func(sn string) (*x509.Certificate, error) { - return nil, nil + return nil, errors.New("not found") }, Err: errors.New("force"), })) @@ -1538,7 +1540,7 @@ func TestAuthority_Revoke(t *testing.T) { return true, nil }, MGetCertificate: func(sn string) (*x509.Certificate, error) { - return nil, nil + return nil, errors.New("not found") }, Err: db.ErrAlreadyExists, })) @@ -1807,3 +1809,148 @@ func TestAuthority_constraints(t *testing.T) { }) } } + +func TestAuthority_CRL(t *testing.T) { + reasonCode := 2 + reason := "bob was let go" + validIssuer := "step-cli" + validAudience := testAudiences.Revoke + now := time.Now().UTC() + // + jwk, err := jose.ReadKey("testdata/secrets/step_cli_key_priv.jwk", jose.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + // + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", jwk.KeyID)) + assert.FatalError(t, err) + + crlCtx := provisioner.NewContextWithMethod(context.Background(), provisioner.RevokeMethod) + + var crlStore db.CertificateRevocationListInfo + var revokedList []db.RevokedCertificateInfo + + type test struct { + auth *Authority + ctx context.Context + expected []string + err error + } + tests := map[string]func() test{ + "fail/empty-crl": func() test { + a := testAuthority(t, WithDatabase(&db.MockAuthDB{ + MUseToken: func(id, tok string) (bool, error) { + return true, nil + }, + MGetCertificate: func(sn string) (*x509.Certificate, error) { + return nil, errors.New("not found") + }, + MStoreCRL: func(i *db.CertificateRevocationListInfo) error { + crlStore = *i + return nil + }, + MGetCRL: func() (*db.CertificateRevocationListInfo, error) { + return nil, database.ErrNotFound + }, + MGetRevokedCertificates: func() (*[]db.RevokedCertificateInfo, error) { + return &revokedList, nil + }, + MRevoke: func(rci *db.RevokedCertificateInfo) error { + revokedList = append(revokedList, *rci) + return nil + }, + })) + a.config.CRL = &config.CRLConfig{ + Enabled: true, + } + + return test{ + auth: a, + ctx: crlCtx, + expected: nil, + err: database.ErrNotFound, + } + }, + "ok/crl-full": func() test { + a := testAuthority(t, WithDatabase(&db.MockAuthDB{ + MUseToken: func(id, tok string) (bool, error) { + return true, nil + }, + MGetCertificate: func(sn string) (*x509.Certificate, error) { + return nil, errors.New("not found") + }, + MStoreCRL: func(i *db.CertificateRevocationListInfo) error { + crlStore = *i + return nil + }, + MGetCRL: func() (*db.CertificateRevocationListInfo, error) { + return &crlStore, nil + }, + MGetRevokedCertificates: func() (*[]db.RevokedCertificateInfo, error) { + return &revokedList, nil + }, + MRevoke: func(rci *db.RevokedCertificateInfo) error { + revokedList = append(revokedList, *rci) + return nil + }, + })) + a.config.CRL = &config.CRLConfig{ + Enabled: true, + GenerateOnRevoke: true, + } + + var ex []string + + for i := 0; i < 100; i++ { + sn := fmt.Sprintf("%v", i) + + cl := jose.Claims{ + Subject: fmt.Sprintf("sn-%v", i), + Issuer: validIssuer, + NotBefore: jose.NewNumericDate(now), + Expiry: jose.NewNumericDate(now.Add(time.Minute)), + Audience: validAudience, + ID: sn, + } + raw, err := jose.Signed(sig).Claims(cl).CompactSerialize() + assert.FatalError(t, err) + err = a.Revoke(crlCtx, &RevokeOptions{ + Serial: sn, + ReasonCode: reasonCode, + Reason: reason, + OTT: raw, + }) + + assert.FatalError(t, err) + + ex = append(ex, sn) + } + + return test{ + auth: a, + ctx: crlCtx, + expected: ex, + } + }, + } + for name, f := range tests { + tc := f() + t.Run(name, func(t *testing.T) { + if crlBytes, err := tc.auth.GetCertificateRevocationList(); err == nil { + crl, parseErr := x509.ParseCRL(crlBytes) + if parseErr != nil { + t.Errorf("x509.ParseCertificateRequest() error = %v, wantErr %v", parseErr, nil) + return + } + + var cmpList []string + for _, c := range crl.TBSCertList.RevokedCertificates { + cmpList = append(cmpList, c.SerialNumber.String()) + } + + assert.Equals(t, cmpList, tc.expected) + } else { + assert.NotNil(t, tc.err, err.Error()) + } + }) + } +} diff --git a/cas/apiv1/requests.go b/cas/apiv1/requests.go index d93cf38d..eff53a77 100644 --- a/cas/apiv1/requests.go +++ b/cas/apiv1/requests.go @@ -154,3 +154,13 @@ type CreateCertificateAuthorityResponse struct { PrivateKey crypto.PrivateKey Signer crypto.Signer } + +// CreateCRLRequest is the request to create a Certificate Revocation List. +type CreateCRLRequest struct { + RevocationList *x509.RevocationList +} + +// CreateCRLResponse is the response to a Certificate Revocation List request. +type CreateCRLResponse struct { + CRL []byte //the CRL in DER format +} diff --git a/cas/apiv1/services.go b/cas/apiv1/services.go index ed611295..f1d02b3c 100644 --- a/cas/apiv1/services.go +++ b/cas/apiv1/services.go @@ -14,6 +14,12 @@ type CertificateAuthorityService interface { RevokeCertificate(req *RevokeCertificateRequest) (*RevokeCertificateResponse, error) } +// CertificateAuthorityCRLGenerator is an optional interface implemented by CertificateAuthorityService +// that has a method to create a CRL +type CertificateAuthorityCRLGenerator interface { + CreateCRL(req *CreateCRLRequest) (*CreateCRLResponse, error) +} + // CertificateAuthorityGetter is an interface implemented by a // CertificateAuthorityService that has a method to get the root certificate. type CertificateAuthorityGetter interface { diff --git a/cas/softcas/softcas.go b/cas/softcas/softcas.go index e00a36de..6eae9e9e 100644 --- a/cas/softcas/softcas.go +++ b/cas/softcas/softcas.go @@ -3,6 +3,7 @@ package softcas import ( "context" "crypto" + "crypto/rand" "crypto/rsa" "crypto/x509" "time" @@ -132,6 +133,20 @@ func (c *SoftCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1 }, nil } +// CreateCRL will create a new CRL based on the RevocationList passed to it +func (c *SoftCAS) CreateCRL(req *apiv1.CreateCRLRequest) (*apiv1.CreateCRLResponse, error) { + certChain, signer, err := c.getCertSigner() + if err != nil { + return nil, err + } + revocationListBytes, err := x509.CreateRevocationList(rand.Reader, req.RevocationList, certChain[0], signer) + if err != nil { + return nil, err + } + + return &apiv1.CreateCRLResponse{CRL: revocationListBytes}, nil +} + // CreateCertificateAuthority creates a root or an intermediate certificate. func (c *SoftCAS) CreateCertificateAuthority(req *apiv1.CreateCertificateAuthorityRequest) (*apiv1.CreateCertificateAuthorityResponse, error) { switch { diff --git a/db/db.go b/db/db.go index 05f10793..784c75f4 100644 --- a/db/db.go +++ b/db/db.go @@ -19,6 +19,7 @@ var ( certsTable = []byte("x509_certs") certsDataTable = []byte("x509_certs_data") revokedCertsTable = []byte("revoked_x509_certs") + crlTable = []byte("x509_crl") revokedSSHCertsTable = []byte("revoked_ssh_certs") usedOTTTable = []byte("used_ott") sshCertsTable = []byte("ssh_certs") @@ -27,6 +28,9 @@ var ( sshHostPrincipalsTable = []byte("ssh_host_principals") ) +var crlKey = []byte("crl") //TODO: at the moment we store a single CRL in the database, in a dedicated table. +// is this acceptable? probably not.... + // ErrAlreadyExists can be returned if the DB attempts to set a key that has // been previously set. var ErrAlreadyExists = errors.New("already exists") @@ -87,6 +91,13 @@ type CertificateStorer interface { StoreSSHCertificate(crt *ssh.Certificate) error } +// CertificateRevocationListDB is an interface to indicate whether the DB supports CRL generation +type CertificateRevocationListDB interface { + GetRevokedCertificates() (*[]RevokedCertificateInfo, error) + GetCRL() (*CertificateRevocationListInfo, error) + StoreCRL(*CertificateRevocationListInfo) error +} + // DB is a wrapper over the nosql.DB interface. type DB struct { nosql.DB @@ -113,7 +124,7 @@ func New(c *Config) (AuthDB, error) { tables := [][]byte{ revokedCertsTable, certsTable, usedOTTTable, sshCertsTable, sshHostsTable, sshHostPrincipalsTable, sshUsersTable, - revokedSSHCertsTable, certsDataTable, + revokedSSHCertsTable, certsDataTable, crlTable, } for _, b := range tables { if err := db.CreateTable(b); err != nil { @@ -133,11 +144,21 @@ type RevokedCertificateInfo struct { ReasonCode int Reason string RevokedAt time.Time + ExpiresAt time.Time TokenID string MTLS bool ACME bool } +// CertificateRevocationListInfo contains a CRL in DER format and associated +// metadata to allow a decision on whether to regenerate the CRL or not easier +type CertificateRevocationListInfo struct { + Number int64 + ExpiresAt time.Time + Duration time.Duration + DER []byte +} + // IsRevoked returns whether or not a certificate with the given identifier // has been revoked. // In the case of an X509 Certificate the `id` should be the Serial Number of @@ -220,6 +241,51 @@ func (db *DB) RevokeSSH(rci *RevokedCertificateInfo) error { } } +// GetRevokedCertificates gets a list of all revoked certificates. +func (db *DB) GetRevokedCertificates() (*[]RevokedCertificateInfo, error) { + entries, err := db.List(revokedCertsTable) + if err != nil { + return nil, err + } + var revokedCerts []RevokedCertificateInfo + for _, e := range entries { + var data RevokedCertificateInfo + if err := json.Unmarshal(e.Value, &data); err != nil { + return nil, err + } + revokedCerts = append(revokedCerts, data) + } + return &revokedCerts, nil +} + +// StoreCRL stores a CRL in the DB +func (db *DB) StoreCRL(crlInfo *CertificateRevocationListInfo) error { + crlInfoBytes, err := json.Marshal(crlInfo) + if err != nil { + return errors.Wrap(err, "json Marshal error") + } + + if err := db.Set(crlTable, crlKey, crlInfoBytes); err != nil { + return errors.Wrap(err, "database Set error") + } + return nil +} + +// GetCRL gets the existing CRL from the database +func (db *DB) GetCRL() (*CertificateRevocationListInfo, error) { + crlInfoBytes, err := db.Get(crlTable, crlKey) + if err != nil { + return nil, errors.Wrap(err, "database Get error") + } + + var crlInfo CertificateRevocationListInfo + err = json.Unmarshal(crlInfoBytes, &crlInfo) + if err != nil { + return nil, errors.Wrap(err, "json Unmarshal error") + } + return &crlInfo, err +} + // GetCertificate retrieves a certificate by the serial number. func (db *DB) GetCertificate(serialNumber string) (*x509.Certificate, error) { asn1Data, err := db.Get(certsTable, []byte(serialNumber)) @@ -382,20 +448,44 @@ func (db *DB) Shutdown() error { // MockAuthDB mocks the AuthDB interface. // type MockAuthDB struct { - Err error - Ret1 interface{} - MIsRevoked func(string) (bool, error) - MIsSSHRevoked func(string) (bool, error) - MRevoke func(rci *RevokedCertificateInfo) error - MRevokeSSH func(rci *RevokedCertificateInfo) error - MGetCertificate func(serialNumber string) (*x509.Certificate, error) - MGetCertificateData func(serialNumber string) (*CertificateData, error) - MStoreCertificate func(crt *x509.Certificate) error - MUseToken func(id, tok string) (bool, error) - MIsSSHHost func(principal string) (bool, error) - MStoreSSHCertificate func(crt *ssh.Certificate) error - MGetSSHHostPrincipals func() ([]string, error) - MShutdown func() error + Err error + Ret1 interface{} + MIsRevoked func(string) (bool, error) + MIsSSHRevoked func(string) (bool, error) + MRevoke func(rci *RevokedCertificateInfo) error + MRevokeSSH func(rci *RevokedCertificateInfo) error + MGetCertificate func(serialNumber string) (*x509.Certificate, error) + MGetCertificateData func(serialNumber string) (*CertificateData, error) + MStoreCertificate func(crt *x509.Certificate) error + MUseToken func(id, tok string) (bool, error) + MIsSSHHost func(principal string) (bool, error) + MStoreSSHCertificate func(crt *ssh.Certificate) error + MGetSSHHostPrincipals func() ([]string, error) + MShutdown func() error + MGetRevokedCertificates func() (*[]RevokedCertificateInfo, error) + MGetCRL func() (*CertificateRevocationListInfo, error) + MStoreCRL func(*CertificateRevocationListInfo) error +} + +func (m *MockAuthDB) GetRevokedCertificates() (*[]RevokedCertificateInfo, error) { + if m.MGetRevokedCertificates != nil { + return m.MGetRevokedCertificates() + } + return m.Ret1.(*[]RevokedCertificateInfo), m.Err +} + +func (m *MockAuthDB) GetCRL() (*CertificateRevocationListInfo, error) { + if m.MGetCRL != nil { + return m.MGetCRL() + } + return m.Ret1.(*CertificateRevocationListInfo), m.Err +} + +func (m *MockAuthDB) StoreCRL(info *CertificateRevocationListInfo) error { + if m.MStoreCRL != nil { + return m.MStoreCRL(info) + } + return m.Err } // IsRevoked mock. diff --git a/db/simple.go b/db/simple.go index a7e38de9..6321e86f 100644 --- a/db/simple.go +++ b/db/simple.go @@ -41,6 +41,21 @@ func (s *SimpleDB) Revoke(rci *RevokedCertificateInfo) error { return ErrNotImplemented } +// GetRevokedCertificates returns a "NotImplemented" error. +func (s *SimpleDB) GetRevokedCertificates() (*[]RevokedCertificateInfo, error) { + return nil, ErrNotImplemented +} + +// GetCRL returns a "NotImplemented" error. +func (s *SimpleDB) GetCRL() (*CertificateRevocationListInfo, error) { + return nil, ErrNotImplemented +} + +// StoreCRL returns a "NotImplemented" error. +func (s *SimpleDB) StoreCRL(crlInfo *CertificateRevocationListInfo) error { + return ErrNotImplemented +} + // RevokeSSH returns a "NotImplemented" error. func (s *SimpleDB) RevokeSSH(rci *RevokedCertificateInfo) error { return ErrNotImplemented diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 67c5673d..0465f0b7 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -126,17 +126,20 @@ is `json`. * `db`: data persistence layer. See [database documentation](./database.md) for more info. + * `type`: `badger`, `bbolt`, `mysql`, etc. + * `dataSource`: string that can be interpreted differently depending on the + type of the database. Usually a path to where the data is stored. See the + [database configuration docs](./database.md#configuration) for more info. + * `database`: name of the database. Used for backends that may have multiple + databases. e.g. MySQL + * `valueDir`: directory to store the value log in (Badger specific). - - type: `badger`, `bbolt`, `mysql`, etc. - - - dataSource: `string` that can be interpreted differently depending on the - type of the database. Usually a path to where the data is stored. See - the [database configuration docs](./database.md#configuration) for more info. - - - database: name of the database. Used for backends that may have - multiple databases. e.g. MySQL - - - valueDir: directory to store the value log in (Badger specific). +* `crl`: Certificate Revocation List settings: + * `enable`: enables CRL generation (`true` to generate, `false` to disable) + * `generateOnRevoke`: a revoke will generate a new CRL if the crl is enabled. + * `cacheDuration`: the duration until next update of the CRL, defaults to 24h. + * `renewPeriod`: the time between CRL regeneration. If not set ~2/3 of the + cacheDuration will be used. * `tls`: settings for negotiating communication with the CA; includes acceptable ciphersuites, min/max TLS version, etc.