diff --git a/allowlist/main.go b/allowlist/main.go index 70839ff03..b7a0e5c35 100644 --- a/allowlist/main.go +++ b/allowlist/main.go @@ -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 { diff --git a/allowlist/main_test.go b/allowlist/main_test.go index 9a6b4132d..97bef54cb 100644 --- a/allowlist/main_test.go +++ b/allowlist/main_test.go @@ -5,6 +5,8 @@ import ( ) func TestNewFromYAML(t *testing.T) { + t.Parallel() + tests := []struct { name string yamlData string @@ -22,9 +24,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", @@ -37,6 +39,8 @@ func TestNewFromYAML(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + list, err := NewFromYAML[string]([]byte(tt.yamlData)) if (err != nil) != tt.expectErr { t.Fatalf("NewFromYAML() error = %v, expectErr = %v", err, tt.expectErr) @@ -53,3 +57,53 @@ func TestNewFromYAML(t *testing.T) { }) } } + +func TestNewList(t *testing.T) { + t.Parallel() + + 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) { + t.Parallel() + + 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]) + } + } + }) + } +} diff --git a/cmd/boulder-ra/main.go b/cmd/boulder-ra/main.go index e0b2e3e91..9ba0107af 100644 --- a/cmd/boulder-ra/main.go +++ b/cmd/boulder-ra/main.go @@ -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" @@ -91,6 +93,18 @@ 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"` + } + // GoodKey is an embedded config stanza for the goodkey library. GoodKey goodkey.Config @@ -252,6 +266,21 @@ 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 { + var allowList *allowlist.List[int64] + if v.AllowList != "" { + 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)) + } + validationProfiles[profileName] = ra.NewValidationProfile(allowList) + } + } + if features.Get().AsyncFinalize && c.RA.FinalizeTimeout.Duration == 0 { cmd.Fail("finalizeTimeout must be supplied when AsyncFinalize feature is enabled") } @@ -289,6 +318,7 @@ func main() { c.RA.MaxNames, authorizationLifetime, pendingAuthorizationLifetime, + validationProfiles, pubc, c.RA.OrderLifetime.Duration, c.RA.FinalizeTimeout.Duration, diff --git a/ra/ra.go b/ra/ra.go index 0fe36ff65..cafa5cfd7 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -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" @@ -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 @@ -84,6 +98,7 @@ type RegistrationAuthorityImpl struct { // How long before a newly created authorization expires. authorizationLifetime time.Duration pendingAuthorizationLifetime time.Duration + validationProfiles map[string]*ValidationProfile maxContactsPerReg int limiter *ratelimits.Limiter txnBuilder *ratelimits.TransactionBuilder @@ -124,6 +139,7 @@ func NewRegistrationAuthorityImpl( maxNames int, authorizationLifetime time.Duration, pendingAuthorizationLifetime time.Duration, + validationProfiles map[string]*ValidationProfile, pubc pubpb.PublisherClient, orderLifetime time.Duration, finalizeTimeout time.Duration, @@ -230,6 +246,7 @@ func NewRegistrationAuthorityImpl( log: logger, authorizationLifetime: authorizationLifetime, pendingAuthorizationLifetime: pendingAuthorizationLifetime, + validationProfiles: validationProfiles, maxContactsPerReg: maxContactsPerReg, keyPolicy: keyPolicy, limiter: limiter, @@ -2122,6 +2139,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 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 { diff --git a/ra/ra_test.go b/ra/ra_test.go index 2db6ba480..460144fdd 100644 --- a/ra/ra_test.go +++ b/ra/ra_test.go @@ -36,6 +36,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" akamaipb "github.com/letsencrypt/boulder/akamai/proto" + "github.com/letsencrypt/boulder/allowlist" capb "github.com/letsencrypt/boulder/ca/proto" "github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/core" @@ -342,6 +343,7 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho 1, testKeyPolicy, limiter, txnBuilder, 100, 300*24*time.Hour, 7*24*time.Hour, nil, + nil, 7*24*time.Hour, 5*time.Minute, ctp, nil, nil) ra.SA = sa @@ -1666,6 +1668,65 @@ func TestNewOrder_AuthzReuse_NoPending(t *testing.T) { test.AssertNotEquals(t, new.V2Authorizations[0], extant.V2Authorizations[0]) } +func TestNewOrder_ProfileSelectionAllowList(t *testing.T) { + t.Parallel() + + _, _, ra, _, _, cleanUp := initAuthorities(t) + defer cleanUp() + + testCases := []struct { + name string + allowList *allowlist.List[int64] + expectErr bool + expectErrContains string + }{ + { + name: "Allow All Account IDs", + allowList: nil, + expectErr: false, + }, + { + name: "Deny all but account Id 1337", + allowList: allowlist.NewList([]int64{1337}), + expectErr: true, + expectErrContains: "not permitted to use certificate profile", + }, + { + name: "Deny all", + allowList: allowlist.NewList([]int64{}), + expectErr: true, + expectErrContains: "not permitted to use certificate profile", + }, + { + name: "Allow Registration.Id", + allowList: allowlist.NewList([]int64{Registration.Id}), + expectErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ra.validationProfiles = map[string]*ValidationProfile{ + "test": NewValidationProfile(tc.allowList), + } + + orderReq := &rapb.NewOrderRequest{ + RegistrationID: Registration.Id, + DnsNames: []string{randomDomain()}, + CertificateProfileName: "test", + } + _, err := ra.NewOrder(context.Background(), orderReq) + + if tc.expectErrContains != "" { + test.AssertErrorIs(t, err, berrors.Unauthorized) + test.AssertContains(t, err.Error(), tc.expectErrContains) + } else { + test.AssertNotError(t, err, "NewOrder failed") + } + }) + } +} + // mockSAWithAuthzs has a GetAuthorizations2 method that returns the protobuf // version of its authzs struct member. It also has a fake GetOrderForNames // which always fails, and a fake NewOrderAndAuthzs which always succeeds, to