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: Allow profile selection to be gated on account-based allow lists #7959

Merged
merged 12 commits into from
Jan 24, 2025
Merged
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
60 changes: 57 additions & 3 deletions allowlist/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
)

func TestNewFromYAML(t *testing.T) {
t.Parallel()

tests := []struct {
name string
yamlData string
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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)
beautifulentropy marked this conversation as resolved.
Show resolved Hide resolved
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])
}
}
})
}
}
30 changes: 30 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,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

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -289,6 +318,7 @@ func main() {
c.RA.MaxNames,
authorizationLifetime,
pendingAuthorizationLifetime,
validationProfiles,
pubc,
c.RA.OrderLifetime.Duration,
c.RA.FinalizeTimeout.Duration,
Expand Down
32 changes: 32 additions & 0 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,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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -230,6 +246,7 @@ func NewRegistrationAuthorityImpl(
log: logger,
authorizationLifetime: authorizationLifetime,
pendingAuthorizationLifetime: pendingAuthorizationLifetime,
validationProfiles: validationProfiles,
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions ra/ra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1666,6 +1668,68 @@ func TestNewOrder_AuthzReuse_NoPending(t *testing.T) {
test.AssertNotEquals(t, new.V2Authorizations[0], extant.V2Authorizations[0])
}

func TestNewOrder_ProfileSelectionAllowList(t *testing.T) {
beautifulentropy marked this conversation as resolved.
Show resolved Hide resolved
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",
beautifulentropy marked this conversation as resolved.
Show resolved Hide resolved
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) {
t.Parallel()

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.expectErr {
test.AssertError(t, err, "NewOrder did not error")
test.AssertErrorIs(t, err, berrors.Unauthorized)
beautifulentropy marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
Loading