Skip to content

Commit

Permalink
RA: Allow profile selection to be gated on account-based allow lists
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Jan 21, 2025
1 parent 80f653d commit c73237e
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 0 deletions.
23 changes: 23 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,15 @@ 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.
ValidationProfiles map[string]struct {
// AllowList specifies the file path to a YAML list of account IDs
// permitted to use this profile. If left empty, no accounts are
// allowed to use this profile.
AllowList string `validate:"omitempty"`
}

// GoodKey is an embedded config stanza for the goodkey library.
GoodKey goodkey.Config

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

validationProfiles := make(map[string]*ra.ValidationProfile)
for profileName, v := range c.RA.ValidationProfiles {
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 +311,7 @@ func main() {
c.RA.MaxNames,
authorizationLifetime,
pendingAuthorizationLifetime,
validationProfiles,
pubc,
c.RA.OrderLifetime.Duration,
c.RA.FinalizeTimeout.Duration,
Expand Down
28 changes: 28 additions & 0 deletions ra/ra.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 @@ -66,6 +67,18 @@ var (
caaRecheckDuration = -7 * time.Hour
)

// ValidationProfile holds the allowlist for a given validation profile.
type ValidationProfile struct {
// allowList the set of account IDs allowed to use this profile. If left
// empty, no accounts are allowed to use this profile.
allowList *allowlist.List[int64]
}

// NewValidationProfile creates a new ValidationProfile with the provided allowList.
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 @@ -85,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 @@ -127,6 +141,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 @@ -245,6 +260,7 @@ func NewRegistrationAuthorityImpl(
log: logger,
authorizationLifetime: authorizationLifetime,
pendingAuthorizationLifetime: pendingAuthorizationLifetime,
validationProfiles: validationProfiles,
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
Expand Down Expand Up @@ -2084,6 +2100,18 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
"Order cannot contain more than %d DNS names", ra.maxNames)
}

if req.CertificateProfileName != "" {
profileValidation, ok := ra.validationProfiles[req.CertificateProfileName]
if ok {
if !profileValidation.allowList.Contains(req.RegistrationID) {
return nil, berrors.UnauthorizedError("account ID %d is not permitted to use 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
41 changes: 41 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,45 @@ func TestNewOrder_AuthzReuse_NoPending(t *testing.T) {
test.AssertNotEquals(t, new.V2Authorizations[0], extant.V2Authorizations[0])
}

func TestNewOrder_ProfileSelectionAllowList(t *testing.T) {
_, _, ra, _, _, cleanUp := initAuthorities(t)
defer cleanUp()

// Set up an allowlist that doesn't contain Registration.Id.
ra.validationProfiles = map[string]*ValidationProfile{
"test": {
allowList: allowlist.NewList[int64]([]int64{1337}),
},
}

// Issuance should fail with an unauthorized error regarding the profile.
orderReq := &rapb.NewOrderRequest{
RegistrationID: Registration.Id,
DnsNames: []string{"a.example.com"},
CertificateProfileName: "test",
}
_, err := ra.NewOrder(context.Background(), orderReq)
test.AssertError(t, err, "NewOrder with invalid profile did not error")
test.AssertErrorIs(t, err, berrors.Unauthorized)
test.AssertContains(t, err.Error(), "not permitted to use profile")

// Set up an allowlist that contains Registration.Id.
ra.validationProfiles = map[string]*ValidationProfile{
"test": {
allowList: allowlist.NewList([]int64{Registration.Id}),
},
}

// Issuance should succeed with the profile.
orderReq = &rapb.NewOrderRequest{
RegistrationID: Registration.Id,
DnsNames: []string{"a.example.com"},
CertificateProfileName: "test",
}
_, err = ra.NewOrder(context.Background(), orderReq)
test.AssertNotError(t, err, "NewOrder for account ID that is on the allowlist 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

0 comments on commit c73237e

Please sign in to comment.