Skip to content

Commit 7ea51e5

Browse files
pgporadaaarongable
andauthored
boulder-observer: check certificate status via CRL too (#8186)
Let's Encrypt [recently removed OCSP URLs from certificates](https://community.letsencrypt.org/t/removing-ocsp-urls-from-certificates/236699) which unfortunately caused the boulder-observer TLS prober to panic. This change short circuits the OCSP checking logic if no OCSP URL exists in the to-be-checked certificate. Fixes #8185 --------- Co-authored-by: Aaron Gable <[email protected]>
1 parent ac2dae7 commit 7ea51e5

File tree

5 files changed

+348
-48
lines changed

5 files changed

+348
-48
lines changed

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ services:
4444
# we can put that name inside our integration test certs (e.g. as a crl
4545
# url) and have it look like a publicly-accessible name.
4646
- "ca.example.org:10.77.77.77"
47+
# Allow the boulder container to be reached as "integration.trust", for
48+
# similar reasons, but intended for use as a SAN rather than a CRLDP.
49+
- "integration.trust:10.77.77.77"
4750
ports:
4851
- 4001:4001 # ACMEv2
4952
- 4002:4002 # OCSP

observer/probers/tls/tls.go

Lines changed: 143 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,35 @@ import (
55
"crypto/tls"
66
"crypto/x509"
77
"encoding/base64"
8+
"errors"
89
"fmt"
910
"io"
1011
"net"
1112
"net/http"
1213
"time"
1314

14-
"github.com/letsencrypt/boulder/observer/obsdialer"
1515
"github.com/prometheus/client_golang/prometheus"
1616
"golang.org/x/crypto/ocsp"
17+
18+
"github.com/letsencrypt/boulder/observer/obsdialer"
1719
)
1820

1921
type reason int
2022

2123
const (
2224
none reason = iota
2325
internalError
24-
ocspError
26+
revocationStatusError
2527
rootDidNotMatch
26-
responseDidNotMatch
28+
statusDidNotMatch
2729
)
2830

2931
var reasonToString = map[reason]string{
30-
none: "nil",
31-
internalError: "internalError",
32-
ocspError: "ocspError",
33-
rootDidNotMatch: "rootDidNotMatch",
34-
responseDidNotMatch: "responseDidNotMatch",
32+
none: "nil",
33+
internalError: "internalError",
34+
revocationStatusError: "revocationStatusError",
35+
rootDidNotMatch: "rootDidNotMatch",
36+
statusDidNotMatch: "statusDidNotMatch",
3537
}
3638

3739
func getReasons() []string {
@@ -65,14 +67,19 @@ func (p TLSProbe) Kind() string {
6567
}
6668

6769
// Get OCSP status (good, revoked or unknown) of certificate
68-
func checkOCSP(cert, issuer *x509.Certificate, want int) (bool, error) {
70+
func checkOCSP(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
6971
req, err := ocsp.CreateRequest(cert, issuer, nil)
7072
if err != nil {
7173
return false, err
7274
}
7375

7476
url := fmt.Sprintf("%s/%s", cert.OCSPServer[0], base64.StdEncoding.EncodeToString(req))
75-
res, err := http.Get(url)
77+
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
78+
if err != nil {
79+
return false, err
80+
}
81+
82+
res, err := http.DefaultClient.Do(r)
7683
if err != nil {
7784
return false, err
7885
}
@@ -90,6 +97,45 @@ func checkOCSP(cert, issuer *x509.Certificate, want int) (bool, error) {
9097
return ocspRes.Status == want, nil
9198
}
9299

100+
func checkCRL(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
101+
if len(cert.CRLDistributionPoints) != 1 {
102+
return false, errors.New("cert does not contain CRLDP URI")
103+
}
104+
105+
req, err := http.NewRequestWithContext(ctx, "GET", cert.CRLDistributionPoints[0], nil)
106+
if err != nil {
107+
return false, fmt.Errorf("creating HTTP request: %w", err)
108+
}
109+
110+
resp, err := http.DefaultClient.Do(req)
111+
if err != nil {
112+
return false, fmt.Errorf("downloading CRL: %w", err)
113+
}
114+
defer resp.Body.Close()
115+
116+
der, err := io.ReadAll(resp.Body)
117+
if err != nil {
118+
return false, fmt.Errorf("reading CRL: %w", err)
119+
}
120+
121+
crl, err := x509.ParseRevocationList(der)
122+
if err != nil {
123+
return false, fmt.Errorf("parsing CRL: %w", err)
124+
}
125+
126+
err = crl.CheckSignatureFrom(issuer)
127+
if err != nil {
128+
return false, fmt.Errorf("validating CRL: %w", err)
129+
}
130+
131+
for _, entry := range crl.RevokedCertificateEntries {
132+
if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 {
133+
return want == ocsp.Revoked, nil
134+
}
135+
}
136+
return want == ocsp.Good, nil
137+
}
138+
93139
// Return an error if the root settings are nonempty and do not match the
94140
// expected root.
95141
func (p TLSProbe) checkRoot(rootOrg, rootCN string) error {
@@ -109,40 +155,54 @@ func (p TLSProbe) exportMetrics(cert *x509.Certificate, reason reason) {
109155
}
110156

111157
func (p TLSProbe) probeExpired(timeout time.Duration) bool {
112-
config := &tls.Config{
113-
// Set InsecureSkipVerify to skip the default validation we are
114-
// replacing. This will not disable VerifyConnection.
115-
InsecureSkipVerify: true,
116-
VerifyConnection: func(cs tls.ConnectionState) error {
117-
opts := x509.VerifyOptions{
118-
CurrentTime: cs.PeerCertificates[0].NotAfter,
119-
Intermediates: x509.NewCertPool(),
120-
}
121-
for _, cert := range cs.PeerCertificates[1:] {
122-
opts.Intermediates.AddCert(cert)
123-
}
124-
_, err := cs.PeerCertificates[0].Verify(opts)
125-
return err
126-
},
158+
addr := p.hostname
159+
_, _, err := net.SplitHostPort(addr)
160+
if err != nil {
161+
addr = net.JoinHostPort(addr, "443")
127162
}
128-
ctx, cancel := context.WithTimeout(context.Background(), timeout)
129-
defer cancel()
163+
130164
tlsDialer := tls.Dialer{
131165
NetDialer: &obsdialer.Dialer,
132-
Config: config,
166+
Config: &tls.Config{
167+
// Set InsecureSkipVerify to skip the default validation we are
168+
// replacing. This will not disable VerifyConnection.
169+
InsecureSkipVerify: true,
170+
VerifyConnection: func(cs tls.ConnectionState) error {
171+
issuers := x509.NewCertPool()
172+
for _, cert := range cs.PeerCertificates[1:] {
173+
issuers.AddCert(cert)
174+
}
175+
opts := x509.VerifyOptions{
176+
// We set the current time to be the cert's expiration date so that
177+
// the validation routine doesn't complain that the cert is expired.
178+
CurrentTime: cs.PeerCertificates[0].NotAfter,
179+
// By settings roots and intermediates to be whatever was presented
180+
// in the handshake, we're saying that we don't care about the cert
181+
// chaining up to the system trust store. This is safe because we
182+
// check the root ourselves in checkRoot().
183+
Intermediates: issuers,
184+
Roots: issuers,
185+
}
186+
_, err := cs.PeerCertificates[0].Verify(opts)
187+
return err
188+
},
189+
},
133190
}
134-
conn, err := tlsDialer.DialContext(ctx, "tcp", p.hostname+":443")
191+
192+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
193+
defer cancel()
194+
195+
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
135196
if err != nil {
136197
p.exportMetrics(nil, internalError)
137198
return false
138199
}
139200
defer conn.Close()
140201

141202
// tls.Dialer.DialContext is documented to always return *tls.Conn
142-
tlsConn := conn.(*tls.Conn)
143-
peers := tlsConn.ConnectionState().PeerCertificates
203+
peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
144204
if time.Until(peers[0].NotAfter) > 0 {
145-
p.exportMetrics(peers[0], responseDidNotMatch)
205+
p.exportMetrics(peers[0], statusDidNotMatch)
146206
return false
147207
}
148208

@@ -158,35 +218,77 @@ func (p TLSProbe) probeExpired(timeout time.Duration) bool {
158218
}
159219

160220
func (p TLSProbe) probeUnexpired(timeout time.Duration) bool {
161-
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", p.hostname+":443", &tls.Config{})
221+
addr := p.hostname
222+
_, _, err := net.SplitHostPort(addr)
223+
if err != nil {
224+
addr = net.JoinHostPort(addr, "443")
225+
}
226+
227+
tlsDialer := tls.Dialer{
228+
NetDialer: &obsdialer.Dialer,
229+
Config: &tls.Config{
230+
// Set InsecureSkipVerify to skip the default validation we are
231+
// replacing. This will not disable VerifyConnection.
232+
InsecureSkipVerify: true,
233+
VerifyConnection: func(cs tls.ConnectionState) error {
234+
issuers := x509.NewCertPool()
235+
for _, cert := range cs.PeerCertificates[1:] {
236+
issuers.AddCert(cert)
237+
}
238+
opts := x509.VerifyOptions{
239+
// By settings roots and intermediates to be whatever was presented
240+
// in the handshake, we're saying that we don't care about the cert
241+
// chaining up to the system trust store. This is safe because we
242+
// check the root ourselves in checkRoot().
243+
Intermediates: issuers,
244+
Roots: issuers,
245+
}
246+
_, err := cs.PeerCertificates[0].Verify(opts)
247+
return err
248+
},
249+
},
250+
}
251+
252+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
253+
defer cancel()
254+
255+
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
162256
if err != nil {
163257
p.exportMetrics(nil, internalError)
164258
return false
165259
}
166-
167260
defer conn.Close()
168-
peers := conn.ConnectionState().PeerCertificates
261+
262+
// tls.Dialer.DialContext is documented to always return *tls.Conn
263+
peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
169264
root := peers[len(peers)-1].Issuer
170265
err = p.checkRoot(root.Organization[0], root.CommonName)
171266
if err != nil {
172267
p.exportMetrics(peers[0], rootDidNotMatch)
173268
return false
174269
}
175270

176-
var ocspStatus bool
271+
var wantStatus int
177272
switch p.response {
178273
case "valid":
179-
ocspStatus, err = checkOCSP(peers[0], peers[1], ocsp.Good)
274+
wantStatus = ocsp.Good
180275
case "revoked":
181-
ocspStatus, err = checkOCSP(peers[0], peers[1], ocsp.Revoked)
276+
wantStatus = ocsp.Revoked
277+
}
278+
279+
var statusMatch bool
280+
if len(peers[0].OCSPServer) != 0 {
281+
statusMatch, err = checkOCSP(ctx, peers[0], peers[1], wantStatus)
282+
} else {
283+
statusMatch, err = checkCRL(ctx, peers[0], peers[1], wantStatus)
182284
}
183285
if err != nil {
184-
p.exportMetrics(peers[0], ocspError)
286+
p.exportMetrics(peers[0], revocationStatusError)
185287
return false
186288
}
187289

188-
if !ocspStatus {
189-
p.exportMetrics(peers[0], responseDidNotMatch)
290+
if !statusMatch {
291+
p.exportMetrics(peers[0], statusDidNotMatch)
190292
return false
191293
}
192294

observer/probers/tls/tls_conf.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package probers
22

33
import (
44
"fmt"
5+
"net"
56
"net/url"
7+
"strconv"
68
"strings"
79

10+
"github.com/prometheus/client_golang/prometheus"
11+
812
"github.com/letsencrypt/boulder/observer/probers"
913
"github.com/letsencrypt/boulder/strictyaml"
10-
"github.com/prometheus/client_golang/prometheus"
1114
)
1215

1316
const (
@@ -42,15 +45,28 @@ func (c TLSConf) UnmarshalSettings(settings []byte) (probers.Configurer, error)
4245
}
4346

4447
func (c TLSConf) validateHostname() error {
45-
url, err := url.Parse(c.Hostname)
48+
hostname := c.Hostname
49+
50+
if strings.Contains(c.Hostname, ":") {
51+
host, port, err := net.SplitHostPort(c.Hostname)
52+
if err != nil {
53+
return fmt.Errorf("invalid 'hostname', got %q, expected a valid hostport: %s", c.Hostname, err)
54+
}
55+
56+
_, err = strconv.Atoi(port)
57+
if err != nil {
58+
return fmt.Errorf("invalid 'hostname', got %q, expected a valid hostport: %s", c.Hostname, err)
59+
}
60+
hostname = host
61+
}
62+
63+
url, err := url.Parse(hostname)
4664
if err != nil {
47-
return fmt.Errorf(
48-
"invalid 'hostname', got %q, expected a valid hostname: %s", c.Hostname, err)
65+
return fmt.Errorf("invalid 'hostname', got %q, expected a valid hostname: %s", c.Hostname, err)
4966
}
5067

5168
if url.Scheme != "" {
52-
return fmt.Errorf(
53-
"invalid 'hostname', got: %q, should not include scheme", c.Hostname)
69+
return fmt.Errorf("invalid 'hostname', got: %q, should not include scheme", c.Hostname)
5470
}
5571

5672
return nil

observer/probers/tls/tls_conf_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"reflect"
55
"testing"
66

7-
"github.com/letsencrypt/boulder/observer/probers"
87
"github.com/prometheus/client_golang/prometheus"
98
"gopkg.in/yaml.v3"
9+
10+
"github.com/letsencrypt/boulder/observer/probers"
1011
)
1112

1213
func TestTLSConf_MakeProber(t *testing.T) {
@@ -33,10 +34,12 @@ func TestTLSConf_MakeProber(t *testing.T) {
3334
// valid
3435
{"valid hostname", fields{"example.com", goodRootCN, "valid"}, colls, false},
3536
{"valid hostname with path", fields{"example.com/foo/bar", "ISRG Root X2", "Revoked"}, colls, false},
37+
{"valid hostname with port", fields{"example.com:8080", goodRootCN, "expired"}, colls, false},
3638

3739
// invalid hostname
3840
{"bad hostname", fields{":::::", goodRootCN, goodResponse}, colls, true},
3941
{"included scheme", fields{"https://example.com", goodRootCN, goodResponse}, colls, true},
42+
{"included scheme and port", fields{"https://example.com:443", goodRootCN, goodResponse}, colls, true},
4043

4144
// invalid response
4245
{"empty response", fields{goodHostname, goodRootCN, ""}, colls, true},

0 commit comments

Comments
 (0)