WFE: Add support for certificate profiles (#7373)

- Parse and validate the `profile` field in `newOrder` requests.
- Pass the `profile` field from `newOrder` calls to the resulting
`RA.NewOrder` call.
- When the client requests a specific profile, ensure that the profile
field is populated in the order returned.

Fixes #7332
Part of #7309
This commit is contained in:
Samantha 2024-03-20 12:49:45 -04:00 committed by GitHub
parent 206c35f099
commit c6b50558e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 131 additions and 5 deletions

View File

@ -168,6 +168,12 @@ type Config struct {
// CP/CPS, under "DV-SSL Subscriber Certificate". The value must match // CP/CPS, under "DV-SSL Subscriber Certificate". The value must match
// the CA and RA configurations. // the CA and RA configurations.
MaxNames int `validate:"min=0,max=100"` MaxNames int `validate:"min=0,max=100"`
// CertificateProfileNames is the list of acceptable certificate profile
// names for newOrder requests. Requests with a profile name not in this
// list will be rejected. This field is optional; if unset, no profile
// names are accepted.
CertificateProfileNames []string `validate:"omitempty,dive,alphanum,min=1,max=32"`
} }
Syslog cmd.SyslogConfig Syslog cmd.SyslogConfig
@ -409,6 +415,7 @@ func main() {
limiter, limiter,
txnBuilder, txnBuilder,
maxNames, maxNames,
c.WFE.CertificateProfileNames,
) )
cmd.FailOnError(err, "Unable to create WFE") cmd.FailOnError(err, "Unable to create WFE")

View File

