Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ra: Gate OCSP Must-Staple issuance on account-based allow list #7976

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion allowlist/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ func NewList[T comparable](members []T) *List[T] {
}

// NewFromYAML reads a YAML sequence of values of type T and returns a *List[T]
// containing those values. If the data cannot be parsed, an error is returned.
// containing those values. If data is empty, an empty (deny all) list is
// returned. If data cannot be parsed, an error is returned.
func NewFromYAML[T comparable](data []byte) (*List[T], error) {
if len(data) == 0 {
return NewList([]T{}), nil
}

var entries []T
err := strictyaml.Unmarshal(data, &entries)
if err != nil {
Expand Down
52 changes: 49 additions & 3 deletions allowlist/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ func TestNewFromYAML(t *testing.T) {
{
name: "empty YAML",
yamlData: "",
check: nil,
expectAnswers: nil,
expectErr: true,
check: []string{"oak", "walnut", "maple", "cherry"},
expectAnswers: []bool{false, false, false, false},
expectErr: false,
},
{
name: "invalid YAML",
Expand Down Expand Up @@ -53,3 +53,49 @@ func TestNewFromYAML(t *testing.T) {
})
}
}

func TestNewList(t *testing.T) {
tests := []struct {
name string
members []string
check []string
expectAnswers []bool
}{
{
name: "unique members",
members: []string{"oak", "maple", "cherry"},
check: []string{"oak", "walnut", "maple", "cherry"},
expectAnswers: []bool{true, false, true, true},
},
{
name: "duplicate members",
members: []string{"oak", "maple", "cherry", "oak"},
check: []string{"oak", "walnut", "maple", "cherry"},
expectAnswers: []bool{true, false, true, true},
},
{
name: "nil list",
members: nil,
check: []string{"oak", "walnut", "maple", "cherry"},
expectAnswers: []bool{false, false, false, false},
},
{
name: "empty list",
members: []string{},
check: []string{"oak", "walnut", "maple", "cherry"},
expectAnswers: []bool{false, false, false, false},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
list := NewList[string](tt.members)
for i, item := range tt.check {
got := list.Contains(item)
if got != tt.expectAnswers[i] {
t.Errorf("Contains(%q) got %v, want %v", item, got, tt.expectAnswers[i])
}
}
})
}
}
49 changes: 49 additions & 0 deletions cmd/boulder-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package notmain
import (
"context"
"flag"
"fmt"
"os"
"time"

akamaipb "github.com/letsencrypt/boulder/akamai/proto"
"github.com/letsencrypt/boulder/allowlist"
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
Expand Down Expand Up @@ -91,6 +93,25 @@ type Config struct {
// you need to request a new challenge.
PendingAuthorizationLifetimeDays int `validate:"required,min=1,max=29"`

// ValidationProfiles is a map of validation profiles to their
// respective issuance allow lists. If a profile is not included in this
// mapping, it cannot be used by any account. If this field is left
// empty, all profiles are open to all accounts.
ValidationProfiles map[string]struct {
// AllowList specifies the path to a YAML file containing a list of
// account IDs permitted to use this profile. If no path is
// specified, the profile is open to all accounts. If the file
// exists but is empty, the profile is closed to all accounts.
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

Expand Down Expand Up @@ -252,6 +273,32 @@ func main() {
}
pendingAuthorizationLifetime := time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour

var validationProfiles map[string]*ra.ValidationProfile
if c.RA.ValidationProfiles != nil {
validationProfiles = make(map[string]*ra.ValidationProfile)
for profileName, v := range c.RA.ValidationProfiles {
if v.AllowList == "" {
// No allow list file is specified, this profile is open to all accounts.
validationProfiles[profileName] = ra.NewValidationProfile(nil)
} else {
data, err := os.ReadFile(v.AllowList)
cmd.FailOnError(err, fmt.Sprintf("Failed to read allow list for profile %q", profileName))
allowList, err := allowlist.NewFromYAML[int64](data)
cmd.FailOnError(err, fmt.Sprintf("Failed to parse allow list for profile %q", profileName))
// Use of this profile is restricted to the accounts listed in the allow list.
validationProfiles[profileName] = ra.NewValidationProfile(allowList)
}
}
}

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")
}
Expand Down Expand Up @@ -289,6 +336,8 @@ func main() {
c.RA.MaxNames,
authorizationLifetime,
pendingAuthorizationLifetime,
validationProfiles,
mustStapleAllowList,
pubc,
c.RA.OrderLifetime.Duration,
c.RA.FinalizeTimeout.Duration,
Expand Down
7 changes: 4 additions & 3 deletions issuance/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ func generateSCTListExt(scts []ct.SignedCertificateTimestamp) (pkix.Extension, e
}, nil
}

var mustStapleExt = pkix.Extension{
// OCSPMustStapleExt is the OCSP Must-Staple extension, as defined in RFC 7633.
var 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:
Expand Down Expand Up @@ -380,7 +381,7 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance
}

if req.IncludeMustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, mustStapleExt)
template.ExtraExtensions = append(template.ExtraExtensions, OCSPMustStapleExt)
}

// check that the tbsCertificate is properly formed by signing it
Expand Down Expand Up @@ -428,7 +429,7 @@ func (i *Issuer) Issue(token *issuanceToken) ([]byte, error) {
// Must-Staple (a.k.a. id-pe-tlsFeature) extension.
func ContainsMustStaple(extensions []pkix.Extension) bool {
for _, ext := range extensions {
if ext.Id.Equal(mustStapleExt.Id) && bytes.Equal(ext.Value, mustStapleExt.Value) {
if ext.Id.Equal(OCSPMustStapleExt.Id) && bytes.Equal(ext.Value, OCSPMustStapleExt.Value) {
return true
}
}
Expand Down
2 changes: 1 addition & 1 deletion issuance/cert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ func TestIssueMustStaple(t *testing.T) {
test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9})
test.AssertDeepEquals(t, cert.PublicKey, pk.Public())
test.AssertEquals(t, len(cert.Extensions), 10) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, Must-Staple, Poison
test.AssertDeepEquals(t, cert.Extensions[9], mustStapleExt)
test.AssertDeepEquals(t, cert.Extensions[9], OCSPMustStapleExt)
}

func TestIssueBadLint(t *testing.T) {
Expand Down
81 changes: 70 additions & 11 deletions ra/ra.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/letsencrypt/boulder/akamai"
akamaipb "github.com/letsencrypt/boulder/akamai/proto"
"github.com/letsencrypt/boulder/allowlist"
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/core"
corepb "github.com/letsencrypt/boulder/core/proto"
Expand Down Expand Up @@ -65,6 +66,19 @@ var (
caaRecheckDuration = -7 * time.Hour
)

// ValidationProfile holds the allowlist for a given validation profile.
type ValidationProfile struct {
// allowList holds the set of account IDs allowed to use this profile. If
// nil, the profile is open to all accounts (everyone is allowed).
allowList *allowlist.List[int64]
}

// NewValidationProfile creates a new ValidationProfile with the provided
// allowList. A nil allowList is interpreted as open access for all accounts.
func NewValidationProfile(allowList *allowlist.List[int64]) *ValidationProfile {
return &ValidationProfile{allowList: allowList}
}

// RegistrationAuthorityImpl defines an RA.
//
// NOTE: All of the fields in RegistrationAuthorityImpl need to be
Expand All @@ -84,6 +98,8 @@ type RegistrationAuthorityImpl struct {
// How long before a newly created authorization expires.
authorizationLifetime time.Duration
pendingAuthorizationLifetime time.Duration
validationProfiles map[string]*ValidationProfile
mustStapleAllowList *allowlist.List[int64]
maxContactsPerReg int
limiter *ratelimits.Limiter
txnBuilder *ratelimits.TransactionBuilder
Expand All @@ -97,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)
Expand All @@ -124,6 +141,8 @@ func NewRegistrationAuthorityImpl(
maxNames int,
authorizationLifetime time.Duration,
pendingAuthorizationLifetime time.Duration,
validationProfiles map[string]*ValidationProfile,
mustStapleAllowList *allowlist.List[int64],
pubc pubpb.PublisherClient,
orderLifetime time.Duration,
finalizeTimeout time.Duration,
Expand Down Expand Up @@ -220,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
Expand All @@ -230,6 +255,8 @@ func NewRegistrationAuthorityImpl(
log: logger,
authorizationLifetime: authorizationLifetime,
pendingAuthorizationLifetime: pendingAuthorizationLifetime,
validationProfiles: validationProfiles,
mustStapleAllowList: mustStapleAllowList,
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
Expand All @@ -252,6 +279,7 @@ func NewRegistrationAuthorityImpl(
inflightFinalizes: inflightFinalizes,
certCSRMismatch: certCSRMismatch,
pauseCounter: pauseCounter,
mustStapleRequestsCounter: mustStapleRequestsCounter,
}
return ra
}
Expand Down Expand Up @@ -928,6 +956,22 @@ func (ra *RegistrationAuthorityImpl) validateFinalizeRequest(
return nil, berrors.BadCSRError("unable to parse CSR: %s", err.Error())
}

if ra.mustStapleAllowList != nil {
for _, ext := range csr.Extensions {
if !ext.Id.Equal(issuance.OCSPMustStapleExt.Id) {
beautifulentropy marked this conversation as resolved.
Show resolved Hide resolved
continue
}
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
Expand Down Expand Up @@ -2122,6 +2166,21 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
"Order cannot contain more than %d DNS names", ra.maxNames)
}

if req.CertificateProfileName != "" && ra.validationProfiles != nil {
vp, ok := ra.validationProfiles[req.CertificateProfileName]
if !ok {
return nil, berrors.MalformedError("requested certificate profile %q not found",
req.CertificateProfileName,
)
}
if ok && vp.allowList != nil && !vp.allowList.Contains(req.RegistrationID) {
return nil, berrors.UnauthorizedError("account ID %d is not permitted to use certificate profile %q",
req.RegistrationID,
req.CertificateProfileName,
)
}
}

// Validate that our policy allows issuing for each of the names in the order
err := ra.PA.WillingToIssue(newOrder.DnsNames)
if err != nil {
Expand Down
Loading
Loading