Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WFE/ARI: Add method for tracking certificate replacement #7298

Merged
merged 10 commits into from
Feb 26, 2024
6 changes: 6 additions & 0 deletions core/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@ type SuggestedWindow struct {
End time.Time `json:"end"`
}

// IsWithin returns true if the given time is within the suggested window,
// inclusive of the start time and exclusive of the end time.
func (window SuggestedWindow) IsWithin(now time.Time) bool {
return !now.Before(window.Start) && now.Before(window.End)
}

// RenewalInfo is a type which is exposed to clients which query the renewalInfo
// endpoint specified in draft-aaron-ari.
type RenewalInfo struct {
Expand Down
24 changes: 24 additions & 0 deletions core/objects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math/big"
"net"
"testing"
"time"

"gopkg.in/go-jose/go-jose.v2"

Expand Down Expand Up @@ -174,3 +175,26 @@ func TestFindChallengeByType(t *testing.T) {
test.AssertEquals(t, 1, authz.FindChallengeByStringID(authz.Challenges[1].StringID()))
test.AssertEquals(t, -1, authz.FindChallengeByStringID("hello"))
}

func TestRenewalInfoSuggestedWindowIsWithin(t *testing.T) {
now := time.Now().UTC()
window := SuggestedWindow{
Start: now,
End: now.Add(time.Hour),
}

// Exactly the beginning, inclusive of the first nanosecond.
test.Assert(t, window.IsWithin(now), "Start of window should be within the window")

// Exactly the middle.
test.Assert(t, window.IsWithin(now.Add(time.Minute*30)), "Middle of window should be within the window")

// Exactly the end time.
test.Assert(t, !window.IsWithin(now.Add(time.Hour)), "End of window should be outside the window")

// Exactly the end of the window.
test.Assert(t, window.IsWithin(now.Add(time.Hour-time.Nanosecond)), "Should be just inside the window")

// Just before the first nanosecond.
test.Assert(t, !window.IsWithin(now.Add(-time.Nanosecond)), "Before the window should not be within the window")
}
155 changes: 133 additions & 22 deletions wfe2/wfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,47 @@ func (wfe *WebFrontEndImpl) refundNewOrderLimits(ctx context.Context, transactio
}
}

// orderMatchesReplacement checks if the order matches the provided certificate
// as identified by the provided ARI CertID. This function ensures that:
// - the certificate being replaced exists,
// - the requesting account owns that certificate, and
// - a name in this new order matches a name in the certificate being
// replaced.
func (wfe *WebFrontEndImpl) orderMatchesReplacement(ctx context.Context, acct *core.Registration, names []string, serial string) error {
// It's okay to use GetCertificate (vs trying to get a precertificate),
// because we don't intend to serve ARI for certs that never made it past
// the precert stage.
oldCert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial})
if err != nil {
if errors.Is(err, berrors.NotFound) {
return berrors.NotFoundError("request included `replaces` field, but no current certificate with serial %q exists", serial)
}
return errors.New("failed to retrieve existing certificate")
}

if oldCert.RegistrationID != acct.ID {
return berrors.UnauthorizedError("requester account did not request the certificate being replaced by this order")
}
parsedCert, err := x509.ParseCertificate(oldCert.Der)
if err != nil {
return fmt.Errorf("error parsing certificate replaced by this order: %w", err)
}

var nameMatch bool
for _, name := range names {
if parsedCert.VerifyHostname(name) == nil {
// At least one name in the new order matches a name in the
// predecessor certificate.
nameMatch = true
break
}
}
if !nameMatch {
return berrors.MalformedError("identifiers in this order do not match any names in the certificate being replaced")
}
return nil
}

func (wfe *WebFrontEndImpl) determineARIWindow(ctx context.Context, serial string) (core.RenewalInfo, error) {
// Check if the serial is impacted by an incident.
result, err := wfe.sa.IncidentsForSerial(ctx, &sapb.Serial{Serial: serial})
Expand Down Expand Up @@ -2147,12 +2188,71 @@ func (wfe *WebFrontEndImpl) determineARIWindow(ctx context.Context, serial strin
// the precert stage.
cert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial})
if err != nil {
return core.RenewalInfo{}, fmt.Errorf("retrieving existing certificate: %w", err)
if errors.Is(err, berrors.NotFound) {
return core.RenewalInfo{}, err
}
return core.RenewalInfo{}, fmt.Errorf("failed to retrieve existing certificate: %w", err)
}

return core.RenewalInfoSimple(cert.Issued.AsTime(), cert.Expires.AsTime()), nil
}

