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:
parent
206c35f099
commit
c6b50558e6
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,10 @@
|
||||||
"features": {
|
"features": {
|
||||||
"ServeRenewalInfo": true,
|
"ServeRenewalInfo": true,
|
||||||
"TrackReplacementCertificatesARI": true
|
"TrackReplacementCertificatesARI": true
|
||||||
}
|
},
|
||||||
|
"certificateProfileNames": [
|
||||||
|
"defaultBoulderCertificateProfile"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"syslog": {
|
"syslog": {
|
||||||
"stdoutlevel": 4,
|
"stdoutlevel": 4,
|
||||||
|
|
|
||||||
31
wfe2/wfe.go
31
wfe2/wfe.go
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue