diff --git a/cmd/boulder-ra/main.go b/cmd/boulder-ra/main.go index 1149f88df2b..02ad3e127cd 100644 --- a/cmd/boulder-ra/main.go +++ b/cmd/boulder-ra/main.go @@ -86,24 +86,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.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.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 +126,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.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 +267,40 @@ 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 - - // 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) + logger.Infof("Configured default profile name set to: %s", c.RA.DefaultProfileName) + + // 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 := make(map[string]*ra.ValidationProfile) + for name, profileConfig := range c.RA.ValidationProfiles { + profile, err := ra.NewValidationProfile(&profileConfig) + cmd.FailOnError(err, fmt.Sprintf("Failed to load validation profile %q", name)) + + validationProfiles[name] = profile + } + + _, present := validationProfiles[c.RA.DefaultProfileName] + if !present { + cmd.Fail(fmt.Sprintf("No profile configured matching default profile name %q", c.RA.DefaultProfileName)) + } + var mustStapleAllowList *allowlist.List[int64] if c.RA.MustStapleAllowList != "" { data, err := os.ReadFile(c.RA.MustStapleAllowList) @@ -331,12 +344,10 @@ func main() { limiter, txnBuilder, c.RA.MaxNames, - authorizationLifetime, - pendingAuthorizationLifetime, validationProfiles, + c.RA.DefaultProfileName, mustStapleAllowList, pubc, - c.RA.OrderLifetime.Duration, c.RA.FinalizeTimeout.Duration, ctp, apc, diff --git a/ra/ra.go b/ra/ra.go index 4b5e8dcebd6..34286cb2f8c 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -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,17 +68,97 @@ var ( caaRecheckDuration = -7 * time.Hour ) +// 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 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] } -// 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} +// NewValidationProfile creates a new ValidationProfile from the given config. +// It enforces that the given authorization lifetimes are within the bounds +// mandated by the Baseline Requirements. +func NewValidationProfile(c *ValidationProfileConfig) (*ValidationProfile, error) { + // 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.PendingAuthzLifetime.Duration <= 0 || c.PendingAuthzLifetime.Duration > 29*(24*time.Hour) { + return nil, fmt.Errorf("PendingAuthzLifetime value must be greater than 0 and less than 30d, but got %q", c.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 c.ValidAuthzLifetime.Duration <= 0 || c.ValidAuthzLifetime.Duration > 397*(24*time.Hour) { + return nil, fmt.Errorf("ValidAuthzLifetime value must be greater than 0 and less than 398d, but got %q", c.ValidAuthzLifetime.Duration) + } + + var allowList *allowlist.List[int64] + if c.AllowList != "" { + data, err := os.ReadFile(c.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) + } + } + + return &ValidationProfile{ + pendingAuthzLifetime: c.PendingAuthzLifetime.Duration, + validAuthzLifetime: c.ValidAuthzLifetime.Duration, + orderLifetime: c.OrderLifetime.Duration, + allowList: allowList, + }, nil } // RegistrationAuthorityImpl defines an RA. @@ -92,21 +174,18 @@ 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 + validationProfiles map[string]*ValidationProfile + defaultProfileName string + 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 @@ -139,12 +218,10 @@ func NewRegistrationAuthorityImpl( limiter *ratelimits.Limiter, txnBuilder *ratelimits.TransactionBuilder, maxNames int, - authorizationLifetime time.Duration, - pendingAuthorizationLifetime time.Duration, validationProfiles map[string]*ValidationProfile, + defaultProfileName string, mustStapleAllowList *allowlist.List[int64], pubc pubpb.PublisherClient, - orderLifetime time.Duration, finalizeTimeout time.Duration, ctp *ctpolicy.CTPolicy, purger akamaipb.AkamaiPurgerClient, @@ -251,35 +328,33 @@ 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, + clk: clk, + log: logger, + validationProfiles: validationProfiles, + defaultProfileName: defaultProfileName, + 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, } return ra } @@ -943,6 +1018,15 @@ func (ra *RegistrationAuthorityImpl) validateFinalizeRequest( req.Order.Status) } + profileName := req.Order.CertificateProfileName + if profileName == "" { + profileName = ra.defaultProfileName + } + profile, ok := ra.validationProfiles[profileName] + if !ok { + return nil, berrors.InvalidProfileError("unrecognized profile name %q", profileName) + } + // 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. @@ -1022,7 +1106,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 @@ -1060,9 +1144,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.defaultProfileName != UnconfiguredDefaultProfileName { + profileName = ra.defaultProfileName + } + // 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 @@ -1304,17 +1397,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 @@ -1326,7 +1413,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, @@ -1448,6 +1535,15 @@ func (ra *RegistrationAuthorityImpl) PerformValidation( return nil, berrors.MalformedError("expired authorization") } + profileName := authz.CertificateProfileName + if profileName == "" { + profileName = ra.defaultProfileName + } + profile, ok := ra.validationProfiles[profileName] + if !ok { + return nil, berrors.InvalidProfileError("unrecognized profile name %q", profileName) + } + challIndex := int(req.ChallengeIndex) if challIndex >= len(authz.Challenges) { return nil, @@ -1549,6 +1645,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 @@ -1558,6 +1655,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) } @@ -1565,7 +1663,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 @@ -2162,19 +2260,22 @@ 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, - ) - } + profileName := req.CertificateProfileName + if profileName == "" { + profileName = ra.defaultProfileName + } + profile, ok := ra.validationProfiles[profileName] + if !ok { + return nil, berrors.MalformedError("requested certificate profile %q not found", + req.CertificateProfileName, + ) + } + + 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 @@ -2264,7 +2365,11 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New 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 @@ -2301,17 +2406,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) + + challStrs := make([]string, len(challTypes)) + for i, t := range challTypes { + challStrs[i] = 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) @@ -2331,7 +2449,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 } @@ -2360,31 +2478,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 { diff --git a/ra/ra_test.go b/ra/ra_test.go index 9fb9f28a6e2..d9d0c2852bf 100644 --- a/ra/ra_test.go +++ b/ra/ra_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -343,12 +344,13 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho 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) + map[string]*ValidationProfile{"test": { + pendingAuthzLifetime: 7 * 24 * time.Hour, + validAuthzLifetime: 300 * 24 * time.Hour, + orderLifetime: 7 * 24 * time.Hour, + }}, + "test", + nil, nil, 5*time.Minute, ctp, nil, nil) ra.SA = sa ra.VA = va ra.CA = ca @@ -633,7 +635,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.validationProfiles[ra.defaultProfileName].validAuthzLifetime)) // Check that validated timestamp was recorded, stored, and retrieved expectedValidated := fc.Now() @@ -1053,7 +1055,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.validationProfiles[ra.defaultProfileName].validAuthzLifetime = 15 * time.Hour recentValidated := fc.Now().Add(-1 * time.Hour) recentExpires := fc.Now().Add(15 * time.Hour) @@ -1363,7 +1365,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.validationProfiles[ra.defaultProfileName].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 +1405,9 @@ func TestNewOrder_OrderReusex(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.validationProfiles["different"] = ra.validationProfiles[ra.defaultProfileName] + testCases := []struct { Name string RegistrationID int64 @@ -1492,7 +1497,7 @@ func TestNewOrder_OrderReuse_Expired(t *testing.T) { defer cleanUp() // Set the order lifetime to something short and known. - ra.orderLifetime = time.Hour + ra.validationProfiles[ra.defaultProfileName].orderLifetime = time.Hour // Create an initial order. extant, err := ra.NewOrder(context.Background(), &rapb.NewOrderRequest{ @@ -1667,6 +1672,76 @@ 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.validationProfiles = 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, + }, + } + ra.defaultProfileName = "one" + + 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() @@ -1678,21 +1753,16 @@ func TestNewOrder_ProfileSelectionAllowList(t *testing.T) { expectErrContains string }{ { - name: "Allow all account IDs regardless of profile", - validationProfiles: nil, - expectErr: false, - }, - { - name: "Allow all account IDs for this specific profile", + name: "Allow all account IDs", validationProfiles: map[string]*ValidationProfile{ - "test": NewValidationProfile(nil), + "test": {allowList: nil}, }, expectErr: false, }, { name: "Deny all but account Id 1337", validationProfiles: map[string]*ValidationProfile{ - "test": NewValidationProfile(allowlist.NewList([]int64{1337})), + "test": {allowList: allowlist.NewList([]int64{1337})}, }, expectErr: true, expectErrContains: "not permitted to use certificate profile", @@ -1700,7 +1770,7 @@ func TestNewOrder_ProfileSelectionAllowList(t *testing.T) { { name: "Deny all", validationProfiles: map[string]*ValidationProfile{ - "test": NewValidationProfile(allowlist.NewList([]int64{})), + "test": {allowList: allowlist.NewList([]int64{})}, }, expectErr: true, expectErrContains: "not permitted to use certificate profile", @@ -1708,7 +1778,7 @@ func TestNewOrder_ProfileSelectionAllowList(t *testing.T) { { name: "Allow Registration.Id", validationProfiles: map[string]*ValidationProfile{ - "test": NewValidationProfile(allowlist.NewList([]int64{Registration.Id})), + "test": {allowList: allowlist.NewList([]int64{Registration.Id})}, }, expectErr: false, }, @@ -2060,7 +2130,7 @@ func TestNewOrderExpiry(t *testing.T) { names := []string{"zombo.com"} // Set the order lifetime to 48 hours. - ra.orderLifetime = 48 * time.Hour + ra.validationProfiles[ra.defaultProfileName].orderLifetime = 48 * time.Hour // Use an expiry that is sooner than the configured order expiry but greater // than 24 hours away. @@ -2106,8 +2176,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.validationProfiles[ra.defaultProfileName].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 @@ -2742,7 +2812,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.validationProfiles[ra.defaultProfileName].orderLifetime) challs := []core.AcmeChallenge{core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01} var authzIDs []int64 for i, name := range names { @@ -2975,11 +3045,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) } @@ -3168,7 +3238,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.validationProfiles[ra.defaultProfileName].orderLifetime) var authzIDs []int64 for _, name := range names { authzIDs = append(authzIDs, createFinalizedAuthorization(t, sa, name, exp, core.ChallengeTypeHTTP01, ra.clk.Now())) @@ -3297,11 +3367,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) @@ -3318,71 +3389,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) { diff --git a/test/config-next/ra.json b/test/config-next/ra.json index e1d9e914215..3fe1a9521de 100644 --- a/test/config-next/ra.json +++ b/test/config-next/ra.json @@ -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",