Skip to content

Commit

Permalink
Implement latest draft-ietf-acme-ari spec (#461)
Browse files Browse the repository at this point in the history
The draft spec version at the time of this PR was
draft-ietf-acme-ari-03, but failed replacement order handling is from
the [yet-to-be-released
draft-ietf-acme-ari-04](https://github.com/aarongable/draft-acme-ari/blob/1813de294a6d813f4eba3f5c45b14ee5139ef66a/draft-ietf-acme-ari.md#L177).

* Add a `renewalInfo` entry to the directory object which provides the
base URL for ARI requests.
* Add a new WFE handlefunc which parses incoming requests and returns
reasonable `renewalInfo` for determining when the client should attempt
renewal of a certificate.
* Add support for marking orders as `replaced`. Replacement orders can
be chained, but there can be no duplicate replacement of orders, just
like boulder.
* Restructured the asynchronous finalization anonymous go func to handle
storing replaced orders. To be replaced, an order must previously have
been finalized and have an issued certificate.
  • Loading branch information
pgporada committed May 24, 2024
1 parent 5f93713 commit db1f587
Show file tree
Hide file tree
Showing 8 changed files with 370 additions and 14 deletions.
3 changes: 3 additions & 0 deletions acme/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions ca/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pebble/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"crypto"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"sync"
"time"

Expand All @@ -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) {
Expand Down Expand Up @@ -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),
},
}
}
57 changes: 55 additions & 2 deletions db/memorystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
19 changes: 17 additions & 2 deletions va/va.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -124,6 +131,7 @@ func New(
sleepTime: defaultSleepTime,
strict: strict,
customResolverAddr: customResolverAddr,
db: db,
}

if customResolverAddr != "" {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion va/va_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit db1f587

Please sign in to comment.