Skip to content

Commit 2b0f7ea

Browse files
authored
Added CRL support (#18)
1 parent 793e193 commit 2b0f7ea

8 files changed

+331
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Writing state: certs.state
159159
| not_before | Certificate is not valid before this time ([RFC3339 timestamp](https://tools.ietf.org/html/rfc3339)) | `2020-01-01T09:00:00Z` |
160160
| not_after | Certificate is not valid after this time ([RFC3339 timestamp](https://tools.ietf.org/html/rfc3339)) | `2020-01-01T09:00:00Z` |
161161
| serial | Serial number for the certificate. Default value is current time in nanoseconds. | `123` |
162+
| 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` |
162163

163164
## Go API
164165

certificate.go

+11
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ func (c *Certificate) PublicKey() (crypto.PublicKey, error) {
137137
return c.GeneratedCert.PrivateKey.(crypto.Signer).Public(), nil
138138
}
139139

140+
// PrivateKey returns crypto.Signer that represents the PrivateKey associated to the Certificate.
141+
// A key pair and certificate will be generated at first call of any Certificate functions.
142+
// Error is not nil if generation fails.
143+
func (c *Certificate) PrivateKey() (crypto.Signer, error) {
144+
err := c.ensureGenerated()
145+
if err != nil {
146+
return nil, err
147+
}
148+
return c.GeneratedCert.PrivateKey.(crypto.Signer), nil
149+
}
150+
140151
// PEM returns the Certificate as certificate and private key PEM buffers.
141152
// A key pair and certificate will be generated at first call of any Certificate functions.
142153
// Error is not nil if generation fails.

crl.go

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright certyaml authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package certyaml
16+
17+
import (
18+
"bytes"
19+
"crypto/rand"
20+
"crypto/x509/pkix"
21+
"encoding/pem"
22+
"fmt"
23+
"os"
24+
"time"
25+
)
26+
27+
// CRL defines properties for generating CRL files.
28+
type CRL struct {
29+
// ThisUpdate is the issue date of this CRL.
30+
// Default value is current time (when value is nil).
31+
ThisUpdate *time.Time
32+
33+
// NextUpdate indicates the date by which the next CRL will be issued.
34+
// Default value is ThisUpdate + one week (when value is nil).
35+
NextUpdate *time.Time
36+
37+
// Revoked is the list of Certificates that will be included in the CRL.
38+
// All Certificates must be issued by the same Issuer.
39+
// Self-signed certificates cannot be added.
40+
Revoked []*Certificate
41+
}
42+
43+
// Add appends a Certificate to CRL list.
44+
// All Certificates must be issued by the same Issuer.
45+
// Self-signed certificates cannot be added.
46+
// Error is not nil if adding fails.
47+
func (crl *CRL) Add(cert *Certificate) error {
48+
if cert.Issuer == nil {
49+
return fmt.Errorf("cannot revoke self-signed certificate: %s", cert.Subject)
50+
}
51+
if len(crl.Revoked) > 0 && (crl.Revoked[0].Issuer != cert.Issuer) {
52+
return fmt.Errorf("CRL can contain certificates for single issuer only")
53+
}
54+
crl.Revoked = append(crl.Revoked, cert)
55+
return nil
56+
}
57+
58+
// DER returns the CRL as DER buffer.
59+
// Error is not nil if generation fails.
60+
func (crl *CRL) DER() (crlBytes []byte, err error) {
61+
if len(crl.Revoked) == 0 {
62+
return nil, fmt.Errorf("certificates have not been added to CRL")
63+
}
64+
65+
effectiveRevocationTime := time.Now()
66+
if crl.ThisUpdate != nil {
67+
effectiveRevocationTime = *crl.ThisUpdate
68+
}
69+
70+
week := 24 * 7 * time.Hour
71+
effectiveExpiry := effectiveRevocationTime.UTC().Add(week)
72+
if crl.NextUpdate != nil {
73+
effectiveExpiry = *crl.NextUpdate
74+
}
75+
76+
issuer := crl.Revoked[0].Issuer
77+
78+
var revokedCerts []pkix.RevokedCertificate
79+
for _, c := range crl.Revoked {
80+
err := c.ensureGenerated()
81+
if err != nil {
82+
return nil, err
83+
}
84+
if c.Issuer == nil {
85+
return nil, fmt.Errorf("cannot revoke self-signed certificate: %s", c.Subject)
86+
} else if c.Issuer != issuer {
87+
return nil, fmt.Errorf("CRL can contain certificates for single issuer only")
88+
}
89+
revokedCerts = append(revokedCerts, pkix.RevokedCertificate{
90+
SerialNumber: c.SerialNumber,
91+
RevocationTime: effectiveRevocationTime,
92+
})
93+
}
94+
95+
ca, err := issuer.X509Certificate()
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
privateKey, err := issuer.PrivateKey()
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
return ca.CreateCRL(rand.Reader, privateKey, revokedCerts, effectiveRevocationTime, effectiveExpiry)
106+
}
107+
108+
// PEM returns the CRL as PEM buffer.
109+
// Error is not nil if generation fails.
110+
func (crl *CRL) PEM() (crlBytes []byte, err error) {
111+
derBytes, err := crl.DER()
112+
if err != nil {
113+
return nil, err
114+
}
115+
116+
var buf bytes.Buffer
117+
err = pem.Encode(&buf, &pem.Block{
118+
Type: "X509 CRL",
119+
Bytes: derBytes,
120+
})
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
crlBytes = append(crlBytes, buf.Bytes()...) // Create copy of underlying buf.
126+
return
127+
}
128+
129+
// WritePEM writes the CRL as PEM file.
130+
// Error is not nil if writing fails.
131+
func (crl *CRL) WritePEM(crlFile string) error {
132+
pemBytes, err := crl.PEM()
133+
if err != nil {
134+
return err
135+
}
136+
err = os.WriteFile(crlFile, pemBytes, 0600)
137+
if err != nil {
138+
return err
139+
}
140+
141+
return nil
142+
}

crl_test.go

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright certyaml authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package certyaml
16+
17+
import (
18+
"crypto/x509"
19+
"math/big"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
)
24+
25+
func TestRevocation(t *testing.T) {
26+
ca := Certificate{Subject: "CN=ca"}
27+
input1 := Certificate{Subject: "CN=Joe", Issuer: &ca, SerialNumber: big.NewInt(123)}
28+
input2 := Certificate{Subject: "CN=Jill", Issuer: &ca, SerialNumber: big.NewInt(456)}
29+
30+
crl := CRL{}
31+
err := crl.Add(&input1)
32+
assert.Nil(t, err)
33+
err = crl.Add(&input2)
34+
assert.Nil(t, err)
35+
36+
crlBytes, err := crl.DER()
37+
assert.Nil(t, err)
38+
certList, err := x509.ParseCRL(crlBytes)
39+
assert.Nil(t, err)
40+
assert.Equal(t, 2, len(certList.TBSCertList.RevokedCertificates))
41+
assert.Equal(t, "CN=ca", certList.TBSCertList.Issuer.String())
42+
assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber)
43+
assert.Equal(t, big.NewInt(456), certList.TBSCertList.RevokedCertificates[1].SerialNumber)
44+
}
45+
46+
func TestInvalidSelfSigned(t *testing.T) {
47+
input := Certificate{Subject: "CN=joe"}
48+
49+
// Include self-signed certificate in struct.
50+
crl := CRL{Revoked: []*Certificate{&input}}
51+
_, err := crl.DER()
52+
assert.NotNil(t, err)
53+
54+
// Try adding self-signed certificates.
55+
err = crl.Add(&input)
56+
assert.NotNil(t, err)
57+
}
58+
59+
func TestInvalidIssuers(t *testing.T) {
60+
ca1 := Certificate{Subject: "CN=ca1"}
61+
ca2 := Certificate{Subject: "CN=ca2"}
62+
input1 := Certificate{Subject: "CN=Joe", Issuer: &ca1}
63+
input2 := Certificate{Subject: "CN=Jill", Issuer: &ca2}
64+
65+
// Include certificates with different issuers in struct.
66+
crl := CRL{Revoked: []*Certificate{&input1, &input2}}
67+
_, err := crl.DER()
68+
assert.NotNil(t, err)
69+
70+
// Try adding certificates with different issuers.
71+
crl = CRL{}
72+
err = crl.Add(&input1)
73+
assert.Nil(t, err)
74+
err = crl.Add(&input2)
75+
assert.NotNil(t, err)
76+
}

internal/manifest/manifest.go

+34
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type CertificateManifest struct {
5858
IssuerAsString string `json:"issuer"`
5959
Filename string `json:"filename"`
6060
SerialNumberAsInt *int64 `json:"serial"`
61+
Revoked bool `json:"revoked"`
6162
}
6263

6364
func (c *CertificateManifest) hash() string {
@@ -88,6 +89,9 @@ func GenerateCertificates(output io.Writer, manifestFile, stateFile, destDir str
8889
return fmt.Errorf("error while parsing certificate state file: %s", err)
8990
}
9091

92+
// Map of CLRs, indexed by issuing CAs subject name.
93+
revocationLists := map[string]*api.CRL{}
94+
9195
// Parse multi-document YAML file
9296
scanner := bufio.NewScanner(f)
9397
scanner.Split(splitByDocument)
@@ -130,6 +134,36 @@ func GenerateCertificates(output io.Writer, manifestFile, stateFile, destDir str
130134
return fmt.Errorf("error while saving certificate: %s", err)
131135
}
132136
m.certs[c.Subject] = &c
137+
138+
// If revoked, add to existing revocation list or create new one.
139+
if c.Revoked {
140+
issuer := c.Issuer
141+
if issuer == nil {
142+
return fmt.Errorf("cannot revoke self-signed certificate: %s", c.Subject)
143+
}
144+
// Does revocation list already exist for this CA?
145+
crl, ok := revocationLists[issuer.Subject]
146+
// If not, create new CRL.
147+
if !ok {
148+
crl = &api.CRL{}
149+
}
150+
err := crl.Add(&c.Certificate)
151+
if err != nil {
152+
return err
153+
}
154+
revocationLists[issuer.Subject] = crl
155+
}
156+
}
157+
158+
// Write CRLs to PEM files.
159+
for _, crl := range revocationLists {
160+
issuer := m.certs[crl.Revoked[0].Issuer.Subject]
161+
crlFile := path.Join(m.dataDir, issuer.Filename+"-crl.pem")
162+
fmt.Fprintf(output, "Writing CRL: %s\n", crlFile)
163+
err := crl.WritePEM(crlFile)
164+
if err != nil {
165+
return err
166+
}
133167
}
134168

135169
// Write hashes to state file.

internal/manifest/manifest_test.go

+47
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"crypto/rsa"
2222
"crypto/tls"
2323
"crypto/x509"
24+
"encoding/pem"
2425
"io/ioutil"
2526
"math/big"
2627
"net"
@@ -238,3 +239,49 @@ func TestParsingAllCertificateFields(t *testing.T) {
238239

239240
assert.Equal(t, big.NewInt(123), got.SerialNumber)
240241
}
242+
243+
func TestRevocation(t *testing.T) {
244+
dir, err := ioutil.TempDir("/tmp", "certyaml-unittest")
245+
assert.Nil(t, err)
246+
defer os.RemoveAll(dir)
247+
248+
var output bytes.Buffer
249+
err = GenerateCertificates(&output, "testdata/certs-revocation.yaml", path.Join(dir, "state.yaml"), dir)
250+
assert.Nil(t, err)
251+
252+
crlFile := path.Join(dir, "ca1-crl.pem")
253+
pemBuffer, err := os.ReadFile(crlFile)
254+
assert.Nil(t, err)
255+
block, rest := pem.Decode(pemBuffer)
256+
assert.NotNil(t, block)
257+
assert.Equal(t, "X509 CRL", block.Type)
258+
assert.Empty(t, rest)
259+
certList, err := x509.ParseCRL(block.Bytes)
260+
assert.Nil(t, err)
261+
assert.Equal(t, "CN=ca1", certList.TBSCertList.Issuer.String())
262+
assert.Equal(t, 1, len(certList.TBSCertList.RevokedCertificates))
263+
assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber)
264+
265+
crlFile = path.Join(dir, "ca2-crl.pem")
266+
pemBuffer, err = os.ReadFile(crlFile)
267+
assert.Nil(t, err)
268+
block, rest = pem.Decode(pemBuffer)
269+
assert.NotNil(t, block)
270+
assert.Equal(t, "X509 CRL", block.Type)
271+
assert.Empty(t, rest)
272+
certList, err = x509.ParseCRL(block.Bytes)
273+
assert.Nil(t, err)
274+
assert.Equal(t, "CN=ca2", certList.TBSCertList.Issuer.String())
275+
assert.Equal(t, 2, len(certList.TBSCertList.RevokedCertificates))
276+
assert.Equal(t, big.NewInt(123), certList.TBSCertList.RevokedCertificates[0].SerialNumber)
277+
assert.Equal(t, big.NewInt(456), certList.TBSCertList.RevokedCertificates[1].SerialNumber)
278+
}
279+
280+
func TestInvalidRevocation(t *testing.T) {
281+
dir, err := ioutil.TempDir("", "certyaml-testsuite-*")
282+
assert.Nil(t, err)
283+
defer os.RemoveAll(dir)
284+
var output bytes.Buffer
285+
err = GenerateCertificates(&output, "testdata/cert-invalid-revoke-self-signed.yaml", path.Join(dir, "state.yaml"), dir)
286+
assert.NotNil(t, err)
287+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
subject: CN=self-signed
2+
revoked: true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
subject: CN=ca1
2+
---
3+
subject: CN=server
4+
issuer: CN=ca1
5+
serial: 123
6+
revoked: true
7+
---
8+
subject: CN=ca2
9+
---
10+
subject: CN=server
11+
issuer: CN=ca2
12+
serial: 123
13+
revoked: true
14+
---
15+
subject: CN=client
16+
issuer: CN=ca2
17+
serial: 456
18+
revoked: true

0 commit comments

Comments
 (0)