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
// the CA and RA configurations.
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
@ -409,6 +415,7 @@ func main() {
limiter,
txnBuilder,
maxNames,
c.WFE.CertificateProfileNames,
)
cmd.FailOnError(err, "Unable to create WFE")

View File

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

View File

@ -14,6 +14,7 @@ import (
"math/big"
"net"
"net/http"
"slices"
"strconv"
"strings"
"time"
@ -171,6 +172,11 @@ type WebFrontEndImpl struct {
limiter *ratelimits.Limiter
txnBuilder *ratelimits.TransactionBuilder
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
@ -195,6 +201,7 @@ func NewWebFrontEndImpl(
limiter *ratelimits.Limiter,
txnBuilder *ratelimits.TransactionBuilder,
maxNames int,
certificateProfileNames []string,
) (WebFrontEndImpl, error) {
if len(issuerCertificates) == 0 {
return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate")
@ -234,6 +241,7 @@ func NewWebFrontEndImpl(
limiter: limiter,
txnBuilder: txnBuilder,
maxNames: maxNames,
certificateProfileNames: certificateProfileNames,
}
return wfe, nil
@ -2002,6 +2010,7 @@ type orderJSON struct {
Identifiers []identifier.ACMEIdentifier `json:"identifiers"`
Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"`
Profile string `json:"profile,omitempty"`
Certificate string `json:"certificate,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
}
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
// authorizations to fulfill for issuance.
func (wfe *WebFrontEndImpl) NewOrder(
@ -2276,6 +2298,7 @@ func (wfe *WebFrontEndImpl) NewOrder(
NotBefore string
NotAfter string
Replaces string
Profile string
}
err := json.Unmarshal(body, &newOrderRequest)
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
// are authoritative. This saves us from adding latency to each request.
// Goroutines spun out below will respect a context deadline set by the
@ -2348,10 +2378,11 @@ func (wfe *WebFrontEndImpl) NewOrder(
}()
order, err := wfe.ra.NewOrder(ctx, &rapb.NewOrderRequest{
RegistrationID: acct.ID,
Names: names,
ReplacesSerial: replaces,
LimitsExempt: limitsExempt,
RegistrationID: acct.ID,
Names: names,
ReplacesSerial: replaces,
LimitsExempt: limitsExempt,
CertificateProfileName: newOrderRequest.Profile,
})
// 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) {

View File

@ -427,6 +427,7 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) {
limiter,
txnBuilder,
100,
[]string{""},
)
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")
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")
}