// validateReplacementOrder implements draft-ietf-acme-ari-02. For a new order
// to be considered a replacement for an existing certificate, the existing
// certificate:
// 1. MUST NOT have been replaced by another finalized order,
// 2. MUST be associated with the same ACME account as this request, and
// 3. MUST have at least one identifier in common with this request.
//
// There are three values returned by this function:
// - The first return value is the serial number of the certificate being
// replaced. If the order is not a replacement, this value is an empty
// string.
// - The second return value is a boolean indicating whether the order is
// exempt from rate limits. If the order is a replacement and the request
// is made within the suggested renewal window, this value is true.
// Otherwise, this value is false.
// - The last value is an error, this is non-nil unless the order is not a
// replacement or there was an error while validating the replacement.
func (wfe *WebFrontEndImpl) validateReplacementOrder(ctx context.Context, acct *core.Registration, names []string, replaces string) (string, bool, error) {
if replaces == "" {
// No replacement indicated.
return "", false, nil
}

certID, err := parseCertID(replaces, wfe.issuerCertificates)
if err != nil {
return "", false, fmt.Errorf("while parsing ARI CertID an error occurred: %w", err)
}

exists, err := wfe.sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: certID.Serial()})
if err != nil {
return "", false, fmt.Errorf("checking replacement status of existing certificate: %w", err)
}
if exists.Exists {
return "", false, berrors.MalformedError("cannot indicate an order replaces a certificate which already has a replacement order")
}

err = wfe.orderMatchesReplacement(ctx, acct, names, certID.Serial())
if err != nil {
// The provided replacement field value failed to meet the required
// criteria. We're going to return the error to the caller instead
// of trying to create a regular (non-replacement) order.
return "", false, fmt.Errorf("while checking that this order is a replacement: %w", err)
}
// This order is a replacement for an existing certificate.
replaces = certID.Serial()

// For an order to be exempt from rate limits, it must be a replacement
// and the request must be made within the suggested renewal window.
renewalInfo, err := wfe.determineARIWindow(ctx, replaces)
if err != nil {
return "", false, err
}