@ -129,7 +129,10 @@
"features": { "features": {
"ServeRenewalInfo": true, "ServeRenewalInfo": true,
"TrackReplacementCertificatesARI": true "TrackReplacementCertificatesARI": true
} },
"certificateProfileNames": [
"defaultBoulderCertificateProfile"
]
}, },
"syslog": { "syslog": {
"stdoutlevel": 4, "stdoutlevel": 4,

View File

@ -14,6 +14,7 @@ import (
"math/big" "math/big"
"net" "net"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -171,6 +172,11 @@ type WebFrontEndImpl struct {
limiter *ratelimits.Limiter limiter *ratelimits.Limiter
txnBuilder *ratelimits.TransactionBuilder txnBuilder *ratelimits.TransactionBuilder
maxNames int maxNames int
// certificateProfileNames is a list of profile names that are allowed to be
// passed to the newOrder endpoint. If a profile name is not in this list,
// the request will be rejected as malformed.
certificateProfileNames []string
} }
// NewWebFrontEndImpl constructs a web service for Boulder // NewWebFrontEndImpl constructs a web service for Boulder
@ -195,6 +201,7 @@ func NewWebFrontEndImpl(
limiter *ratelimits.Limiter, limiter *ratelimits.Limiter,
txnBuilder *ratelimits.TransactionBuilder, txnBuilder *ratelimits.TransactionBuilder,
maxNames int, maxNames int,
certificateProfileNames []string,
) (WebFrontEndImpl, error) { ) (WebFrontEndImpl, error) {
if len(issuerCertificates) == 0 { if len(issuerCertificates) == 0 {
return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate") return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate")
@ -234,6 +241,7 @@ func NewWebFrontEndImpl(
limiter: limiter, limiter: limiter,
txnBuilder: txnBuilder, txnBuilder: txnBuilder,
maxNames: maxNames, maxNames: maxNames,
certificateProfileNames: certificateProfileNames,
} }
return wfe, nil return wfe, nil
@ -2002,6 +2010,7 @@ type orderJSON struct {
Identifiers []identifier.ACMEIdentifier `json:"identifiers"` Identifiers []identifier.ACMEIdentifier `json:"identifiers"`
Authorizations []string `json:"authorizations"` Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"` Finalize string `json:"finalize"`
Profile string `json:"profile,omitempty"`
Certificate string `json:"certificate,omitempty"` Certificate string `json:"certificate,omitempty"`
Error *probs.ProblemDetails `json:"error,omitempty"` Error *probs.ProblemDetails `json:"error,omitempty"`
} }
@ -2253,6 +2262,19 @@ func (wfe *WebFrontEndImpl) validateReplacementOrder(ctx context.Context, acct *
return replaces, renewalInfo.SuggestedWindow.IsWithin(wfe.clk.Now()), nil return replaces, renewalInfo.SuggestedWindow.IsWithin(wfe.clk.Now()), nil
} }
func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error {
if profile == "" {
// No profile name is specified.
return nil
}
if !slices.Contains(wfe.certificateProfileNames, profile) {
// The profile name is not in the list of configured profiles.
return errors.New("not a recognized profile name")
}
return nil
}
// NewOrder is used by clients to create a new order object and a set of // NewOrder is used by clients to create a new order object and a set of
// authorizations to fulfill for issuance. // authorizations to fulfill for issuance.
func (wfe *WebFrontEndImpl) NewOrder( func (wfe *WebFrontEndImpl) NewOrder(
@ -2276,6 +2298,7 @@ func (wfe *WebFrontEndImpl) NewOrder(
NotBefore string NotBefore string
NotAfter string NotAfter string
Replaces string Replaces string
Profile string
} }
err := json.Unmarshal(body, &newOrderRequest) err := json.Unmarshal(body, &newOrderRequest)
if err != nil { if err != nil {
@ -2326,6 +2349,13 @@ func (wfe *WebFrontEndImpl) NewOrder(
} }
} }
err = wfe.validateCertificateProfileName(newOrderRequest.Profile)
if err != nil {
// TODO(#7392) Provide link to profile documentation.
wfe.sendError(response, logEvent, probs.Malformed("Invalid certificate profile, %q: %s", newOrderRequest.Profile, err), err)
return
}
// TODO(#5545): Spending and Refunding can be async until these rate limits // TODO(#5545): Spending and Refunding can be async until these rate limits
// are authoritative. This saves us from adding latency to each request. // are authoritative. This saves us from adding latency to each request.
// Goroutines spun out below will respect a context deadline set by the // Goroutines spun out below will respect a context deadline set by the
@ -2352,6 +2382,7 @@ func (wfe *WebFrontEndImpl) NewOrder(
Names: names, Names: names,
ReplacesSerial: replaces, ReplacesSerial: replaces,
LimitsExempt: limitsExempt, LimitsExempt: limitsExempt,
CertificateProfileName: newOrderRequest.Profile,
}) })
// TODO(#7153): Check each value via core.IsAnyNilOrZero // TODO(#7153): Check each value via core.IsAnyNilOrZero
if err != nil || order == nil || order.Id == 0 || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) { if err != nil || order == nil || order.Id == 0 || order.RegistrationID == 0 || len(order.Names) == 0 || core.IsAnyNilOrZero(order.Created, order.Expires) {

View File

@ -427,6 +427,7 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) {
limiter, limiter,
txnBuilder, txnBuilder,
100, 100,
[]string{""},
) )
test.AssertNotError(t, err, "Unable to create WFE") test.AssertNotError(t, err, "Unable to create WFE")
@ -4053,3 +4054,87 @@ func TestOrderMatchesReplacement(t *testing.T) {
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, "1") err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, "1")
test.AssertErrorIs(t, err, berrors.NotFound) test.AssertErrorIs(t, err, berrors.NotFound)
} }
type mockRA struct {
rapb.RegistrationAuthorityClient
expectProfileName string
}
// NewOrder returns an error if the ""
func (sa *mockRA) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, opts ...grpc.CallOption) (*corepb.Order, error) {
if in.CertificateProfileName != sa.expectProfileName {
return nil, errors.New("not expected profile name")
}
now := time.Now().UTC()
created := now.AddDate(-30, 0, 0)
exp := now.AddDate(30, 0, 0)
return &corepb.Order{
Id: 123456789,
RegistrationID: 987654321,
Created: timestamppb.New(created),
Expires: timestamppb.New(exp),
Names: []string{"example.com"},
Status: string(core.StatusValid),
V2Authorizations: []int64{1},
CertificateSerial: "serial",
Error: nil,
CertificateProfileName: in.CertificateProfileName,
}, nil
}
func TestNewOrderWithProfile(t *testing.T) {
wfe, _, signer := setupWFE(t)
expectProfileName := "test-profile"
wfe.ra = &mockRA{expectProfileName: expectProfileName}
mux := wfe.Handler(metrics.NoopRegisterer)
wfe.certificateProfileNames = []string{expectProfileName}
// Test that the newOrder endpoint returns the proper error if an invalid
// profile is specified.
invalidOrderBody := `
{
"Identifiers": [
{"type": "dns", "value": "example.com"}
],
"Profile": "bad-profile"
}`
responseWriter := httptest.NewRecorder()
r := signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, invalidOrderBody)
mux.ServeHTTP(responseWriter, r)
test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest)
var errorResp map[string]interface{}
err := json.Unmarshal(responseWriter.Body.Bytes(), &errorResp)
test.AssertNotError(t, err, "Failed to unmarshal error response")
test.AssertEquals(t, errorResp["type"], "urn:ietf:params:acme:error:malformed")
test.AssertEquals(t, errorResp["detail"], "Invalid certificate profile, \"bad-profile\": not a recognized profile name")
// Test that the newOrder endpoint returns no error if the valid profile is specified.
validOrderBody := `
{
"Identifiers": [
{"type": "dns", "value": "example.com"}
],
"Profile": "test-profile"
}`
responseWriter = httptest.NewRecorder()
r = signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, validOrderBody)
mux.ServeHTTP(responseWriter, r)
test.AssertEquals(t, responseWriter.Code, http.StatusCreated)
var errorResp1 map[string]interface{}
err = json.Unmarshal(responseWriter.Body.Bytes(), &errorResp1)
test.AssertNotError(t, err, "Failed to unmarshal order response")
test.AssertEquals(t, errorResp1["status"], "valid")
// Set the acceptable profiles to an empty list, the WFE should no longer accept any profiles.
wfe.certificateProfileNames = []string{}
responseWriter = httptest.NewRecorder()
r = signAndPost(signer, newOrderPath, "http://localhost"+newOrderPath, validOrderBody)
mux.ServeHTTP(responseWriter, r)
test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest)
var errorResp2 map[string]interface{}
err = json.Unmarshal(responseWriter.Body.Bytes(), &errorResp2)
test.AssertNotError(t, err, "Failed to unmarshal error response")
test.AssertEquals(t, errorResp2["type"], "urn:ietf:params:acme:error:malformed")
test.AssertEquals(t, errorResp2["detail"], "Invalid certificate profile, \"test-profile\": not a recognized profile name")
}