RA: Use Validation Profiles to determine order/authz lifetimes (#7989)

Add three new fields to the ra.ValidationProfile structure, representing
the profile's pending authorization lifetime (used to assign an
expiration when a new authz is created), valid authorization lifetime
(used to assign an expiration when an authz is successfully validated),
and order lifetime (used to assign an expiration when a new order is
created). Remove the prior top-level fields which controlled these
values across all orders.

Add a "defaultProfileName" field to the RA as well, to facilitate
looking up a default set of lifetimes when the order doesn't specify a
profile. If this default name is explicitly configured, always provide
it to the CA when requesting issuance, so we don't have to duplicate the
default between the two services.

Modify the RA's config struct in a corresponding way: add three new
fields to the ValidationProfiles structure, and deprecate the three old
top-level fields. Also upgrade the ra.NewValidationProfile constructor
to handle these new fields, including doing validation on their values.

Fixes https://github.com/letsencrypt/boulder/issues/7605
This commit is contained in:
Aaron Gable 2025-02-04 08:44:43 -08:00 committed by GitHub
parent 6695895f8b
commit 2f8c6bc522
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 458 additions and 257 deletions

View File

@ -3,7 +3,6 @@ package notmain
import (
"context"
"flag"
"fmt"
"os"
"time"
@ -86,24 +85,33 @@ type Config struct {
// considered valid for. Given a value of 300 days when used with a 90-day
// cert lifetime, this allows creation of certs that will cover a whole
// year, plus a grace period of a month.
AuthorizationLifetimeDays int `validate:"required,min=1,max=397"`
//
// Deprecated: use ValidationProfiles.[profile].ValidAuthzLifetime instead.
// TODO(#7986): Remove this.
AuthorizationLifetimeDays int `validate:"omitempty,required_without=ValidationProfiles,min=1,max=397"`
// PendingAuthorizationLifetimeDays defines how long authorizations may be in
// the pending state. If you can't respond to a challenge this quickly, then
// you need to request a new challenge.
PendingAuthorizationLifetimeDays int `validate:"required,min=1,max=29"`
//
// Deprecated: use ValidationProfiles.[profile].PendingAuthzLifetime instead.
// TODO(#7986): Remove this.
PendingAuthorizationLifetimeDays int `validate:"omitempty,required_without=ValidationProfiles,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"`
}
// TODO(#7986): Make this field required.
ValidationProfiles map[string]ra.ValidationProfileConfig `validate:"omitempty"`
// DefaultProfileName sets the profile to use if one wasn't provided by the
// client in the new-order request. Must match a configured validation
// profile or the RA will fail to start. Must match a certificate profile
// configured in the CA or finalization will fail for orders using this
// default.
// TODO(#7986): Make this field unconditionally required.
DefaultProfileName string `validate:"required_with=ValidationProfiles"`
// MustStapleAllowList specifies the path to a YAML file containing a
// list of account IDs permitted to request certificates with the OCSP
@ -117,7 +125,10 @@ type Config struct {
// OrderLifetime is how far in the future an Order's expiration date should
// be set when it is first created.
OrderLifetime config.Duration
//
// Deprecated: Use ValidationProfiles.[profile].OrderLifetime instead.
// TODO(#7986): Remove this.
OrderLifetime config.Duration `validate:"omitempty,required_without=ValidationProfiles"`
// FinalizeTimeout is how long the RA is willing to wait for the Order
// finalization process to take. This config parameter only has an effect
@ -255,39 +266,30 @@ func main() {
ctp = ctpolicy.New(pubc, sctLogs, infoLogs, finalLogs, c.RA.CTLogs.Stagger.Duration, logger, scope)
// Baseline Requirements v1.8.1 section 4.2.1: "any reused data, document,
// or completed validation MUST be obtained no more than 398 days prior
// to issuing the Certificate". If unconfigured or the configured value is
// greater than 397 days, bail out.
if c.RA.AuthorizationLifetimeDays <= 0 || c.RA.AuthorizationLifetimeDays > 397 {
cmd.Fail("authorizationLifetimeDays value must be greater than 0 and less than 398")
// TODO(#7986): Remove this fallback, error out if no default is configured.
if c.RA.DefaultProfileName == "" {
c.RA.DefaultProfileName = ra.UnconfiguredDefaultProfileName
}
authorizationLifetime := time.Duration(c.RA.AuthorizationLifetimeDays) * 24 * time.Hour
logger.Infof("Configured default profile name set to: %s", c.RA.DefaultProfileName)
// The Baseline Requirements v1.8.1 state that validation tokens "MUST
// NOT be used for more than 30 days from its creation". If unconfigured
// or the configured value pendingAuthorizationLifetimeDays is greater
// than 29 days, bail out.
if c.RA.PendingAuthorizationLifetimeDays <= 0 || c.RA.PendingAuthorizationLifetimeDays > 29 {
cmd.Fail("pendingAuthorizationLifetimeDays value must be greater than 0 and less than 30")
}
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)
// TODO(#7986): Remove this fallback, error out if no profiles are configured.
if len(c.RA.ValidationProfiles) == 0 {
c.RA.ValidationProfiles = map[string]ra.ValidationProfileConfig{
c.RA.DefaultProfileName: {
PendingAuthzLifetime: config.Duration{
Duration: time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour},
ValidAuthzLifetime: config.Duration{
Duration: time.Duration(c.RA.AuthorizationLifetimeDays) * 24 * time.Hour},
OrderLifetime: c.RA.OrderLifetime,
// Leave the allowlist empty, so all accounts have access to this
// default profile.
},
}
}
validationProfiles, err := ra.NewValidationProfiles(c.RA.DefaultProfileName, c.RA.ValidationProfiles)
cmd.FailOnError(err, "Failed to load validation profiles")
var mustStapleAllowList *allowlist.List[int64]
if c.RA.MustStapleAllowList != "" {
data, err := os.ReadFile(c.RA.MustStapleAllowList)
@ -331,12 +333,9 @@ func main() {
limiter,
txnBuilder,
c.RA.MaxNames,
authorizationLifetime,
pendingAuthorizationLifetime,
validationProfiles,
mustStapleAllowList,
pubc,
c.RA.OrderLifetime.Duration,
c.RA.FinalizeTimeout.Duration,
ctp,
apc,

346
ra/ra.go
View File

@ -10,6 +10,7 @@ import (
"fmt"
"net"
"net/url"
"os"
"slices"
"sort"
"strconv"
@ -30,6 +31,7 @@ import (
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"
corepb "github.com/letsencrypt/boulder/core/proto"
csrlib "github.com/letsencrypt/boulder/csr"
@ -66,19 +68,6 @@ 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
@ -92,21 +81,17 @@ type RegistrationAuthorityImpl struct {
PA core.PolicyAuthority
publisher pubpb.PublisherClient
clk clock.Clock
log blog.Logger
keyPolicy goodkey.KeyPolicy
// 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
maxNames int
orderLifetime time.Duration
finalizeTimeout time.Duration
drainWG sync.WaitGroup
clk clock.Clock
log blog.Logger
keyPolicy goodkey.KeyPolicy
profiles *validationProfiles
mustStapleAllowList *allowlist.List[int64]
maxContactsPerReg int
limiter *ratelimits.Limiter
txnBuilder *ratelimits.TransactionBuilder
maxNames int
finalizeTimeout time.Duration
drainWG sync.WaitGroup
issuersByNameID map[issuance.NameID]*issuance.Certificate
purger akamaipb.AkamaiPurgerClient
@ -142,12 +127,9 @@ func NewRegistrationAuthorityImpl(
limiter *ratelimits.Limiter,
txnBuilder *ratelimits.TransactionBuilder,
maxNames int,
authorizationLifetime time.Duration,
pendingAuthorizationLifetime time.Duration,
validationProfiles map[string]*ValidationProfile,
profiles *validationProfiles,
mustStapleAllowList *allowlist.List[int64],
pubc pubpb.PublisherClient,
orderLifetime time.Duration,
finalizeTimeout time.Duration,
ctp *ctpolicy.CTPolicy,
purger akamaipb.AkamaiPurgerClient,
@ -262,40 +244,163 @@ func NewRegistrationAuthorityImpl(
}
ra := &RegistrationAuthorityImpl{
clk: clk,
log: logger,
authorizationLifetime: authorizationLifetime,
pendingAuthorizationLifetime: pendingAuthorizationLifetime,
validationProfiles: validationProfiles,
mustStapleAllowList: mustStapleAllowList,
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
txnBuilder: txnBuilder,
maxNames: maxNames,
publisher: pubc,
orderLifetime: orderLifetime,
finalizeTimeout: finalizeTimeout,
ctpolicy: ctp,
ctpolicyResults: ctpolicyResults,
purger: purger,
issuersByNameID: issuersByNameID,
namesPerCert: namesPerCert,
newRegCounter: newRegCounter,
recheckCAACounter: recheckCAACounter,
newCertCounter: newCertCounter,
revocationReasonCounter: revocationReasonCounter,
authzAges: authzAges,
orderAges: orderAges,
inflightFinalizes: inflightFinalizes,
certCSRMismatch: certCSRMismatch,
pauseCounter: pauseCounter,
mustStapleRequestsCounter: mustStapleRequestsCounter,
newOrUpdatedContactCounter: newOrUpdatedContactCounter,
clk: clk,
log: logger,
profiles: profiles,
mustStapleAllowList: mustStapleAllowList,
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
txnBuilder: txnBuilder,
maxNames: maxNames,
publisher: pubc,
finalizeTimeout: finalizeTimeout,
ctpolicy: ctp,
ctpolicyResults: ctpolicyResults,
purger: purger,
issuersByNameID: issuersByNameID,
namesPerCert: namesPerCert,
newRegCounter: newRegCounter,
recheckCAACounter: recheckCAACounter,
newCertCounter: newCertCounter,
revocationReasonCounter: revocationReasonCounter,
authzAges: authzAges,
orderAges: orderAges,
inflightFinalizes: inflightFinalizes,
certCSRMismatch: certCSRMismatch,
pauseCounter: pauseCounter,
mustStapleRequestsCounter: mustStapleRequestsCounter,
newOrUpdatedContactCounter: newOrUpdatedContactCounter,
}
return ra
}
// UnconfiguredDefaultProfileName is a unique string which the RA can use to
// identify a profile, but also detect that no profiles were explicitly
// configured, and therefore should not be assumed to exist outside the RA.
// TODO(#7986): Remove this when the defaultProfileName config is required.
const UnconfiguredDefaultProfileName = "unconfiguredDefaultProfileName"
// ValidationProfileConfig is a config struct which can be used to create a
// ValidationProfile.
type ValidationProfileConfig struct {
// PendingAuthzLifetime defines how far in the future an authorization's
// "expires" timestamp is set when it is first created, i.e. how much
// time the applicant has to attempt the challenge.
PendingAuthzLifetime config.Duration `validate:"required"`
// ValidAuthzLifetime defines how far in the future an authorization's
// "expires" timestamp is set when one of its challenges is fulfilled,
// i.e. how long a validated authorization may be reused.
ValidAuthzLifetime config.Duration `validate:"required"`
// OrderLifetime defines how far in the future an order's "expires"
// timestamp is set when it is first created, i.e. how much time the
// applicant has to fulfill all challenges and finalize the order. This is
// a maximum time: if the order reuses an authorization and that authz
// expires earlier than this OrderLifetime would otherwise set, then the
// order's expiration is brought in to match that authorization.
OrderLifetime config.Duration `validate:"required"`
// 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"`
}
// validationProfile holds the order and authz lifetimes and allowlist for a
// given validation profile.
type validationProfile struct {
// PendingAuthzLifetime defines how far in the future an authorization's
// "expires" timestamp is set when it is first created, i.e. how much
// time the applicant has to attempt the challenge.
pendingAuthzLifetime time.Duration
// ValidAuthzLifetime defines how far in the future an authorization's
// "expires" timestamp is set when one of its challenges is fulfilled,
// i.e. how long a validated authorization may be reused.
validAuthzLifetime time.Duration
// OrderLifetime defines how far in the future an order's "expires"
// timestamp is set when it is first created, i.e. how much time the
// applicant has to fulfill all challenges and finalize the order. This is
// a maximum time: if the order reuses an authorization and that authz
// expires earlier than this OrderLifetime would otherwise set, then the
// order's expiration is brought in to match that authorization.
orderLifetime time.Duration
// 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]
}
// validationProfiles provides access to the set of configured profiles,
// including the default profile for orders/authzs which do not specify one.
type validationProfiles struct {
defaultName string
byName map[string]*validationProfile
}
// NewValidationProfiles builds a new validationProfiles struct from the given
// configs and default name. It enforces that the given authorization lifetimes
// are within the bounds mandated by the Baseline Requirements.
func NewValidationProfiles(defaultName string, configs map[string]ValidationProfileConfig) (*validationProfiles, error) {
profiles := make(map[string]*validationProfile, len(configs))
for name, config := range configs {
// The Baseline Requirements v1.8.1 state that validation tokens "MUST
// NOT be used for more than 30 days from its creation". If unconfigured
// or the configured value pendingAuthorizationLifetimeDays is greater
// than 29 days, bail out.
if config.PendingAuthzLifetime.Duration <= 0 || config.PendingAuthzLifetime.Duration > 29*(24*time.Hour) {
return nil, fmt.Errorf("PendingAuthzLifetime value must be greater than 0 and less than 30d, but got %q", config.PendingAuthzLifetime.Duration)
}
// Baseline Requirements v1.8.1 section 4.2.1: "any reused data, document,
// or completed validation MUST be obtained no more than 398 days prior
// to issuing the Certificate". If unconfigured or the configured value is
// greater than 397 days, bail out.
if config.ValidAuthzLifetime.Duration <= 0 || config.ValidAuthzLifetime.Duration > 397*(24*time.Hour) {
return nil, fmt.Errorf("ValidAuthzLifetime value must be greater than 0 and less than 398d, but got %q", config.ValidAuthzLifetime.Duration)
}
var allowList *allowlist.List[int64]
if config.AllowList != "" {
data, err := os.ReadFile(config.AllowList)
if err != nil {
return nil, fmt.Errorf("reading allowlist: %w", err)
}
allowList, err = allowlist.NewFromYAML[int64](data)
if err != nil {
return nil, fmt.Errorf("parsing allowlist: %w", err)
}
}
profiles[name] = &validationProfile{
pendingAuthzLifetime: config.PendingAuthzLifetime.Duration,
validAuthzLifetime: config.ValidAuthzLifetime.Duration,
orderLifetime: config.OrderLifetime.Duration,
allowList: allowList,
}
}
_, ok := profiles[defaultName]
if !ok {
return nil, fmt.Errorf("no profile configured matching default profile name %q", defaultName)
}
return &validationProfiles{
defaultName: defaultName,
byName: profiles,
}, nil
}
func (vp *validationProfiles) get(name string) (*validationProfile, error) {
if name == "" {
name = vp.defaultName
}
profile, ok := vp.byName[name]
if !ok {
return nil, berrors.InvalidProfileError("unrecognized profile name %q", name)
}
return profile, nil
}
// certificateRequestAuthz is a struct for holding information about a valid
// authz referenced during a certificateRequestEvent. It holds both the
// authorization ID and the challenge type that made the authorization valid. We
@ -961,6 +1066,11 @@ func (ra *RegistrationAuthorityImpl) validateFinalizeRequest(
req.Order.Status)
}
profile, err := ra.profiles.get(req.Order.CertificateProfileName)
if err != nil {
return nil, err
}
// There should never be an order with 0 names at the stage, but we check to
// be on the safe side, throwing an internal server error if this assumption
// is ever violated.
@ -1040,7 +1150,7 @@ func (ra *RegistrationAuthorityImpl) validateFinalizeRequest(
ID: authz.ID,
ChallengeType: solvedByChallengeType,
}
authzAge := (ra.authorizationLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds()
authzAge := (profile.validAuthzLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds()
ra.authzAges.WithLabelValues("FinalizeOrder", string(authz.Status)).Observe(authzAge)
}
logEvent.Authorizations = logEventAuthzs
@ -1078,9 +1188,18 @@ func (ra *RegistrationAuthorityImpl) issueCertificateOuter(
logEvent.PreviousCertificateIssued = timestamps.Timestamps[0].AsTime()
}
// If the order didn't request a specific profile and we have a default
// configured, provide it to the CA so we can stop relying on the CA's
// configured default.
// TODO(#7309): Make this unconditional.
profileName := order.CertificateProfileName
if profileName == "" && ra.profiles.defaultName != UnconfiguredDefaultProfileName {
profileName = ra.profiles.defaultName
}
// Step 3: Issue the Certificate
cert, cpId, err := ra.issueCertificateInner(
ctx, csr, isRenewal, order.CertificateProfileName, accountID(order.RegistrationID), orderID(order.Id))
ctx, csr, isRenewal, profileName, accountID(order.RegistrationID), orderID(order.Id))
// Step 4: Fail the order if necessary, and update metrics and log fields
var result string
@ -1328,17 +1447,11 @@ func (ra *RegistrationAuthorityImpl) UpdateRegistrationKey(ctx context.Context,
// recordValidation records an authorization validation event,
// it should only be used on v2 style authorizations.
func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authID string, authExpires *time.Time, challenge *core.Challenge) error {
func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authID string, authExpires time.Time, challenge *core.Challenge) error {
authzID, err := strconv.ParseInt(authID, 10, 64)
if err != nil {
return err
}
var expires time.Time
if challenge.Status == core.StatusInvalid {
expires = *authExpires
} else {
expires = ra.clk.Now().Add(ra.authorizationLifetime)
}
vr, err := bgrpc.ValidationResultToPB(challenge.ValidationRecord, challenge.Error, "", "")
if err != nil {
return err
@ -1350,7 +1463,7 @@ func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authI
_, err = ra.SA.FinalizeAuthorization2(ctx, &sapb.FinalizeAuthorizationRequest{
Id: authzID,
Status: string(challenge.Status),
Expires: timestamppb.New(expires),
Expires: timestamppb.New(authExpires),
Attempted: string(challenge.Type),
AttemptedAt: validated,
ValidationRecords: vr.Records,
@ -1472,6 +1585,11 @@ func (ra *RegistrationAuthorityImpl) PerformValidation(
return nil, berrors.MalformedError("expired authorization")
}
profile, err := ra.profiles.get(authz.CertificateProfileName)
if err != nil {
return nil, err
}
challIndex := int(req.ChallengeIndex)
if challIndex >= len(authz.Challenges) {
return nil,
@ -1573,6 +1691,7 @@ func (ra *RegistrationAuthorityImpl) PerformValidation(
prob = probs.ServerInternal("Records for validation failed sanity check")
}
expires := *authz.Expires
if prob != nil {
challenge.Status = core.StatusInvalid
challenge.Error = prob
@ -1582,6 +1701,7 @@ func (ra *RegistrationAuthorityImpl) PerformValidation(
}
} else {
challenge.Status = core.StatusValid
expires = ra.clk.Now().Add(profile.validAuthzLifetime)
if features.Get().AutomaticallyPauseZombieClients {
ra.resetAccountPausingLimit(vaCtx, authz.RegistrationID, authz.Identifier)
}
@ -1589,7 +1709,7 @@ func (ra *RegistrationAuthorityImpl) PerformValidation(
challenge.Validated = &vStart
authz.Challenges[challIndex] = *challenge
err = ra.recordValidation(vaCtx, authz.ID, authz.Expires, challenge)
err = ra.recordValidation(vaCtx, authz.ID, expires, challenge)
if err != nil {
if errors.Is(err, berrors.NotFound) {
// We log NotFound at a lower level because this is largely due to a
@ -2186,23 +2306,20 @@ 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,
)
}
profile, err := ra.profiles.get(req.CertificateProfileName)
if err != nil {
return nil, err
}
if profile.allowList != nil && !profile.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)
err = ra.PA.WillingToIssue(newOrder.DnsNames)
if err != nil {
return nil, err
}
@ -2290,12 +2407,19 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
missingAuthzIdents = append(missingAuthzIdents, ident)
continue
}
// If the authz is associated with the wrong profile, don't reuse it.
if authz.CertificateProfileName != req.CertificateProfileName {
missingAuthzIdents = append(missingAuthzIdents, ident)
continue
}
authzAge := (ra.authorizationLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds()
// This is only used for our metrics.
authzAge := (profile.validAuthzLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds()
if authz.Status == core.StatusPending {
authzAge = (profile.pendingAuthzLifetime - authz.Expires.Sub(ra.clk.Now())).Seconds()
}
// If the identifier is a wildcard and the existing authz only has one
// DNS-01 type challenge we can reuse it. In theory we will
// never get back an authorization for a domain with a wildcard prefix
@ -2332,17 +2456,30 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
// authorization for each.
var newAuthzs []*sapb.NewAuthzRequest
for _, ident := range missingAuthzIdents {
pb, err := ra.createPendingAuthz(newOrder.RegistrationID, ident)
challTypes, err := ra.PA.ChallengeTypesFor(ident)
if err != nil {
return nil, err
}
newAuthzs = append(newAuthzs, pb)
var challStrs []string
for _, t := range challTypes {
challStrs = append(challStrs, string(t))
}
newAuthzs = append(newAuthzs, &sapb.NewAuthzRequest{
Identifier: ident.AsProto(),
RegistrationID: newOrder.RegistrationID,
Expires: timestamppb.New(ra.clk.Now().Add(profile.pendingAuthzLifetime).Truncate(time.Second)),
ChallengeTypes: challStrs,
Token: core.NewToken(),
})
ra.authzAges.WithLabelValues("NewOrder", string(core.StatusPending)).Observe(0)
}
// Start with the order's own expiry as the minExpiry. We only care
// about authz expiries that are sooner than the order's expiry
minExpiry := ra.clk.Now().Add(ra.orderLifetime)
minExpiry := ra.clk.Now().Add(profile.orderLifetime)
// Check the reused authorizations to see if any have an expiry before the
// minExpiry (the order's lifetime)
@ -2362,7 +2499,7 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
// If the newly created pending authz's have an expiry closer than the
// minExpiry the minExpiry is the pending authz expiry.
if len(newAuthzs) > 0 {
newPendingAuthzExpires := ra.clk.Now().Add(ra.pendingAuthorizationLifetime)
newPendingAuthzExpires := ra.clk.Now().Add(profile.pendingAuthzLifetime)
if newPendingAuthzExpires.Before(minExpiry) {
minExpiry = newPendingAuthzExpires
}
@ -2391,31 +2528,6 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
return storedOrder, nil
}
// createPendingAuthz checks that a name is allowed for issuance and creates the
// necessary challenges for it and puts this and all of the relevant information
// into a corepb.Authorization for transmission to the SA to be stored
func (ra *RegistrationAuthorityImpl) createPendingAuthz(reg int64, ident identifier.ACMEIdentifier) (*sapb.NewAuthzRequest, error) {
challTypes, err := ra.PA.ChallengeTypesFor(ident)
if err != nil {
return nil, err
}
challStrs := make([]string, len(challTypes))
for i, t := range challTypes {
challStrs[i] = string(t)
}
authz := &sapb.NewAuthzRequest{
Identifier: ident.AsProto(),
RegistrationID: reg,
Expires: timestamppb.New(ra.clk.Now().Add(ra.pendingAuthorizationLifetime).Truncate(time.Second)),
ChallengeTypes: challStrs,
Token: core.NewToken(),
}
return authz, nil
}
// wildcardOverlap takes a slice of domain names and returns an error if any of
// them is a non-wildcard FQDN that overlaps with a wildcard domain in the map.
func wildcardOverlap(dnsNames []string) error {

View File

@ -10,6 +10,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
@ -155,6 +156,13 @@ func numAuthorizations(o *corepb.Order) int {
return len(o.V2Authorizations)
}
// def is a test-only helper that returns the default validation profile
// and is guaranteed to succeed because the validationProfile constructor
// ensures that the default name has a corresponding profile.
func (vp *validationProfiles) def() *validationProfile {
return vp.byName[vp.defaultName]
}
type DummyValidationAuthority struct {
doDCVRequest chan *vapb.PerformValidationRequest
doDCVError error
@ -340,15 +348,19 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho
testKeyPolicy, err := goodkey.NewPolicy(nil, nil)
test.AssertNotError(t, err, "making keypolicy")
profiles := &validationProfiles{
defaultName: "test",
byName: map[string]*validationProfile{"test": {
pendingAuthzLifetime: 7 * 24 * time.Hour,
validAuthzLifetime: 300 * 24 * time.Hour,
orderLifetime: 7 * 24 * time.Hour,
}},
}
ra := NewRegistrationAuthorityImpl(
fc, log, stats,
1, testKeyPolicy, limiter, txnBuilder, 100,
300*24*time.Hour, 7*24*time.Hour,
nil,
nil,
nil,
7*24*time.Hour, 5*time.Minute,
ctp, nil, nil)
profiles, nil, nil, 5*time.Minute, ctp, nil, nil)
ra.SA = sa
ra.VA = va
ra.CA = ca
@ -633,7 +645,7 @@ func TestPerformValidationSuccess(t *testing.T) {
// The DB authz's expiry should be equal to the current time plus the
// configured authorization lifetime
test.AssertEquals(t, dbAuthzPB.Expires.AsTime(), now.Add(ra.authorizationLifetime))
test.AssertEquals(t, dbAuthzPB.Expires.AsTime(), now.Add(ra.profiles.def().validAuthzLifetime))
// Check that validated timestamp was recorded, stored, and retrieved
expectedValidated := fc.Now()
@ -1053,7 +1065,7 @@ func TestRecheckCAADates(t *testing.T) {
defer cleanUp()
recorder := &caaRecorder{names: make(map[string]bool)}
ra.VA = va.RemoteClients{CAAClient: recorder}
ra.authorizationLifetime = 15 * time.Hour
ra.profiles.def().validAuthzLifetime = 15 * time.Hour
recentValidated := fc.Now().Add(-1 * time.Hour)
recentExpires := fc.Now().Add(15 * time.Hour)
@ -1363,7 +1375,7 @@ func TestNewOrder(t *testing.T) {
})
test.AssertNotError(t, err, "ra.NewOrder failed")
test.AssertEquals(t, orderA.RegistrationID, int64(1))
test.AssertEquals(t, orderA.Expires.AsTime(), now.Add(ra.orderLifetime))
test.AssertEquals(t, orderA.Expires.AsTime(), now.Add(ra.profiles.def().orderLifetime))
test.AssertEquals(t, len(orderA.DnsNames), 3)
test.AssertEquals(t, orderA.CertificateProfileName, "test")
// We expect the order names to have been sorted, deduped, and lowercased
@ -1403,6 +1415,9 @@ func TestNewOrder_OrderReuse(t *testing.T) {
secondReg, err := ra.NewRegistration(context.Background(), input)
test.AssertNotError(t, err, "Error creating a second test registration")
// Insert a second (albeit identical) profile to reference
ra.profiles.byName["different"] = ra.profiles.def()
testCases := []struct {
Name string
RegistrationID int64
@ -1492,7 +1507,7 @@ func TestNewOrder_OrderReuse_Expired(t *testing.T) {
defer cleanUp()
// Set the order lifetime to something short and known.
ra.orderLifetime = time.Hour
ra.profiles.def().orderLifetime = time.Hour
// Create an initial order.
extant, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{
@ -1676,48 +1691,115 @@ func TestNewOrder_AuthzReuse_NoPending(t *testing.T) {
test.AssertNotEquals(t, new.V2Authorizations[0], extant.V2Authorizations[0])
}
func TestNewOrder_ValidationProfiles(t *testing.T) {
_, _, ra, _, _, cleanUp := initAuthorities(t)
defer cleanUp()
ra.profiles = &validationProfiles{
defaultName: "one",
byName: map[string]*validationProfile{
"one": {
pendingAuthzLifetime: 1 * 24 * time.Hour,
validAuthzLifetime: 1 * 24 * time.Hour,
orderLifetime: 1 * 24 * time.Hour,
},
"two": {
pendingAuthzLifetime: 2 * 24 * time.Hour,
validAuthzLifetime: 2 * 24 * time.Hour,
orderLifetime: 2 * 24 * time.Hour,
},
},
}
for _, tc := range []struct {
name string
profile string
wantExpires time.Time
}{
{
// A request with no profile should get an order and authzs with one-day lifetimes.
name: "no profile specified",
profile: "",
wantExpires: ra.clk.Now().Add(1 * 24 * time.Hour),
},
{
// A request for profile one should get an order and authzs with one-day lifetimes.
name: "profile one",
profile: "one",
wantExpires: ra.clk.Now().Add(1 * 24 * time.Hour),
},
{
// A request for profile two should get an order and authzs with one-day lifetimes.
name: "profile two",
profile: "two",
wantExpires: ra.clk.Now().Add(2 * 24 * time.Hour),
},
} {
t.Run(tc.name, func(t *testing.T) {
order, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{
RegistrationID: Registration.Id,
DnsNames: []string{randomDomain()},
CertificateProfileName: tc.profile,
})
if err != nil {
t.Fatalf("creating order: %s", err)
}
gotExpires := order.Expires.AsTime()
if gotExpires != tc.wantExpires {
t.Errorf("NewOrder(profile: %q).Expires = %s, expected %s", tc.profile, gotExpires, tc.wantExpires)
}
authz, err := ra.GetAuthorization(context.Background(), &rapb.GetAuthorizationRequest{
Id: order.V2Authorizations[0],
})
if err != nil {
t.Fatalf("fetching test authz: %s", err)
}
gotExpires = authz.Expires.AsTime()
if gotExpires != tc.wantExpires {
t.Errorf("GetAuthorization(profile: %q).Expires = %s, expected %s", tc.profile, gotExpires, tc.wantExpires)
}
})
}
}
func TestNewOrder_ProfileSelectionAllowList(t *testing.T) {
_, _, ra, _, _, cleanUp := initAuthorities(t)
defer cleanUp()
testCases := []struct {
name string
validationProfiles map[string]*ValidationProfile
validationProfiles map[string]*validationProfile
expectErr bool
expectErrContains string
}{
{
name: "Allow all account IDs regardless of profile",
validationProfiles: nil,
expectErr: false,
},
{
name: "Allow all account IDs for this specific profile",
validationProfiles: map[string]*ValidationProfile{
"test": NewValidationProfile(nil),
name: "Allow all account IDs",
validationProfiles: map[string]*validationProfile{
"test": {allowList: nil},
},
expectErr: false,
},
{
name: "Deny all but account Id 1337",
validationProfiles: map[string]*ValidationProfile{
"test": NewValidationProfile(allowlist.NewList([]int64{1337})),
validationProfiles: map[string]*validationProfile{
"test": {allowList: allowlist.NewList([]int64{1337})},
},
expectErr: true,
expectErrContains: "not permitted to use certificate profile",
},
{
name: "Deny all",
validationProfiles: map[string]*ValidationProfile{
"test": NewValidationProfile(allowlist.NewList([]int64{})),
validationProfiles: map[string]*validationProfile{
"test": {allowList: allowlist.NewList([]int64{})},
},
expectErr: true,
expectErrContains: "not permitted to use certificate profile",
},
{
name: "Allow Registration.Id",
validationProfiles: map[string]*ValidationProfile{
"test": NewValidationProfile(allowlist.NewList([]int64{Registration.Id})),
validationProfiles: map[string]*validationProfile{
"test": {allowList: allowlist.NewList([]int64{Registration.Id})},
},
expectErr: false,
},
@ -1725,7 +1807,7 @@ func TestNewOrder_ProfileSelectionAllowList(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ra.validationProfiles = tc.validationProfiles
ra.profiles.byName = tc.validationProfiles
orderReq := &rapb.NewOrderRequest{
RegistrationID: Registration.Id,
@ -2069,7 +2151,7 @@ func TestNewOrderExpiry(t *testing.T) {
names := []string{"zombo.com"}
// Set the order lifetime to 48 hours.
ra.orderLifetime = 48 * time.Hour
ra.profiles.def().orderLifetime = 48 * time.Hour
// Use an expiry that is sooner than the configured order expiry but greater
// than 24 hours away.
@ -2115,8 +2197,8 @@ func TestNewOrderExpiry(t *testing.T) {
test.AssertEquals(t, order.Expires.AsTime(), fakeAuthzExpires)
// Set the order lifetime to be lower than the fakeAuthzLifetime
ra.orderLifetime = 12 * time.Hour
expectedOrderExpiry := clk.Now().Add(ra.orderLifetime)
ra.profiles.def().orderLifetime = 12 * time.Hour
expectedOrderExpiry := clk.Now().Add(12 * time.Hour)
// Create the order again
order, err = ra.NewOrder(ctx, orderReq)
// It shouldn't fail
@ -2751,7 +2833,7 @@ func TestIssueCertificateAuditLog(t *testing.T) {
// Make some valid authorizations for some names using different challenge types
names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"}
exp := ra.clk.Now().Add(ra.orderLifetime)
exp := ra.clk.Now().Add(ra.profiles.def().orderLifetime)
challs := []core.AcmeChallenge{core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01}
var authzIDs []int64
for i, name := range names {
@ -2984,11 +3066,11 @@ func TestUpdateMissingAuthorization(t *testing.T) {
// Twiddle the authz to pretend its been validated by the VA
authz.Challenges[0].Status = "valid"
err = ra.recordValidation(ctx, authz.ID, authz.Expires, &authz.Challenges[0])
err = ra.recordValidation(ctx, authz.ID, fc.Now().Add(24*time.Hour), &authz.Challenges[0])
test.AssertNotError(t, err, "ra.recordValidation failed")
// Try to record the same validation a second time.
err = ra.recordValidation(ctx, authz.ID, authz.Expires, &authz.Challenges[0])
err = ra.recordValidation(ctx, authz.ID, fc.Now().Add(25*time.Hour), &authz.Challenges[0])
test.AssertError(t, err, "ra.recordValidation didn't fail")
test.AssertErrorIs(t, err, berrors.NotFound)
}
@ -3177,7 +3259,7 @@ func TestIssueCertificateInnerErrs(t *testing.T) {
// Make some valid authorizations for some names
names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"}
exp := ra.clk.Now().Add(ra.orderLifetime)
exp := ra.clk.Now().Add(ra.profiles.def().orderLifetime)
var authzIDs []int64
for _, name := range names {
authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, ra.clk.Now()))
@ -3306,11 +3388,12 @@ func (sa *mockSAWithFinalize) FQDNSetTimestampsForWindow(ctx context.Context, in
}, nil
}
func TestIssueCertificateInnerWithProfile(t *testing.T) {
func TestIssueCertificateOuter(t *testing.T) {
_, _, ra, _, fc, cleanup := initAuthorities(t)
defer cleanup()
ra.SA = &mockSAWithFinalize{}
// Generate a reasonable-looking CSR and cert to pass the matchesCSR check.
// Create a CSR to submit and a certificate for the fake CA to return.
testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
test.AssertNotError(t, err, "generating test key")
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{DNSNames: []string{"example.com"}}, testKey)
@ -3327,71 +3410,68 @@ func TestIssueCertificateInnerWithProfile(t *testing.T) {
test.AssertNotError(t, err, "creating test cert")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
// Use a mock CA that will record the profile name and profile hash included
// in the RA's request messages. Populate it with the cert generated above.
mockCA := MockCARecordingProfile{inner: &mocks.MockCA{PEM: certPEM}}
ra.CA = &mockCA
ra.SA = &mockSAWithFinalize{}
// Call issueCertificateInner with the CSR generated above and the profile
// name "default", which will cause the mockCA to return a specific hash.
_, cpId, err := ra.issueCertificateInner(context.Background(), csr, false, "default", 1, 1)
test.AssertNotError(t, err, "issuing cert with profile name")
test.AssertEquals(t, mockCA.profileName, cpId.name)
test.AssertByteEquals(t, mockCA.profileHash, cpId.hash)
}
func TestIssueCertificateOuter(t *testing.T) {
_, sa, ra, _, fc, cleanup := initAuthorities(t)
defer cleanup()
// Make some valid authorizations for some names
names := []string{"not-example.com", "www.not-example.com", "still.not-example.com", "definitely.not-example.com"}
exp := ra.clk.Now().Add(ra.orderLifetime)
var authzIDs []int64
for _, name := range names {
authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, ra.clk.Now()))
}
// Create a pending order for all of the names
order, err := sa.NewOrderAndAuthzs(context.Background(), &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{
RegistrationID: Registration.Id,
Expires: timestamppb.New(exp),
DnsNames: names,
V2Authorizations: authzIDs,
CertificateProfileName: "philsProfile",
for _, tc := range []struct {
name string
profile string
wantProfile string
wantHash string
}{
{
name: "select default profile when none specified",
wantProfile: "test", // matches ra.defaultProfileName
wantHash: "9f86d081884c7d65",
},
})
test.AssertNotError(t, err, "Could not add test order with finalized authz IDs")
{
name: "default profile specified",
profile: "test",
wantProfile: "test",
wantHash: "9f86d081884c7d65",
},
{
name: "other profile specified",
profile: "other",
wantProfile: "other",
wantHash: "d9298a10d1b07358",
},
} {
t.Run(tc.name, func(t *testing.T) {
// Use a mock CA that will record the profile name and profile hash included
// in the RA's request messages. Populate it with the cert generated above.
mockCA := MockCARecordingProfile{inner: &mocks.MockCA{PEM: certPEM}}
ra.CA = &mockCA
testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
test.AssertNotError(t, err, "generating test key")
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{DNSNames: []string{"example.com"}}, testKey)
test.AssertNotError(t, err, "creating test csr")
csr, err := x509.ParseCertificateRequest(csrDER)
test.AssertNotError(t, err, "parsing test csr")
certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"example.com"},
NotBefore: fc.Now(),
BasicConstraintsValid: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}, &x509.Certificate{}, testKey.Public(), testKey)
test.AssertNotError(t, err, "creating test cert")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
order := &corepb.Order{
RegistrationID: Registration.Id,
Expires: timestamppb.New(fc.Now().Add(24 * time.Hour)),
DnsNames: []string{"example.com"},
CertificateProfileName: tc.profile,
}
// Use a mock CA that will record the profile name and profile hash included
// in the RA's request messages. Populate it with the cert generated above.
mockCA := MockCARecordingProfile{inner: &mocks.MockCA{PEM: certPEM}}
ra.CA = &mockCA
order, err = ra.issueCertificateOuter(context.Background(), order, csr, certificateRequestEvent{})
ra.SA = &mockSAWithFinalize{}
// The resulting order should have new fields populated
if order.Status != string(core.StatusValid) {
t.Errorf("order.Status = %+v, want %+v", order.Status, core.StatusValid)
}
if order.CertificateSerial != core.SerialToString(big.NewInt(1)) {
t.Errorf("CertificateSerial = %+v, want %+v", order.CertificateSerial, 1)
}
_, err = ra.issueCertificateOuter(context.Background(), order, csr, certificateRequestEvent{})
test.AssertNotError(t, err, "Could not issue certificate")
test.AssertMetricWithLabelsEquals(t, ra.newCertCounter, prometheus.Labels{"profileName": mockCA.profileName, "profileHash": fmt.Sprintf("%x", mockCA.profileHash)}, 1)
// The recorded profile and profile hash should match what we expect.
if mockCA.profileName != tc.wantProfile {
t.Errorf("recorded profileName = %+v, want %+v", mockCA.profileName, tc.wantProfile)
}
wantHash, err := hex.DecodeString(tc.wantHash)
if err != nil {
t.Fatalf("decoding test hash: %s", err)
}
if !bytes.Equal(mockCA.profileHash, wantHash) {
t.Errorf("recorded profileName = %x, want %x", mockCA.profileHash, wantHash)
}
test.AssertMetricWithLabelsEquals(t, ra.newCertCounter, prometheus.Labels{"profileName": tc.wantProfile, "profileHash": tc.wantHash}, 1)
ra.newCertCounter.Reset()
})
}
}
func TestNewOrderMaxNames(t *testing.T) {

View File

@ -27,10 +27,7 @@
"maxContactsPerRegistration": 3,
"hostnamePolicyFile": "test/hostname-policy.yaml",
"maxNames": 100,
"authorizationLifetimeDays": 30,
"pendingAuthorizationLifetimeDays": 7,
"goodkey": {},
"orderLifetime": "168h",
"finalizeTimeout": "30s",
"issuerCerts": [
"test/certs/webpki/int-rsa-a.cert.pem",
@ -40,6 +37,19 @@
"test/certs/webpki/int-ecdsa-b.cert.pem",
"test/certs/webpki/int-ecdsa-c.cert.pem"
],
"validationProfiles": {
"legacy": {
"pendingAuthzLifetime": "168h",
"validAuthzLifetime": "720h",
"orderLifetime": "168h"
},
"modern": {
"pendingAuthzLifetime": "7h",
"validAuthzLifetime": "7h",
"orderLifetime": "7h"
}
},
"defaultProfileName": "legacy",
"tls": {
"caCertFile": "test/certs/ipki/minica.pem",
"certFile": "test/certs/ipki/ra.boulder/cert.pem",