diff --git a/acme/common.go b/acme/common.go index ab926d26..47a58ace 100644 --- a/acme/common.go +++ b/acme/common.go @@ -59,6 +59,9 @@ type Order struct { NotAfter string `json:"notAfter,omitempty"` Authorizations []string `json:"authorizations"` Certificate string `json:"certificate,omitempty"` + + // https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + Replaces string `json:"replaces,omitempty"` } // An Authorization is created for each identifier in an order diff --git a/ca/ca.go b/ca/ca.go index c16d7f4a..ae0175f0 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -438,6 +438,25 @@ func (ca *CAImpl) CompleteOrder(order *core.Order) { order.Unlock() } +// RecognizedSKID attempts to match the incoming Authority Key Idenfitier (AKID) +// bytes to the Subject Key Identifier (SKID) of an intermediate certificate. It +// returns an error if no match is found. +func (ca *CAImpl) RecognizedSKID(issuer []byte) error { + if issuer == nil { + return errors.New("issuer bytes must not be nil") + } + + for _, chain := range ca.chains { + for _, intermediate := range chain.intermediates { + if bytes.Equal(intermediate.cert.Cert.SubjectKeyId, issuer) { + return nil + } + } + } + + return errors.New("no known issuer matches the provided Authority Key Identifier ") +} + func (ca *CAImpl) GetNumberOfRootCerts() int { return len(ca.chains) } diff --git a/cmd/pebble/main.go b/cmd/pebble/main.go index 9fb0886c..fde0ed5d 100644 --- a/cmd/pebble/main.go +++ b/cmd/pebble/main.go @@ -95,7 +95,7 @@ func main() { db := db.NewMemoryStore() ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, alternateRoots, chainLength, c.Pebble.CertificateValidityPeriod) - va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress) + va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress, db) for keyID, key := range c.Pebble.ExternalAccountMACKeys { err := db.AddExternalAccountKeyByID(keyID, key) diff --git a/core/types.go b/core/types.go index b94d5a77..23c1f141 100644 --- a/core/types.go +++ b/core/types.go @@ -5,9 +5,11 @@ import ( "crypto" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/pem" "errors" "fmt" + "math/big" "sync" "time" @@ -27,6 +29,8 @@ type Order struct { AuthorizationObjects []*Authorization BeganProcessing bool CertificateObject *Certificate + // Indicates if the finalized order has been successfully replaced via ARI. + IsReplaced bool } func (o *Order) GetStatus() (string, error) { @@ -200,3 +204,85 @@ type ValidationRecord struct { Error *acme.ProblemDetails ValidatedAt time.Time } + +// CertID represents a unique identifier (CertID) for a certificate as per the +// ACME protocol's "renewalInfo" resource, as specified in draft-ietf-acme-ari- +// 03. The CertID is a composite string derived from the base64url-encoded +// keyIdentifier of the certificate's Authority Key Identifier (AKI) and the +// base64url-encoded serial number of the certificate, separated by a period. +// For more details see: +// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1. +type CertID struct { + KeyIdentifier []byte + SerialNumber *big.Int + // id is the pre-computed hex encoding of SerialNumber. + id string +} + +// SerialHex returns a CertID's id field. +func (c CertID) SerialHex() string { + return c.id +} + +// NewCertID takes bytes representing a serial number and authority key +// identifier and returns a CertID or an error. +func NewCertID(serial []byte, akid []byte) (*CertID, error) { + if serial == nil || akid == nil { + return nil, errors.New("must send non-nil bytes") + } + + return &CertID{ + KeyIdentifier: akid, + SerialNumber: new(big.Int).SetBytes(serial), + id: hex.EncodeToString(serial), + }, nil +} + +// SuggestedWindow is a type exposed inside the RenewalInfo resource. +type SuggestedWindow struct { + Start time.Time `json:"start"` + 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 { + SuggestedWindow SuggestedWindow `json:"suggestedWindow"` +} + +// RenewalInfoSimple constructs a `RenewalInfo` object and suggested window +// using a very simple renewal calculation: calculate a point 2/3rds of the way +// through the validity period, then give a 2-day window around that. Both the +// `issued` and `expires` timestamps are expected to be UTC. +func RenewalInfoSimple(issued time.Time, expires time.Time) *RenewalInfo { + validity := expires.Add(time.Second).Sub(issued) + renewalOffset := validity / time.Duration(3) + idealRenewal := expires.Add(-renewalOffset) + return &RenewalInfo{ + SuggestedWindow: SuggestedWindow{ + Start: idealRenewal.Add(-24 * time.Hour), + End: idealRenewal.Add(24 * time.Hour), + }, + } +} + +// RenewalInfoImmediate constructs a `RenewalInfo` object with a suggested +// window in the past. Per the draft-ietf-acme-ari-01 spec, clients should +// attempt to renew immediately if the suggested window is in the past. The +// passed `now` is assumed to be a timestamp representing the current moment in +// time. +func RenewalInfoImmediate(now time.Time) *RenewalInfo { + oneHourAgo := now.Add(-1 * time.Hour) + return &RenewalInfo{ + SuggestedWindow: SuggestedWindow{ + Start: oneHourAgo, + End: oneHourAgo.Add(time.Minute * 30), + }, + } +} diff --git a/db/memorystore.go b/db/memorystore.go index bf45796d..66c3cab0 100644 --- a/db/memorystore.go +++ b/db/memorystore.go @@ -46,8 +46,11 @@ type MemoryStore struct { // key bytes. accountsByKeyID map[string]*core.Account - ordersByID map[string]*core.Order - ordersByAccountID map[string][]*core.Order + // ordersByIssuedSerial indexes the hex encoding of the certificate's + // SerialNumber. + ordersByIssuedSerial map[string]*core.Order + ordersByID map[string]*core.Order + ordersByAccountID map[string][]*core.Order authorizationsByID map[string]*core.Authorization @@ -66,6 +69,7 @@ func NewMemoryStore() *MemoryStore { accountRand: rand.New(rand.NewSource(time.Now().UnixNano())), accountsByID: make(map[string]*core.Account), accountsByKeyID: make(map[string]*core.Account), + ordersByIssuedSerial: make(map[string]*core.Order), ordersByID: make(map[string]*core.Order), ordersByAccountID: make(map[string][]*core.Order), authorizationsByID: make(map[string]*core.Authorization), @@ -94,6 +98,28 @@ func (m *MemoryStore) GetAccountByKey(key crypto.PublicKey) (*core.Account, erro return m.accountsByKeyID[keyID], nil } +// UpdateReplacedOrder takes a serial and marks a parent order as +// replaced/not-replaced or returns an error. +// +// We intentionally don't Lock the database inside this method because the inner +// GetOrderByIssuedSerial which is used elsewhere does an RLock which would +// hang. +func (m *MemoryStore) UpdateReplacedOrder(serial string, shouldBeReplaced bool) error { + if serial == "" { + return acme.InternalErrorProblem("no serial provided") + } + + originalOrder, err := m.GetOrderByIssuedSerial(serial) + if err != nil { + return acme.InternalErrorProblem(fmt.Sprintf("could not find an order for the given certificate: %s", err)) + } + originalOrder.Lock() + defer originalOrder.Unlock() + originalOrder.IsReplaced = shouldBeReplaced + + return nil +} + // Note that this function should *NOT* be used for key changes. It assumes // the public key associated to the account does not change. Use ChangeAccountKey // to change the account's public key. @@ -195,6 +221,19 @@ func (m *MemoryStore) AddOrder(order *core.Order) (int, error) { return len(m.ordersByID), nil } +func (m *MemoryStore) AddOrderByIssuedSerial(order *core.Order) error { + m.Lock() + defer m.Unlock() + + if order.CertificateObject == nil { + return errors.New("order must have non-empty CertificateObject") + } + + m.ordersByIssuedSerial[order.CertificateObject.ID] = order + + return nil +} + func (m *MemoryStore) GetOrderByID(id string) *core.Order { m.RLock() defer m.RUnlock() @@ -212,6 +251,20 @@ func (m *MemoryStore) GetOrderByID(id string) *core.Order { return nil } +// GetOrderByIssuedSerial returns the order that resulted in the given certificate +// serial. If no such order exists, an error will be returned. +func (m *MemoryStore) GetOrderByIssuedSerial(serial string) (*core.Order, error) { + m.RLock() + defer m.RUnlock() + + order, ok := m.ordersByIssuedSerial[serial] + if !ok { + return nil, errors.New("could not find order resulting in the given certificate serial number") + } + + return order, nil +} + func (m *MemoryStore) GetOrdersByAccountID(accountID string) []*core.Order { m.RLock() defer m.RUnlock() diff --git a/va/va.go b/va/va.go index f4cffaa3..cc1f098c 100644 --- a/va/va.go +++ b/va/va.go @@ -27,6 +27,7 @@ import ( "github.com/letsencrypt/challtestsrv" "github.com/letsencrypt/pebble/v2/acme" "github.com/letsencrypt/pebble/v2/core" + "github.com/letsencrypt/pebble/v2/db" ) const ( @@ -108,12 +109,18 @@ type VAImpl struct { strict bool customResolverAddr string dnsClient *dns.Client + + // The VA having a DB client is indeed strange. This is only used to + // facilitate va.setOrderError changing the ARI related order replacement + // field on failed orders. + db *db.MemoryStore } func New( log *log.Logger, httpPort, tlsPort int, strict bool, customResolverAddr string, + db *db.MemoryStore, ) *VAImpl { va := &VAImpl{ log: log, @@ -124,6 +131,7 @@ func New( sleepTime: defaultSleepTime, strict: strict, customResolverAddr: customResolverAddr, + db: db, } if customResolverAddr != "" { @@ -209,10 +217,17 @@ func (va VAImpl) setAuthzValid(authz *core.Authorization, chal *core.Challenge) // setOrderError updates an order with an error from an authorization // validation. -func (va VAImpl) setOrderError(order *core.Order, err *acme.ProblemDetails) { +func (va VAImpl) setOrderError(order *core.Order, prob *acme.ProblemDetails) { order.Lock() defer order.Unlock() - order.Error = err + order.Error = prob + + // Mark the parent order as "not replaced yet" so a new replacement order + // can be attempted. + err := va.db.UpdateReplacedOrder(order.Replaces, false) + if err != nil { + va.log.Printf("Error updating replacement order: %s", err) + } } // setAuthzInvalid updates an authorization and an associated challenge to be diff --git a/va/va_test.go b/va/va_test.go index d8d40fe9..8dcc8f75 100644 --- a/va/va_test.go +++ b/va/va_test.go @@ -31,7 +31,7 @@ func TestAuthzRace(_ *testing.T) { // This whole test can be removed if/when the MemoryStore becomes 100% by value ms := db.NewMemoryStore() - va := New(log.New(os.Stdout, "Pebble/TestRace", log.LstdFlags), 14000, 15000, false, "") + va := New(log.New(os.Stdout, "Pebble/TestRace", log.LstdFlags), 14000, 15000, false, "", ms) authz := &core.Authorization{ ID: "auth-id", diff --git a/wfe/wfe.go b/wfe/wfe.go index 0f47fc26..264fd014 100644 --- a/wfe/wfe.go +++ b/wfe/wfe.go @@ -20,6 +20,7 @@ import ( "net/mail" "net/url" "os" + "slices" "sort" "strconv" "strings" @@ -53,6 +54,9 @@ const ( keyRolloverPath = "/rollover-account-key" ordersPath = "/list-orderz/" + // Draft or likely-to-change paths + renewalInfoPath = "/draft-ietf-acme-ari-03/renewalInfo/" + // Theses entrypoints are not a part of the standard ACME endpoints, // and are exposed by Pebble as an integration test tool. We export // RootCertPath so that the pebble binary can reference it. @@ -507,6 +511,8 @@ func (wfe *WebFrontEndImpl) Handler() http.Handler { m := http.NewServeMux() // GET & POST handlers wfe.HandleFunc(m, DirectoryPath, wfe.Directory, http.MethodGet, http.MethodPost) + wfe.HandleFunc(m, renewalInfoPath, wfe.RenewalInfo, http.MethodGet, http.MethodPost) + // Note for noncePath: http.MethodGet also implies http.MethodHead wfe.HandleFunc(m, noncePath, wfe.Nonce, http.MethodGet, http.MethodPost) @@ -550,6 +556,11 @@ func (wfe *WebFrontEndImpl) Directory( "newOrder": newOrderPath, "revokeCert": revokeCertPath, "keyChange": keyRolloverPath, + // ARI-capable clients are expected to add the trailing slash per the + // draft. We explicitly strip the trailing slash here so that clients + // don't need to add trailing slash handling in their own code, saving + // them minimal amounts of complexity. + "renewalInfo": strings.TrimRight(renewalInfoPath, "/"), } // RFC 8555 ยง6.3 says the server's directory endpoint should support @@ -1445,6 +1456,11 @@ func (wfe *WebFrontEndImpl) verifyOrder(order *core.Order) *acme.ProblemDetails return problem } } + + if problem := wfe.validateReplacementOrder(order); problem != nil { + return problem + } + return nil } @@ -1627,6 +1643,60 @@ func (wfe *WebFrontEndImpl) makeChallenges(authz *core.Authorization, request *h return nil } +// validateReplacementOrder performs several sanity checks on the order to +// determine if the order is a replacement of an existing order. If the order is +// not a replacement or is a valid replacement, no problem will be returned. +// Otherwise the caller will receive a problem. +func (wfe *WebFrontEndImpl) validateReplacementOrder(newOrder *core.Order) *acme.ProblemDetails { + if newOrder == nil { + return acme.InternalErrorProblem("Order is nil") + } + + if newOrder.Replaces == "" { + return nil + } + + certID, err := wfe.parseCertID(newOrder.Replaces) + if err != nil { + return acme.MalformedProblem(fmt.Sprintf("parsing ARI CertID failed: %s", err)) + } + + originalOrder, err := wfe.db.GetOrderByIssuedSerial(certID.SerialHex()) + if err != nil { + return acme.InternalErrorProblem(fmt.Sprintf("could not find an order for the given certificate: %s", err)) + } + + if originalOrder.IsReplaced { + return acme.Conflict(fmt.Sprintf("cannot indicate an order replaces certificate with serial %s, which already has a replacement order", certID.SerialHex())) + } + + if originalOrder.AccountID != newOrder.AccountID { + return acme.UnauthorizedProblem("requester account did not request the certificate being replaced by this order") + } + + // Servers SHOULD check that the identified certificate and the New Order + // request correspond to the same ACME Account, that they share at least one + // identifier, and that the identified certificate has not already been + // marked as replaced by a different Order that is not "invalid". + // Correspondence checks beyond this (such as requiring exact identifier + // matching) are left up to Server policy. If any of these checks fail, the + // Server SHOULD reject the new-order request. + var foundMatchingIdentifier bool + for _, id := range originalOrder.Identifiers { + if slices.Contains(newOrder.Identifiers, id) { + foundMatchingIdentifier = true + break + } + } + if !foundMatchingIdentifier { + return acme.InternalErrorProblem("at least one identifier in the new order and existing order must match") + } + + wfe.log.Printf("ARI: order %q is a replacement of %q\n", newOrder.ID, originalOrder.ID) + + return nil +} + // NewOrder creates a new Order request and populates its authorizations func (wfe *WebFrontEndImpl) NewOrder( _ context.Context, @@ -1650,7 +1720,7 @@ func (wfe *WebFrontEndImpl) NewOrder( err := json.Unmarshal(postData.body, &newOrder) if err != nil { wfe.sendError( - acme.MalformedProblem(fmt.Sprintf("Error unmarshalling body JSON: %s", err.Error())), response) + acme.MalformedProblem(fmt.Sprintf("Error unmarshaling body JSON: %s", err.Error())), response) return } @@ -1689,6 +1759,7 @@ func (wfe *WebFrontEndImpl) NewOrder( Identifiers: uniquenames, NotBefore: newOrder.NotBefore, NotAfter: newOrder.NotAfter, + Replaces: newOrder.Replaces, }, ExpiresDate: expires, } @@ -1711,7 +1782,7 @@ func (wfe *WebFrontEndImpl) NewOrder( count, err := wfe.db.AddOrder(order) if err != nil { wfe.sendError( - acme.InternalErrorProblem("Error saving order"), response) + acme.InternalErrorProblem(fmt.Sprintf("Error saving order: %s", err)), response) return } wfe.log.Printf("Added order %q to the db\n", order.ID) @@ -1775,6 +1846,91 @@ func (wfe *WebFrontEndImpl) orderForDisplay( return result } +// RenewalInfo implements ACME Renewal Info (ARI) +func (wfe *WebFrontEndImpl) RenewalInfo(_ context.Context, response http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodPost { + wfe.sendError(acme.InternalErrorProblem("POSTing to RenewalInfo has not been implemented yet"), response) + return + } + + if len(request.URL.Path) == 0 { + wfe.sendError(acme.NotFoundProblem("Must specify a request path"), response) + return + } + + certID, err := wfe.parseCertID(request.URL.Path) + if err != nil { + wfe.sendError(acme.MalformedProblem(fmt.Sprintf("Parsing ARI CertID failed: %s", err)), response) + return + } + + renewalInfo, err := wfe.determineARIWindow(certID) + if err != nil { + wfe.sendError(acme.InternalErrorProblem(fmt.Sprintf("Error determining renewal window: %s", err)), response) + return + } + + response.Header().Set("Retry-After", strconv.Itoa(int(6*time.Hour/time.Second))) + err = wfe.writeJSONResponse(response, http.StatusOK, renewalInfo) + if err != nil { + wfe.sendError(acme.InternalErrorProblem(fmt.Sprintf("Error marshaling renewalInfo: %s", err)), response) + return + } +} + +func (wfe *WebFrontEndImpl) determineARIWindow(id *core.CertID) (*core.RenewalInfo, error) { + if id == nil { + return nil, errors.New("CertID was nil") + } + + // Check if the serial is revoked, and if so, renew it immediately. + isRevoked := wfe.db.GetRevokedCertificateBySerial(id.SerialNumber) + if isRevoked != nil { + return core.RenewalInfoImmediate(time.Now().In(time.UTC)), nil + } + + cert := wfe.db.GetCertificateBySerial(id.SerialNumber) + if cert == nil { + return nil, errors.New("failed to retrieve existing certificate serial") + } + + return core.RenewalInfoSimple(cert.Cert.NotBefore, cert.Cert.NotAfter), nil +} + +// parseCertID parses a unique identifier (certID) as specified in +// draft-ietf-acme-ari-03. It takes the composite string as input returns a +// certID struct with the keyIdentifier and serialNumber extracted and decoded. +// For more details see: +// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1. +func (wfe *WebFrontEndImpl) parseCertID(path string) (*core.CertID, error) { + parts := strings.Split(path, ".") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, acme.MalformedProblem("Invalid path") + } + + akid, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, acme.MalformedProblem(fmt.Sprintf("Authority Key Identifier was not base64url-encoded or contained padding: %s", err)) + } + + err = wfe.ca.RecognizedSKID(akid) + if err != nil { + return nil, acme.MalformedProblem(err.Error()) + } + + serialBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, acme.MalformedProblem(fmt.Sprintf("serial number was not base64url-encoded or contained padding: %s", err)) + } + + certID, err := core.NewCertID(serialBytes, akid) + if err != nil { + return nil, acme.MalformedProblem(fmt.Sprintf("error creating certID: %s", err)) + } + + return certID, nil +} + // Order retrieves the details of an existing order func (wfe *WebFrontEndImpl) Order( _ context.Context, @@ -1825,7 +1981,7 @@ func (wfe *WebFrontEndImpl) Order( } } -func (wfe *WebFrontEndImpl) FinalizeOrder( +func (wfe *WebFrontEndImpl) FinalizeOrder( //nolint:gocyclo,gocognit _ context.Context, response http.ResponseWriter, request *http.Request, @@ -1859,9 +2015,7 @@ func (wfe *WebFrontEndImpl) FinalizeOrder( orderStatus := existingOrder.Status orderExpires := existingOrder.ExpiresDate orderIdentifiers := existingOrder.Identifiers - // And then immediately unlock it again - we don't defer() here because - // `maybeIssue` will also acquire a read lock and we call that before - // returning + orderReplaces := existingOrder.Replaces existingOrder.RUnlock() if orderAccountID != existingAcct.ID { @@ -1985,13 +2139,39 @@ func (wfe *WebFrontEndImpl) FinalizeOrder( // Lock and update the order with the parsed CSR and the began processing // state. existingOrder.Lock() + // do a check on the stored order to see if another process + // changed the order status existingOrder.ParsedCSR = parsedCSR existingOrder.BeganProcessing = true existingOrder.Unlock() - // Ask the CA to complete the order in a separate goroutine. wfe.log.Printf("Order %s is fully authorized. Processing finalization", orderID) - go wfe.ca.CompleteOrder(existingOrder) + + // Perform asynchronous finalization + go func() { + wfe.ca.CompleteOrder(existingOrder) + + // Store the order in this table so we can check if it had previously been replaced. + err := wfe.db.AddOrderByIssuedSerial(existingOrder) + if err != nil { + wfe.log.Printf("Error saving order: %s", err) + return + } + + if orderReplaces != "" { + certID, err := wfe.parseCertID(orderReplaces) + if err != nil { + wfe.log.Printf("parsing ARI CertID failed: %s", err) + return + } + err = wfe.db.UpdateReplacedOrder(certID.SerialHex(), true) + if err != nil { + wfe.log.Printf("Error updating replacement order: %s", err) + return + } + wfe.log.Printf("Order %s has been marked as replaced in the DB", orderID) + } + }() // Set the existingOrder to processing before displaying to the user existingOrder.Status = acme.StatusProcessing