Skip to content

Commit f0df65c

Browse files
committed
umbilical: initial revocation checker
1 parent b010c4f commit f0df65c

File tree

3 files changed

+328
-0
lines changed

3 files changed

+328
-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: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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+
}
133+
134+
if !ok {
135+
return true, fmt.Errorf("No valid OCSP response: %w", errors.Join(errs...))
136+
}
137+
138+
switch resp.Status {
139+
case ocsp.Good:
140+
return false, nil
141+
case ocsp.Unknown:
142+
return true, errors.New("OCSP server doesn't know about certificate")
143+
case ocsp.Revoked:
144+
return true, nil
145+
}
146+
147+
return true, errors.New("Unrecognized OCSP status")
148+
}
149+
150+
func (c *Checker) checkCRLCache(url string, serial *big.Int) (*bool, error) {
151+
var (
152+
rawEntry []byte
153+
entry *crlEntry
154+
ret *bool
155+
)
156+
err := c.cache.View(func(tx *bolt.Tx) error {
157+
crlsBucket := tx.Bucket([]byte("crls"))
158+
rawEntry = crlsBucket.Get([]byte(url))
159+
160+
if rawEntry == nil {
161+
return nil
162+
}
163+
entry = new(crlEntry)
164+
if err := json.Unmarshal(rawEntry, entry); err != nil {
165+
return fmt.Errorf("parsing crlentry: %w", err)
166+
}
167+
168+
crlBucket := tx.Bucket([]byte(fmt.Sprintf("crl %d", entry.BucketID)))
169+
170+
val := crlBucket.Get(serial.Bytes())
171+
ret = new(bool)
172+
*ret = val != nil
173+
return nil
174+
})
175+
if err != nil {
176+
return nil, err
177+
}
178+
if ret == nil {
179+
return nil, nil
180+
}
181+
182+
if time.Until(entry.Expires) < 0 {
183+
err := c.cache.Update(func(tx *bolt.Tx) error {
184+
_ = tx.DeleteBucket([]byte(fmt.Sprintf("crl %d", entry.BucketID)))
185+
_ = tx.Bucket([]byte("crls")).Delete([]byte(url))
186+
return nil
187+
})
188+
return nil, err
189+
}
190+
191+
return ret, nil
192+
}
193+
194+
func fetchCRL(url string, issuer *x509.Certificate) (*x509.RevocationList, error) {
195+
resp, err := http.Get(url)
196+
if err != nil {
197+
return nil, fmt.Errorf("GET: %w", err)
198+
}
199+
defer resp.Body.Close()
200+
201+
if resp.StatusCode != 200 {
202+
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
203+
}
204+
205+
bs, err := io.ReadAll(resp.Body)
206+
if err != nil {
207+
return nil, fmt.Errorf("reading response: %w", err)
208+
}
209+
210+
crl, err := x509.ParseRevocationList(bs)
211+
if err != nil {
212+
return nil, fmt.Errorf("parsing CRL: %w", err)
213+
}
214+
215+
err = crl.CheckSignatureFrom(issuer)
216+
if err != nil {
217+
return nil, fmt.Errorf("CRL signature check: %w", err)
218+
}
219+
220+
return crl, nil
221+
}
222+
223+
func (c *Checker) revokedCRL(cert, issuer *x509.Certificate) (bool, error) {
224+
var errs []error
225+
226+
for _, url := range cert.CRLDistributionPoints {
227+
for {
228+
// Check cache first.
229+
revoked, err := c.checkCRLCache(url, cert.SerialNumber)
230+
if err != nil {
231+
return true, err
232+
}
233+
234+
if revoked != nil {
235+
return *revoked, nil
236+
}
237+
238+
// Ok, fetch and cache CRL.
239+
err = c.fetchAndCacheCRL(url, issuer)
240+
if err != nil {
241+
errs = append(errs, err)
242+
break
243+
}
244+
}
245+
}
246+
247+
return true, fmt.Errorf("Couldn't fetch CRL: %w", errors.Join(errs...))
248+
}
249+
250+
func (c *Checker) fetchAndCacheCRL(url string, issuer *x509.Certificate) error {
251+
// First check whether we're already fetching this CRL at the moment. If so,
252+
// wait and retry reading from cache.
253+
c.fetchMux.Lock()
254+
cnd := c.crlFetching[url]
255+
if cnd != nil {
256+
cnd.Wait()
257+
c.fetchMux.Unlock()
258+
return nil
259+
}
260+
261+
cnd = sync.NewCond(&c.fetchMux)
262+
c.crlFetching[url] = cnd
263+
c.fetchMux.Unlock()
264+
265+
crl, err := fetchCRL(url, issuer)
266+
if err != nil {
267+
return err
268+
}
269+
270+
// TODO Should we have a cache for errors?
271+
272+
// Check if CRL has expired to prevent an infinite loop with the
273+
// automatic cache purge.
274+
if time.Until(crl.NextUpdate).Minutes() < 1 {
275+
return errors.New("CRL will update within a minute")
276+
}
277+
278+
err = c.cache.Update(func(tx *bolt.Tx) error {
279+
crlsBucket := tx.Bucket([]byte("crls"))
280+
281+
var entry crlEntry
282+
entry.Expires = crl.NextUpdate
283+
entry.BucketID, err = crlsBucket.NextSequence()
284+
if err != nil {
285+
return err
286+
}
287+
288+
rawEntry, err := json.Marshal(&entry)
289+
if err != nil {
290+
return err
291+
}
292+
293+
if err := crlsBucket.Put([]byte(url), rawEntry); err != nil {
294+
return err
295+
}
296+
297+
crlBucket, err := tx.CreateBucket(
298+
[]byte(fmt.Sprintf("crl %d", entry.BucketID)),
299+
)
300+
if err != nil {
301+
return err
302+
}
303+
304+
for _, rc := range crl.RevokedCertificateEntries {
305+
err = crlBucket.Put(rc.SerialNumber.Bytes(), []byte{})
306+
if err != nil {
307+
return err
308+
}
309+
}
310+
311+
return nil
312+
})
313+
314+
// Wake up waiters
315+
c.fetchMux.Lock()
316+
delete(c.crlFetching, url)
317+
cnd.Broadcast()
318+
c.fetchMux.Unlock()
319+
return nil
320+
}
321+
322+
func (c *Checker) Close() {
323+
c.cache.Close()
324+
}

0 commit comments

Comments
 (0)