Skip to content

Commit

Permalink
Added CRL support (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
tsaarni committed Jun 19, 2022
1 parent 793e193 commit 2b0f7ea
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
142 changes: 142 additions & 0 deletions crl.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions crl_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
34 changes: 34 additions & 0 deletions internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions internal/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"math/big"
"net"
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
subject: CN=self-signed
revoked: true
18 changes: 18 additions & 0 deletions internal/manifest/testdata/certs-revocation.yaml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 2b0f7ea

Please sign in to comment.