diff --git a/README.md b/README.md index a4f1a06..09a129c 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Writing state: certs.state | not_before | Certificate is not valid before this time ([RFC3339 timestamp](https://tools.ietf.org/html/rfc3339)) | `2020-01-01T09:00:00Z` | | not_after | Certificate is not valid after this time ([RFC3339 timestamp](https://tools.ietf.org/html/rfc3339)) | `2020-01-01T09:00:00Z` | | serial | Serial number for the certificate. Default value is current time in nanoseconds. | `123` | +| revoked | When `true` the serial number of the certificate will be written in `[issuer]-crl.pem`. Default value is `false`. The file will be written only if at least one certificate is revoked. CRL `ThisUpdate` is set to current time and `NextUpdate` one week after. Self-signed certificates cannot be revoked. | `true`, `false` | ## Go API diff --git a/certificate.go b/certificate.go index 0858eef..d1cb792 100644 --- a/certificate.go +++ b/certificate.go @@ -137,6 +137,17 @@ func (c *Certificate) PublicKey() (crypto.PublicKey, error) { return c.GeneratedCert.PrivateKey.(crypto.Signer).Public(), nil } +// PrivateKey returns crypto.Signer that represents the PrivateKey associated to the Certificate. +// A key pair and certificate will be generated at first call of any Certificate functions. +// Error is not nil if generation fails. +func (c *Certificate) PrivateKey() (crypto.Signer, error) { + err := c.ensureGenerated() + if err != nil { + return nil, err + } + return c.GeneratedCert.PrivateKey.(crypto.Signer), nil +} + // PEM returns the Certificate as certificate and private key PEM buffers. // A key pair and certificate will be generated at first call of any Certificate functions. // Error is not nil if generation fails. diff --git a/crl.go b/crl.go new file mode 100644 index 0000000..5e2fa62 --- /dev/null +++ b/crl.go @@ -0,0 +1,142 @@ +// Copyright certyaml authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certyaml + +import ( + "bytes" + "crypto/rand" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "os" + "time" +) + +// CRL defines properties for generating CRL files. +type CRL struct { + // ThisUpdate is the issue date of this CRL. + // Default value is current time (when value is nil). + ThisUpdate *time.Time + + // NextUpdate indicates the date by which the next CRL will be issued. + // Default value is ThisUpdate + one week (when value is nil). + NextUpdate *time.Time + + // Revoked is the list of Certificates that will be included in the CRL. + // All Certificates must be issued by the same Issuer. + // Self-signed certificates cannot be added. + Revoked []*Certificate +} + +// Add appends a Certificate to CRL list. +// All Certificates must be issued by the same Issuer. +// Self-signed certificates cannot be added. +// Error is not nil if adding fails. +func (crl *CRL) Add(cert *Certificate) error { + if cert.Issuer == nil { + return fmt.Errorf("cannot revoke self-signed certificate: %s", cert.Subject) + } + if len(crl.Revoked) > 0 && (crl.Revoked[0].Issuer != cert.Issuer) { + return fmt.Errorf("CRL can contain certificates for single issuer only") + } + crl.Revoked = append(crl.Revoked, cert) + return nil +} + +// DER returns the CRL as DER buffer. +// Error is not nil if generation fails. +func (crl *CRL) DER() (crlBytes []byte, err error) { + if len(crl.Revoked) == 0 { + return nil, fmt.Errorf("certificates have not been added to CRL") + } + + effectiveRevocationTime := time.Now() + if crl.ThisUpdate != nil { + effectiveRevocationTime = *crl.ThisUpdate + } + + week := 24 * 7 * time.Hour + effectiveExpiry := effectiveRevocationTime.UTC().Add(week) + if crl.NextUpdate != nil { + effectiveExpiry = *crl.NextUpdate + } + + issuer := crl.Revoked[0].Issuer + + var revokedCerts []pkix.RevokedCertificate + for _, c := range crl.Revoked { + err := c.ensureGenerated() + if err != nil { + return nil, err + } + if c.Issuer == nil { + return nil, fmt.Errorf("cannot revoke self-signed certificate: %s", c.Subject) + } else if c.Issuer != issuer { + return nil, fmt.Errorf("CRL can contain certificates for single issuer only") + } + revokedCerts = append(revokedCerts, pkix.RevokedCertificate{ + SerialNumber: c.SerialNumber, + RevocationTime: effectiveRevocationTime, + }) + } + + ca, err := issuer.X509Certificate() + if err != nil { + return nil, err + } + + privateKey, err := issuer.PrivateKey() + if err != nil { + return nil, err + } + + return ca.CreateCRL(rand.Reader, privateKey, revokedCerts, effectiveRevocationTime, effectiveExpiry) +} + +// PEM returns the CRL as PEM buffer. +// Error is not nil if generation fails. +func (crl *CRL) PEM() (crlBytes []byte, err error) { + derBytes, err := crl.DER() + if err != nil { + return nil, err + } + + var buf bytes.Buffer + err = pem.Encode(&buf, &pem.Block{ + Type: "X509 CRL", + Bytes: derBytes, + }) + if err != nil { + return nil, err + } + + crlBytes = append(crlBytes, buf.Bytes()...) // Create copy of underlying buf. + return +} + +// WritePEM writes the CRL as PEM file. +// Error is not nil if writing fails. +func (crl *CRL) WritePEM(crlFile string) error { + pemBytes, err := crl.PEM() + if err != nil { + return err + } + err = os.WriteFile(crlFile, pemBytes, 0600) + if err != nil { + return err + } + + return nil +} diff --git a/crl_test.go b/crl_test.go new file mode 100644 index 0000000..44b2834 --- /dev/null +++ b/crl_test.go @@ -0,0 +1,76 @@ +// Copyright certyaml authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certyaml + +import ( + "crypto/x509" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRevocation(t *testing.T) { + ca := Certificate{Subject: "CN=ca"} + input1 := Certificate{Subject: "CN=Joe", Issuer: &ca, SerialNumber: big.NewInt(123)} + input2 := Certificate{Subject: "CN=Jill", Issuer: &ca, SerialNumber: big.NewInt(456)} + + crl := CRL{} + err := crl.Add(&input1) + assert.Nil(t, err) + err = crl.Add(&input2) + assert.Nil(t, err) + + crlBytes, err := crl.DER() + assert.Nil(t, err) + certList, err := x509.ParseCRL(crlBytes) + assert.Nil(t, err) + assert.Equal(t, 2, len(certList.TBSCertList.RevokedCertificates)) + assert.Equal(t, "CN=ca", certList.TBSCertList.Issuer.String()) + assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber) + assert.Equal(t, big.NewInt(456), certList.TBSCertList.RevokedCertificates[1].SerialNumber) +} + +func TestInvalidSelfSigned(t *testing.T) { + input := Certificate{Subject: "CN=joe"} + + // Include self-signed certificate in struct. + crl := CRL{Revoked: []*Certificate{&input}} + _, err := crl.DER() + assert.NotNil(t, err) + + // Try adding self-signed certificates. + err = crl.Add(&input) + assert.NotNil(t, err) +} + +func TestInvalidIssuers(t *testing.T) { + ca1 := Certificate{Subject: "CN=ca1"} + ca2 := Certificate{Subject: "CN=ca2"} + input1 := Certificate{Subject: "CN=Joe", Issuer: &ca1} + input2 := Certificate{Subject: "CN=Jill", Issuer: &ca2} + + // Include certificates with different issuers in struct. + crl := CRL{Revoked: []*Certificate{&input1, &input2}} + _, err := crl.DER() + assert.NotNil(t, err) + + // Try adding certificates with different issuers. + crl = CRL{} + err = crl.Add(&input1) + assert.Nil(t, err) + err = crl.Add(&input2) + assert.NotNil(t, err) +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 95217ba..1034306 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -58,6 +58,7 @@ type CertificateManifest struct { IssuerAsString string `json:"issuer"` Filename string `json:"filename"` SerialNumberAsInt *int64 `json:"serial"` + Revoked bool `json:"revoked"` } func (c *CertificateManifest) hash() string { @@ -88,6 +89,9 @@ func GenerateCertificates(output io.Writer, manifestFile, stateFile, destDir str return fmt.Errorf("error while parsing certificate state file: %s", err) } + // Map of CLRs, indexed by issuing CAs subject name. + revocationLists := map[string]*api.CRL{} + // Parse multi-document YAML file scanner := bufio.NewScanner(f) scanner.Split(splitByDocument) @@ -130,6 +134,36 @@ func GenerateCertificates(output io.Writer, manifestFile, stateFile, destDir str return fmt.Errorf("error while saving certificate: %s", err) } m.certs[c.Subject] = &c + + // If revoked, add to existing revocation list or create new one. + if c.Revoked { + issuer := c.Issuer + if issuer == nil { + return fmt.Errorf("cannot revoke self-signed certificate: %s", c.Subject) + } + // Does revocation list already exist for this CA? + crl, ok := revocationLists[issuer.Subject] + // If not, create new CRL. + if !ok { + crl = &api.CRL{} + } + err := crl.Add(&c.Certificate) + if err != nil { + return err + } + revocationLists[issuer.Subject] = crl + } + } + + // Write CRLs to PEM files. + for _, crl := range revocationLists { + issuer := m.certs[crl.Revoked[0].Issuer.Subject] + crlFile := path.Join(m.dataDir, issuer.Filename+"-crl.pem") + fmt.Fprintf(output, "Writing CRL: %s\n", crlFile) + err := crl.WritePEM(crlFile) + if err != nil { + return err + } } // Write hashes to state file. diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index aca6c71..0caf0c2 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -21,6 +21,7 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" + "encoding/pem" "io/ioutil" "math/big" "net" @@ -238,3 +239,49 @@ func TestParsingAllCertificateFields(t *testing.T) { assert.Equal(t, big.NewInt(123), got.SerialNumber) } + +func TestRevocation(t *testing.T) { + dir, err := ioutil.TempDir("/tmp", "certyaml-unittest") + assert.Nil(t, err) + defer os.RemoveAll(dir) + + var output bytes.Buffer + err = GenerateCertificates(&output, "testdata/certs-revocation.yaml", path.Join(dir, "state.yaml"), dir) + assert.Nil(t, err) + + crlFile := path.Join(dir, "ca1-crl.pem") + pemBuffer, err := os.ReadFile(crlFile) + assert.Nil(t, err) + block, rest := pem.Decode(pemBuffer) + assert.NotNil(t, block) + assert.Equal(t, "X509 CRL", block.Type) + assert.Empty(t, rest) + certList, err := x509.ParseCRL(block.Bytes) + assert.Nil(t, err) + assert.Equal(t, "CN=ca1", certList.TBSCertList.Issuer.String()) + assert.Equal(t, 1, len(certList.TBSCertList.RevokedCertificates)) + assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber) + + crlFile = path.Join(dir, "ca2-crl.pem") + pemBuffer, err = os.ReadFile(crlFile) + assert.Nil(t, err) + block, rest = pem.Decode(pemBuffer) + assert.NotNil(t, block) + assert.Equal(t, "X509 CRL", block.Type) + assert.Empty(t, rest) + certList, err = x509.ParseCRL(block.Bytes) + assert.Nil(t, err) + assert.Equal(t, "CN=ca2", certList.TBSCertList.Issuer.String()) + assert.Equal(t, 2, len(certList.TBSCertList.RevokedCertificates)) + assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber) + assert.Equal(t, big.NewInt(456), certList.TBSCertList.RevokedCertificates[1].SerialNumber) +} + +func TestInvalidRevocation(t *testing.T) { + dir, err := ioutil.TempDir("", "certyaml-testsuite-*") + assert.Nil(t, err) + defer os.RemoveAll(dir) + var output bytes.Buffer + err = GenerateCertificates(&output, "testdata/cert-invalid-revoke-self-signed.yaml", path.Join(dir, "state.yaml"), dir) + assert.NotNil(t, err) +} diff --git a/internal/manifest/testdata/cert-invalid-revoke-self-signed.yaml b/internal/manifest/testdata/cert-invalid-revoke-self-signed.yaml new file mode 100644 index 0000000..346ad16 --- /dev/null +++ b/internal/manifest/testdata/cert-invalid-revoke-self-signed.yaml @@ -0,0 +1,2 @@ +subject: CN=self-signed +revoked: true diff --git a/internal/manifest/testdata/certs-revocation.yaml b/internal/manifest/testdata/certs-revocation.yaml new file mode 100644 index 0000000..04ce913 --- /dev/null +++ b/internal/manifest/testdata/certs-revocation.yaml @@ -0,0 +1,18 @@ +subject: CN=ca1 +--- +subject: CN=server +issuer: CN=ca1 +serial: 123 +revoked: true +--- +subject: CN=ca2 +--- +subject: CN=server +issuer: CN=ca2 +serial: 123 +revoked: true +--- +subject: CN=client +issuer: CN=ca2 +serial: 456 +revoked: true