return replaces, renewalInfo.SuggestedWindow.IsWithin(wfe.clk.Now()), 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(
Expand All @@ -2168,12 +2268,14 @@ func (wfe *WebFrontEndImpl) NewOrder(
return
}

// We only allow specifying Identifiers in a new order request - if the
// `notBefore` and/or `notAfter` fields described in Section 7.4 of acme-08
// are sent we return a probs.Malformed as we do not support them
// newOrderRequest is the JSON structure of the request body. We only
// support the identifiers and replaces fields. If notBefore or notAfter are
// sent we return a probs.Malformed as we do not support them.
var newOrderRequest struct {
Identifiers []identifier.ACMEIdentifier `json:"identifiers"`
NotBefore, NotAfter string
Identifiers []identifier.ACMEIdentifier `json:"identifiers"`
NotBefore string
NotAfter string
Replaces string
}
err := json.Unmarshal(body, &newOrderRequest)
if err != nil {
Expand Down Expand Up @@ -2227,12 +2329,21 @@ func (wfe *WebFrontEndImpl) NewOrder(

logEvent.DNSNames = names

replaces, limitsExempt, err := wfe.validateReplacementOrder(ctx, acct, names, newOrderRequest.Replaces)
if err != nil {
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While validating order as a replacement and error occurred"), 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
// ratelimits package and cannot be prematurely canceled by the requester.
txns := wfe.newNewOrderLimitTransactions(acct.ID, names)
go wfe.checkNewOrderLimits(ctx, txns)
var txns []ratelimits.Transaction
if !limitsExempt {
txns = wfe.newNewOrderLimitTransactions(acct.ID, names)
go wfe.checkNewOrderLimits(ctx, txns)
}

var newOrderSuccessful bool
var errIsRateLimit bool
Expand All @@ -2248,6 +2359,8 @@ func (wfe *WebFrontEndImpl) NewOrder(
order, err := wfe.ra.NewOrder(ctx, &rapb.NewOrderRequest{
RegistrationID: acct.ID,
Names: names,
ReplacesSerial: replaces,
LimitsExempt: limitsExempt,
})
// 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) {
Expand Down Expand Up @@ -2560,15 +2673,15 @@ func (c certID) Serial() string {
// certID struct with the keyIdentifier and serialNumber extracted and decoded.
// For more details see:
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1.
func parseCertID(path string, issuerCertificates map[issuance.NameID]*issuance.Certificate) (ariCertID, *probs.ProblemDetails, error) {
func parseCertID(path string, issuerCertificates map[issuance.NameID]*issuance.Certificate) (ariCertID, error) {
parts := strings.Split(path, ".")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return certID{}, probs.Malformed("Invalid path"), nil
return certID{}, berrors.MalformedError("Invalid path")
}

akid, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return certID{}, probs.Malformed("Authority Key Identifier was not base64url-encoded or contained padding", err), err
return certID{}, berrors.MalformedError("Authority Key Identifier was not base64url-encoded or contained padding: %s", err)
}

var found bool
Expand All @@ -2579,18 +2692,18 @@ func parseCertID(path string, issuerCertificates map[issuance.NameID]*issuance.C
}
}
if !found {
return certID{}, probs.NotFound("Path contained an Authority Key Identifier that did not match a known issuer"), nil
return certID{}, berrors.NotFoundError("path contained an Authority Key Identifier that did not match a known issuer")
}

serialNumber, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return certID{}, probs.Malformed("Serial number was not base64url-encoded or contained padding", err), err
return certID{}, berrors.NotFoundError("serial number was not base64url-encoded or contained padding: %s", err)
}

return certID{
keyIdentifier: akid,
serialNumber: new(big.Int).SetBytes(serialNumber),
}, nil, nil
}, nil
}

// RenewalInfo is used to get information about the suggested renewal window
Expand All @@ -2616,10 +2729,9 @@ func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.Reque
certID, err := parseDeprecatedCertID(request.URL.Path)
if err != nil {
// Try parsing certID param using the draft-ietf-acme-ari-02 format.
var prob *probs.ProblemDetails
certID, prob, err = parseCertID(request.URL.Path, wfe.issuerCertificates)
if prob != nil {
wfe.sendError(response, logEvent, prob, err)
certID, err = parseCertID(request.URL.Path, wfe.issuerCertificates)
if err != nil {
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While parsing ARI CertID an error occurred"), err)
return
}
}
Expand Down Expand Up @@ -2678,10 +2790,9 @@ func (wfe *WebFrontEndImpl) UpdateRenewal(ctx context.Context, logEvent *web.Req
certID, err := parseDeprecatedCertID(updateRenewalRequest.CertID)
if err != nil {
// Try parsing certID param using the draft-ietf-acme-ari-02 format.
var prob *probs.ProblemDetails
certID, prob, err = parseCertID(updateRenewalRequest.CertID, wfe.issuerCertificates)
if prob != nil {
wfe.sendError(response, logEvent, prob, err)
certID, err = parseCertID(updateRenewalRequest.CertID, wfe.issuerCertificates)
if err != nil {
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While parsing ARI CertID an error occurred"), err)
return
}
}
Expand Down
56 changes: 56 additions & 0 deletions wfe2/wfe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3986,3 +3986,59 @@ func Test_sendError(t *testing.T) {
// Ensure the Link header isn't populatsed.
test.AssertEquals(t, testResponse.Header().Get("Link"), "")
}

type mockSA struct {
sapb.StorageAuthorityReadOnlyClient
cert *corepb.Certificate
}

// GetCertificate returns the inner certificate if it matches the given serial.
func (sa *mockSA) GetCertificate(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) {
if req.Serial == sa.cert.Serial {
return sa.cert, nil
}
return nil, berrors.NotFoundError("certificate with serial %q not found", req.Serial)
}

func TestOrderMatchesReplacement(t *testing.T) {
wfe, _, _ := setupWFE(t)

expectExpiry := time.Now().AddDate(0, 0, 1)
expectSerial := big.NewInt(1337)
testKey, _ := rsa.GenerateKey(rand.Reader, 1024)
rawCert := x509.Certificate{
NotAfter: expectExpiry,
DNSNames: []string{"example.com", "example-a.com"},
SerialNumber: expectSerial,
}
mockDer, err := x509.CreateCertificate(rand.Reader, &rawCert, &rawCert, &testKey.PublicKey, testKey)
test.AssertNotError(t, err, "failed to create test certificate")

wfe.sa = &mockSA{
cert: &corepb.Certificate{
RegistrationID: 1,
Serial: expectSerial.String(),
Der: mockDer,
},
}

// Working with a single matching identifier.
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, expectSerial.String())
test.AssertNotError(t, err, "failed to check order is replacement")

// Working with a different matching identifier.
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example-a.com"}, expectSerial.String())
test.AssertNotError(t, err, "failed to check order is replacement")

// No matching identifiers.
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example-b.com"}, expectSerial.String())
test.AssertErrorIs(t, err, berrors.Malformed)

// RegID for predecessor order does not match.
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 2}, []string{"example.com"}, expectSerial.String())
test.AssertErrorIs(t, err, berrors.Unauthorized)

// Predecessor certificate not found.
err = wfe.orderMatchesReplacement(context.Background(), &core.Registration{ID: 1}, []string{"example.com"}, "1")
test.AssertErrorIs(t, err, berrors.NotFound)
}