Skip to content

Commit 484e660

Browse files
authored
Merge pull request #14 from bwesterb/revoke
umbilical: initial revocation checker
2 parents b010c4f + 6c6d86b commit 484e660

File tree

3 files changed

+329
-0
lines changed

3 files changed

+329
-0
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ require (
1010
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
1111
)
1212

13+
require go.etcd.io/bbolt v1.4.0 // indirect
14+
1315
require (
1416
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
1517
github.com/russross/blackfriday/v2 v2.1.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
1010
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
1111
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
1212
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
13+
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
14+
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
1315
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
1416
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
1517
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=

umbilical/revocation/checker.go

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// Package revocation implements the code to check for revocation of X.509
2+
// certificates on demand. It either uses OCSP or CRLs. The latter are
3+
// cached on disk.
4+
package revocation
5+
6+
import (
7+
"bytes"
8+
"crypto/x509"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"math/big"
14+
"net/http"
15+
"sync"
16+
"time"
17+
18+
"golang.org/x/crypto/ocsp"
19+
20+
bolt "go.etcd.io/bbolt"
21+
)
22+
23+
type Config struct {
24+
// Path to file to use to cache
25+
Cache string
26+
}
27+
28+
type crlEntry struct {
29+
Expires time.Time
30+
BucketID uint64
31+
}
32+
33+
type Checker struct {
34+
cache *bolt.DB
35+
36+
fetchMux sync.Mutex
37+
// URL -> Cond to wait on a CRL that's currently fetching.
38+
crlFetching map[string]*sync.Cond
39+
}
40+
41+
func NewChecker(cfg Config) (*Checker, error) {
42+
var (
43+
ret Checker
44+
err error
45+
)
46+
47+
ret.cache, err = bolt.Open(cfg.Cache, 0600, nil)
48+
if err != nil {
49+
return nil, fmt.Errorf("bolt.Open(%s): %w", cfg.Cache, err)
50+
}
51+
52+
err = ret.cache.Update(func(tx *bolt.Tx) error {
53+
_, err := tx.CreateBucketIfNotExists([]byte("crls"))
54+
return err
55+
})
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to create crls bucket: %w", err)
58+
}
59+
60+
ret.crlFetching = make(map[string]*sync.Cond)
61+
62+
return &ret, nil
63+
}
64+
65+
// Checks whether the given certificate is revoked by first trying OCSP,
66+
// and then checking CRL.
67+
//
68+
// Warning: make sure you trust the issuer and checked the chain.
69+
// Does not check the signature of the issuer.
70+
func (c *Checker) Revoked(cert, issuer *x509.Certificate) (
71+
bool, error) {
72+
if len(cert.OCSPServer) != 0 {
73+
return c.revokedOCSP(cert, issuer)
74+
}
75+
76+
if len(cert.CRLDistributionPoints) != 0 {
77+
return c.revokedCRL(cert, issuer)
78+
}
79+
80+
return true, errors.New("No revocation mechanism available")
81+
}
82+
83+
func sendOCSP(url string, req []byte, cert, issuer *x509.Certificate) (
84+
*ocsp.Response, error) {
85+
// TODO Support GET. It might be slightly faster and is easier to cache.
86+
resp, err := http.Post(
87+
url,
88+
"application/ocsp-request",
89+
bytes.NewBuffer(req),
90+
)
91+
92+
if err != nil {
93+
return nil, fmt.Errorf("POST: %w", err)
94+
}
95+
defer resp.Body.Close()
96+
97+
if resp.StatusCode != 200 {
98+
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
99+
}
100+
101+
bs, err := io.ReadAll(resp.Body)
102+
if err != nil {
103+
return nil, fmt.Errorf("reading response: %w", err)
104+
}
105+
106+
return ocsp.ParseResponseForCert(bs, cert, issuer)
107+
}
108+
109+
// TODO Cache OCSP. We can use the resp.NextUpdate field.
110+
func (c *Checker) revokedOCSP(cert, issuer *x509.Certificate) (bool, error) {
111+
req, err := ocsp.CreateRequest(cert, issuer, nil)
112+
if err != nil {
113+
return true, fmt.Errorf("ocsp.CreateRequest: %w", err)
114+
}
115+
116+
ok := false
117+
var (
118+
resp *ocsp.Response
119+
errs []error
120+
)
121+
122+
// Although not specified in any standard as far as I know, it seems
123+
// common to try the OCSP servers in the order they are listed.
124+
for _, url := range cert.OCSPServer {
125+
resp, err = sendOCSP(url, req, cert, issuer)
126+
if err != nil {
127+
errs = append(errs, err)
128+
continue
129+
}
130+
131+
ok = true
132+
break
133+
}
134+
135+
if !ok {
136+
return true, fmt.Errorf("No valid OCSP response: %w", errors.Join(errs...))
137+
}
138+
139+
switch resp.Status {
140+
case ocsp.Good:
141+
return false, nil
142+
case ocsp.Unknown:
143+
return true, errors.New("OCSP server doesn't know about certificate")
144+
case ocsp.Revoked:
145+
return true, nil
146+
}
147+
148+
return true, errors.New("Unrecognized OCSP status")
149+
}
150+
151+
func (c *Checker) checkCRLCache(url string, serial *big.Int) (*bool, error) {
152+
var (
153+
rawEntry []byte
154+
entry *crlEntry
155+
ret *bool
156+
)
157+
err := c.cache.View(func(tx *bolt.Tx) error {
158+
crlsBucket := tx.Bucket([]byte("crls"))
159+
rawEntry = crlsBucket.Get([]byte(url))
160+
161+
if rawEntry == nil {
162+
return nil
163+
}
164+
entry = new(crlEntry)
165+
if err := json.Unmarshal(rawEntry, entry); err != nil {
166+
return fmt.Errorf("parsing crlentry: %w", err)
167+
}
168+
169+
crlBucket := tx.Bucket([]byte(fmt.Sprintf("crl %d", entry.BucketID)))
170+
171+
val := crlBucket.Get(serial.Bytes())
172+
ret = new(bool)
173+
*ret = val != nil
174+
return nil
175+
})
176+
if err != nil {
177+
return nil, err
178+
}
179+
if ret == nil {
180+
return nil, nil
181+
}
182+
183+
if time.Until(entry.Expires) < 0 {
184+
err := c.cache.Update(func(tx *bolt.Tx) error {
185+
_ = tx.DeleteBucket([]byte(fmt.Sprintf("crl %d", entry.BucketID)))
186+
_ = tx.Bucket([]byte("crls")).Delete([]byte(url))
187+
return nil
188+
})
189+
return nil, err
190+
}
191+
192+
return ret, nil
193+
}
194+
195+
func fetchCRL(url string, issuer *x509.Certificate) (*x509.RevocationList, error) {
196+
resp, err := http.Get(url)
197+
if err != nil {
198+
return nil, fmt.Errorf("GET: %w", err)
199+
}
200+
defer resp.Body.Close()
201+
202+
if resp.StatusCode != 200 {
203+
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
204+
}
205+
206+
bs, err := io.ReadAll(resp.Body)
207+
if err != nil {
208+
return nil, fmt.Errorf("reading response: %w", err)
209+
}
210+
211+
crl, err := x509.ParseRevocationList(bs)
212+
if err != nil {
213+
return nil, fmt.Errorf("parsing CRL: %w", err)
214+
}
215+
216+
err = crl.CheckSignatureFrom(issuer)
217+
if err != nil {
218+
return nil, fmt.Errorf("CRL signature check: %w", err)
219+
}
220+
221+
return crl, nil
222+
}
223+
224+
func (c *Checker) revokedCRL(cert, issuer *x509.Certificate) (bool, error) {
225+
var errs []error
226+
227+
for _, url := range cert.CRLDistributionPoints {
228+
for {
229+
// Check cache first.
230+
revoked, err := c.checkCRLCache(url, cert.SerialNumber)
231+
if err != nil {
232+
return true, err
233+
}
234+
235+
if revoked != nil {
236+
return *revoked, nil
237+
}
238+
239+
// Ok, fetch and cache CRL.
240+
err = c.fetchAndCacheCRL(url, issuer)
241+
if err != nil {
242+
errs = append(errs, err)
243+
break
244+
}
245+
}
246+
}
247+
248+
return true, fmt.Errorf("Couldn't fetch CRL: %w", errors.Join(errs...))
249+
}
250+
251+
func (c *Checker) fetchAndCacheCRL(url string, issuer *x509.Certificate) error {
252+
// First check whether we're already fetching this CRL at the moment. If so,
253+
// wait and retry reading from cache.
254+
c.fetchMux.Lock()
255+
cnd := c.crlFetching[url]
256+
if cnd != nil {
257+
cnd.Wait()
258+
c.fetchMux.Unlock()
259+
return nil
260+
}
261+
262+
cnd = sync.NewCond(&c.fetchMux)
263+
c.crlFetching[url] = cnd
264+
c.fetchMux.Unlock()
265+
266+
crl, err := fetchCRL(url, issuer)
267+
if err != nil {
268+
return err
269+
}
270+
271+
// TODO Should we have a cache for errors?
272+
273+
// Check if CRL has expired to prevent an infinite loop with the
274+
// automatic cache purge.
275+
if time.Until(crl.NextUpdate).Minutes() < 1 {
276+
return errors.New("CRL will update within a minute")
277+
}
278+
279+
err = c.cache.Update(func(tx *bolt.Tx) error {
280+
crlsBucket := tx.Bucket([]byte("crls"))
281+
282+
var entry crlEntry
283+
entry.Expires = crl.NextUpdate
284+
entry.BucketID, err = crlsBucket.NextSequence()
285+
if err != nil {
286+
return err
287+
}
288+
289+
rawEntry, err := json.Marshal(&entry)
290+
if err != nil {
291+
return err
292+
}
293+
294+
if err := crlsBucket.Put([]byte(url), rawEntry); err != nil {
295+
return err
296+
}
297+
298+
crlBucket, err := tx.CreateBucket(
299+
[]byte(fmt.Sprintf("crl %d", entry.BucketID)),
300+
)
301+
if err != nil {
302+
return err
303+
}
304+
305+
for _, rc := range crl.RevokedCertificateEntries {
306+
err = crlBucket.Put(rc.SerialNumber.Bytes(), []byte{})
307+
if err != nil {
308+
return err
309+
}
310+
}
311+
312+
return nil
313+
})
314+
315+
// Wake up waiters
316+
c.fetchMux.Lock()
317+
delete(c.crlFetching, url)
318+
cnd.Broadcast()
319+
c.fetchMux.Unlock()
320+
return nil
321+
}
322+
323+
func (c *Checker) Close() {
324+
c.cache.Close()
325+
}

0 commit comments

Comments
 (0)