diff --git a/cmd/boulder-ra/main.go b/cmd/boulder-ra/main.go index 9ba0107af..1149f88df 100644 --- a/cmd/boulder-ra/main.go +++ b/cmd/boulder-ra/main.go @@ -105,6 +105,13 @@ type Config struct { AllowList string `validate:"omitempty"` } + // MustStapleAllowList specifies the path to a YAML file containing a + // list of account IDs permitted to request certificates with the OCSP + // Must-Staple extension. If no path is specified, the extension is + // permitted for all accounts. If the file exists but is empty, the + // extension is disabled for all accounts. + MustStapleAllowList string `validate:"omitempty"` + // GoodKey is an embedded config stanza for the goodkey library. GoodKey goodkey.Config @@ -281,6 +288,14 @@ func main() { } } + var mustStapleAllowList *allowlist.List[int64] + if c.RA.MustStapleAllowList != "" { + data, err := os.ReadFile(c.RA.MustStapleAllowList) + cmd.FailOnError(err, "Failed to read allow list for Must-Staple extension") + mustStapleAllowList, err = allowlist.NewFromYAML[int64](data) + cmd.FailOnError(err, "Failed to parse allow list for Must-Staple extension") + } + if features.Get().AsyncFinalize && c.RA.FinalizeTimeout.Duration == 0 { cmd.Fail("finalizeTimeout must be supplied when AsyncFinalize feature is enabled") } @@ -319,6 +334,7 @@ func main() { authorizationLifetime, pendingAuthorizationLifetime, validationProfiles, + mustStapleAllowList, pubc, c.RA.OrderLifetime.Duration, c.RA.FinalizeTimeout.Duration, diff --git a/ra/ra.go b/ra/ra.go index cafa5cfd7..4b5e8dceb 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -99,6 +99,7 @@ type RegistrationAuthorityImpl struct { authorizationLifetime time.Duration pendingAuthorizationLifetime time.Duration validationProfiles map[string]*ValidationProfile + mustStapleAllowList *allowlist.List[int64] maxContactsPerReg int limiter *ratelimits.Limiter txnBuilder *ratelimits.TransactionBuilder @@ -112,17 +113,18 @@ type RegistrationAuthorityImpl struct { ctpolicy *ctpolicy.CTPolicy - ctpolicyResults *prometheus.HistogramVec - revocationReasonCounter *prometheus.CounterVec - namesPerCert *prometheus.HistogramVec - newRegCounter prometheus.Counter - recheckCAACounter prometheus.Counter - newCertCounter *prometheus.CounterVec - authzAges *prometheus.HistogramVec - orderAges *prometheus.HistogramVec - inflightFinalizes prometheus.Gauge - certCSRMismatch prometheus.Counter - pauseCounter *prometheus.CounterVec + ctpolicyResults *prometheus.HistogramVec + revocationReasonCounter *prometheus.CounterVec + namesPerCert *prometheus.HistogramVec + newRegCounter prometheus.Counter + recheckCAACounter prometheus.Counter + newCertCounter *prometheus.CounterVec + authzAges *prometheus.HistogramVec + orderAges *prometheus.HistogramVec + inflightFinalizes prometheus.Gauge + certCSRMismatch prometheus.Counter + pauseCounter *prometheus.CounterVec + mustStapleRequestsCounter *prometheus.CounterVec } var _ rapb.RegistrationAuthorityServer = (*RegistrationAuthorityImpl)(nil) @@ -140,6 +142,7 @@ func NewRegistrationAuthorityImpl( authorizationLifetime time.Duration, pendingAuthorizationLifetime time.Duration, validationProfiles map[string]*ValidationProfile, + mustStapleAllowList *allowlist.List[int64], pubc pubpb.PublisherClient, orderLifetime time.Duration, finalizeTimeout time.Duration, @@ -236,6 +239,12 @@ func NewRegistrationAuthorityImpl( }, []string{"paused", "repaused", "grace"}) stats.MustRegister(pauseCounter) + mustStapleRequestsCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "must_staple_requests", + Help: "Number of times a must-staple request is made, labeled by allowlist=[allowed|denied]", + }, []string{"allowlist"}) + stats.MustRegister(mustStapleRequestsCounter) + issuersByNameID := make(map[issuance.NameID]*issuance.Certificate) for _, issuer := range issuers { issuersByNameID[issuer.NameID()] = issuer @@ -247,6 +256,7 @@ func NewRegistrationAuthorityImpl( authorizationLifetime: authorizationLifetime, pendingAuthorizationLifetime: pendingAuthorizationLifetime, validationProfiles: validationProfiles, + mustStapleAllowList: mustStapleAllowList, maxContactsPerReg: maxContactsPerReg, keyPolicy: keyPolicy, limiter: limiter, @@ -269,6 +279,7 @@ func NewRegistrationAuthorityImpl( inflightFinalizes: inflightFinalizes, certCSRMismatch: certCSRMismatch, pauseCounter: pauseCounter, + mustStapleRequestsCounter: mustStapleRequestsCounter, } return ra } @@ -945,6 +956,18 @@ func (ra *RegistrationAuthorityImpl) validateFinalizeRequest( return nil, berrors.BadCSRError("unable to parse CSR: %s", err.Error()) } + if ra.mustStapleAllowList != nil && issuance.ContainsMustStaple(csr.Extensions) { + if !ra.mustStapleAllowList.Contains(req.Order.RegistrationID) { + ra.mustStapleRequestsCounter.WithLabelValues("denied").Inc() + return nil, berrors.UnauthorizedError( + "OCSP must-staple extension is no longer available: see https://letsencrypt.org/2024/12/05/ending-ocsp", + ) + } else { + ra.mustStapleRequestsCounter.WithLabelValues("allowed").Inc() + } + + } + err = csrlib.VerifyCSR(ctx, csr, ra.maxNames, &ra.keyPolicy, ra.PA) if err != nil { // VerifyCSR returns berror instances that can be passed through as-is diff --git a/ra/ra_test.go b/ra/ra_test.go index 6fd99eef6..b0fbdb0e4 100644 --- a/ra/ra_test.go +++ b/ra/ra_test.go @@ -9,10 +9,12 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "encoding/json" "encoding/pem" "errors" "fmt" + "math" "math/big" mrand "math/rand/v2" "regexp" @@ -344,6 +346,7 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho 300*24*time.Hour, 7*24*time.Hour, nil, nil, + nil, 7*24*time.Hour, 5*time.Minute, ctp, nil, nil) ra.SA = sa @@ -2607,6 +2610,123 @@ func TestFinalizeOrderDisabledChallenge(t *testing.T) { test.AssertContains(t, err.Error(), "authorizations for these identifiers not valid") } +func TestFinalizeWithMustStaple(t *testing.T) { + _, sa, ra, _, fc, cleanUp := initAuthorities(t) + defer cleanUp() + + ocspMustStapleExt := pkix.Extension{ + // RFC 7633: id-pe-tlsfeature OBJECT IDENTIFIER ::= { id-pe 24 } + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}, + // ASN.1 encoding of: + // SEQUENCE + // INTEGER 5 + // where "5" is the status_request feature (RFC 6066) + Value: []byte{0x30, 0x03, 0x02, 0x01, 0x05}, + } + + testCases := []struct { + name string + mustStapleAllowList *allowlist.List[int64] + expectSuccess bool + expectErrorContains string + expectMetricWithLabel prometheus.Labels + }{ + { + name: "Allow only Registration.ID", + mustStapleAllowList: allowlist.NewList([]int64{Registration.Id}), + expectSuccess: true, + expectMetricWithLabel: prometheus.Labels{"allowlist": "allowed"}, + }, + { + name: "Deny all but account Id 1337", + mustStapleAllowList: allowlist.NewList([]int64{1337}), + expectSuccess: false, + expectErrorContains: "no longer available", + expectMetricWithLabel: prometheus.Labels{"allowlist": "denied"}, + }, + { + name: "Deny all account Ids", + mustStapleAllowList: allowlist.NewList([]int64{}), + expectSuccess: false, + expectErrorContains: "no longer available", + expectMetricWithLabel: prometheus.Labels{"allowlist": "denied"}, + }, + { + name: "Allow all account Ids", + mustStapleAllowList: nil, + expectSuccess: true, + // We don't expect this metric to be be emitted if the allowlist is nil. + expectMetricWithLabel: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ra.mustStapleAllowList = tc.mustStapleAllowList + + domain := randomDomain() + + authzID := createFinalizedAuthorization( + t, sa, domain, fc.Now().Add(24*time.Hour), core.ChallengeTypeHTTP01, fc.Now().Add(-1*time.Hour)) + + order, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + DnsNames: []string{domain}, + }) + test.AssertNotError(t, err, "creating test order") + test.AssertEquals(t, order.V2Authorizations[0], authzID) + + testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "generating test key") + + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + PublicKey: testKey.Public(), + DNSNames: []string{domain}, + ExtraExtensions: []pkix.Extension{ocspMustStapleExt}, + }, testKey) + test.AssertNotError(t, err, "creating must-staple CSR") + + serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + test.AssertNotError(t, err, "generating random serial number") + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: domain}, + DNSNames: []string{domain}, + NotBefore: fc.Now(), + NotAfter: fc.Now().Add(365 * 24 * time.Hour), + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + ExtraExtensions: []pkix.Extension{ocspMustStapleExt}, + } + cert, err := x509.CreateCertificate(rand.Reader, template, template, testKey.Public(), testKey) + test.AssertNotError(t, err, "creating certificate") + ra.CA = &mocks.MockCA{ + PEM: pem.EncodeToMemory(&pem.Block{ + Bytes: cert, + Type: "CERTIFICATE", + }), + } + + _, err = ra.FinalizeOrder(context.Background(), &rapb.FinalizeOrderRequest{ + Order: order, + Csr: csr, + }) + + if tc.expectSuccess { + test.AssertNotError(t, err, "finalization should succeed") + } else { + test.AssertError(t, err, "finalization should fail") + test.AssertContains(t, err.Error(), tc.expectErrorContains) + } + + if tc.expectMetricWithLabel != nil { + test.AssertMetricWithLabelsEquals(t, ra.mustStapleRequestsCounter, tc.expectMetricWithLabel, 1) + } + ra.mustStapleRequestsCounter.Reset() + }) + } +} + func TestIssueCertificateAuditLog(t *testing.T) { _, sa, ra, _, _, cleanUp := initAuthorities(t) defer cleanUp()