From 56926b9012b4e89377f923f0391db489a01280d4 Mon Sep 17 00:00:00 2001 From: Raal Goff Date: Sat, 30 Oct 2021 15:52:50 +0800 Subject: [PATCH] initial support for CRL --- api/api.go | 2 + api/crl.go | 16 +++++++ authority/tls.go | 95 ++++++++++++++++++++++++++++++++++++++++++ cas/apiv1/services.go | 6 +++ cas/softcas/softcas.go | 12 ++++++ db/db.go | 69 +++++++++++++++++++++++++++++- db/simple.go | 15 +++++++ 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 api/crl.go diff --git a/api/api.go b/api/api.go index 30ba03f9..d8cfa15f 100644 --- a/api/api.go +++ b/api/api.go @@ -46,6 +46,7 @@ type Authority interface { GetRoots() (federation []*x509.Certificate, err error) GetFederation() ([]*x509.Certificate, error) Version() authority.Version + GenerateCertificateRevocationList(force bool) (string, error) } // TimeDuration is an alias of provisioner.TimeDuration @@ -254,6 +255,7 @@ func (h *caHandler) Route(r Router) { r.MethodFunc("POST", "/renew", h.Renew) r.MethodFunc("POST", "/rekey", h.Rekey) r.MethodFunc("POST", "/revoke", h.Revoke) + r.MethodFunc("GET", "/crl", h.CRL) r.MethodFunc("GET", "/provisioners", h.Provisioners) r.MethodFunc("GET", "/provisioners/{kid}/encrypted-key", h.ProvisionerKey) r.MethodFunc("GET", "/roots", h.Roots) diff --git a/api/crl.go b/api/crl.go new file mode 100644 index 00000000..50b29d6c --- /dev/null +++ b/api/crl.go @@ -0,0 +1,16 @@ +package api + +import "net/http" + +// CRL is an HTTP handler that returns the current CRL +func (h *caHandler) CRL(w http.ResponseWriter, r *http.Request) { + crl, err := h.Authority.GenerateCertificateRevocationList(false) + + if err != nil { + w.WriteHeader(500) + return + } + + w.WriteHeader(200) + _, err = w.Write([]byte(crl)) +} diff --git a/authority/tls.go b/authority/tls.go index 839866a2..0bd4a7e7 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -5,9 +5,12 @@ import ( "crypto" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/asn1" "encoding/base64" "encoding/pem" + "fmt" + "math/big" "net/http" "time" @@ -426,6 +429,9 @@ func (a *Authority) Revoke(ctx context.Context, revokeOpts *RevokeOptions) error // Save as revoked in the Db. err = a.revoke(revokedCert, rci) + + // Generate a new CRL so CRL requesters will always get an up-to-date CRL whenever they request it + _, _ = a.GenerateCertificateRevocationList(true) } switch err { case nil: @@ -458,6 +464,95 @@ func (a *Authority) revokeSSH(crt *ssh.Certificate, rci *db.RevokedCertificateIn return a.db.Revoke(rci) } +// GenerateCertificateRevocationList returns a PEM representation of a signed CRL. +// It will look for a valid generated CRL in the database, check if it has expired, and generate +// a new CRL on demand if it has expired (or a CRL does not already exist). +// +// force set to true will force regeneration of the CRL regardless of whether it has actually expired +func (a *Authority) GenerateCertificateRevocationList(force bool) (string, error) { + + // check for an existing CRL in the database, and return that if its valid + crlInfo, err := a.db.GetCRL() + + if err != nil { + return "", err + } + + if !force && crlInfo != nil && crlInfo.ExpiresAt.After(time.Now().UTC()) { + return crlInfo.PEM, nil + } + + // some CAS may not implement the CRLGenerator interface, so check before we proceed + caCRLGenerator, ok := a.x509CAService.(casapi.CertificateAuthorityCRLGenerator) + + if !ok { + return "", errors.Errorf("CRL Generator not implemented") + } + + revokedList, err := a.db.GetRevokedCertificates() + + // 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 n int64 = 0 + var bn big.Int + + if crlInfo != nil { + n = crlInfo.Number + 1 + } + bn.SetInt64(n) + + // Convert our database db.RevokedCertificateInfo types into the pkix representation ready for the + // CAS to sign it + var revokedCertificates []pkix.RevokedCertificate + + for _, revokedCert := range *revokedList { + var sn big.Int + sn.SetString(revokedCert.Serial, 10) + revokedCertificates = append(revokedCertificates, pkix.RevokedCertificate{ + SerialNumber: &sn, + RevocationTime: revokedCert.RevokedAt, + Extensions: nil, + }) + } + + // Create a RevocationList representation ready for the CAS to sign + // TODO: use a config value for the NextUpdate time duration + // TODO: allow SignatureAlgorithm to be specified? + revocationList := x509.RevocationList{ + SignatureAlgorithm: 0, + RevokedCertificates: revokedCertificates, + Number: &bn, + ThisUpdate: time.Now().UTC(), + NextUpdate: time.Now().UTC().Add(time.Minute * 10), + ExtraExtensions: nil, + } + + certificateRevocationList, err := caCRLGenerator.CreateCertificateRevocationList(&revocationList) + if err != nil { + return "", err + } + + // Quick and dirty PEM encoding + // TODO: clean this up + pemCRL := fmt.Sprintf("-----BEGIN X509 CRL-----\n%s\n-----END X509 CRL-----\n", base64.StdEncoding.EncodeToString(certificateRevocationList)) + + // Create a new db.CertificateRevocationListInfo, which stores the new Number we just generated, the + // expiry time, and the byte-encoded CRL - then store it in the DB + newCRLInfo := db.CertificateRevocationListInfo{ + Number: n, + ExpiresAt: revocationList.NextUpdate, + PEM: pemCRL, + } + + err = a.db.StoreCRL(&newCRLInfo) + if err != nil { + return "", err + } + + // Finally, return our CRL PEM + return pemCRL, 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) { diff --git a/cas/apiv1/services.go b/cas/apiv1/services.go index cf9a5470..143c1f17 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 { + CreateCertificateRevocationList(crl *x509.RevocationList) ([]byte, 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 8e67d016..24e7dd54 100644 --- a/cas/softcas/softcas.go +++ b/cas/softcas/softcas.go @@ -3,6 +3,7 @@ package softcas import ( "context" "crypto" + "crypto/rand" "crypto/x509" "time" @@ -112,6 +113,17 @@ func (c *SoftCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1 }, nil } +// CreateCertificateRevocationList will create a new CRL based on the RevocationList passed to it +func (c *SoftCAS) CreateCertificateRevocationList(crl *x509.RevocationList) ([]byte, error) { + + revocationList, err := x509.CreateRevocationList(rand.Reader, crl, c.CertificateChain[0], c.Signer) + if err != nil { + return nil, err + } + + return revocationList, 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 2643e577..5826d7b9 100644 --- a/db/db.go +++ b/db/db.go @@ -16,6 +16,7 @@ import ( var ( certsTable = []byte("x509_certs") revokedCertsTable = []byte("revoked_x509_certs") + crlTable = []byte("x509_crl") revokedSSHCertsTable = []byte("revoked_ssh_certs") usedOTTTable = []byte("used_ott") sshCertsTable = []byte("ssh_certs") @@ -24,6 +25,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") @@ -47,6 +51,9 @@ type AuthDB interface { IsSSHRevoked(sn string) (bool, error) Revoke(rci *RevokedCertificateInfo) error RevokeSSH(rci *RevokedCertificateInfo) error + GetRevokedCertificates() (*[]RevokedCertificateInfo, error) + GetCRL() (*CertificateRevocationListInfo, error) + StoreCRL(*CertificateRevocationListInfo) error GetCertificate(serialNumber string) (*x509.Certificate, error) StoreCertificate(crt *x509.Certificate) error UseToken(id, tok string) (bool, error) @@ -82,7 +89,7 @@ func New(c *Config) (AuthDB, error) { tables := [][]byte{ revokedCertsTable, certsTable, usedOTTTable, sshCertsTable, sshHostsTable, sshHostPrincipalsTable, sshUsersTable, - revokedSSHCertsTable, + revokedSSHCertsTable, crlTable, } for _, b := range tables { if err := db.CreateTable(b); err != nil { @@ -106,6 +113,14 @@ type RevokedCertificateInfo struct { MTLS bool } +// CertificateRevocationListInfo contains a CRL in PEM and associated metadata to allow a decision on whether +// to regenerate the CRL or not easier +type CertificateRevocationListInfo struct { + Number int64 + ExpiresAt time.Time + PEM string +} + // 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 @@ -188,6 +203,58 @@ 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 database.IsErrNotFound(err) { + return nil, nil + } + + 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)) diff --git a/db/simple.go b/db/simple.go index 0e5426ec..c34cdb5d 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