Skip to content

Commit db1f587

Browse files
authored
Implement latest draft-ietf-acme-ari spec (#461)
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.
1 parent 5f93713 commit db1f587

File tree

8 files changed

+370
-14
lines changed

8 files changed

+370
-14
lines changed

acme/common.go

+3
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ type Order struct {
5959
NotAfter string `json:"notAfter,omitempty"`
6060
Authorizations []string `json:"authorizations"`
6161
Certificate string `json:"certificate,omitempty"`
62+
63+
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
64+
Replaces string `json:"replaces,omitempty"`
6265
}
6366

6467
// An Authorization is created for each identifier in an order

ca/ca.go

+19
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,25 @@ func (ca *CAImpl) CompleteOrder(order *core.Order) {
438438
order.Unlock()
439439
}
440440

441+
// RecognizedSKID attempts to match the incoming Authority Key Idenfitier (AKID)
442+
// bytes to the Subject Key Identifier (SKID) of an intermediate certificate. It
443+
// returns an error if no match is found.
444+
func (ca *CAImpl) RecognizedSKID(issuer []byte) error {
445+
if issuer == nil {
446+
return errors.New("issuer bytes must not be nil")
447+
}
448+
449+
for _, chain := range ca.chains {
450+
for _, intermediate := range chain.intermediates {
451+
if bytes.Equal(intermediate.cert.Cert.SubjectKeyId, issuer) {
452+
return nil
453+
}
454+
}
455+
}
456+
457+
return errors.New("no known issuer matches the provided Authority Key Identifier ")
458+
}
459+
441460
func (ca *CAImpl) GetNumberOfRootCerts() int {
442461
return len(ca.chains)
443462
}

cmd/pebble/main.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func main() {
9595

9696
db := db.NewMemoryStore()
9797
ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, alternateRoots, chainLength, c.Pebble.CertificateValidityPeriod)
98-
va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress)
98+
va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress, db)
9999

100100
for keyID, key := range c.Pebble.ExternalAccountMACKeys {
101101
err := db.AddExternalAccountKeyByID(keyID, key)

core/types.go

+86
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"crypto"
66
"crypto/x509"
77
"encoding/base64"
8+
"encoding/hex"
89
"encoding/pem"
910
"errors"
1011
"fmt"
12+
"math/big"
1113
"sync"
1214
"time"
1315

@@ -27,6 +29,8 @@ type Order struct {
2729
AuthorizationObjects []*Authorization
2830
BeganProcessing bool
2931
CertificateObject *Certificate
32+
// Indicates if the finalized order has been successfully replaced via ARI.
33+
IsReplaced bool
3034
}
3135

3236
func (o *Order) GetStatus() (string, error) {
@@ -200,3 +204,85 @@ type ValidationRecord struct {
200204
Error *acme.ProblemDetails
201205
ValidatedAt time.Time
202206
}
207+
208+
// CertID represents a unique identifier (CertID) for a certificate as per the
209+
// ACME protocol's "renewalInfo" resource, as specified in draft-ietf-acme-ari-
210+
// 03. The CertID is a composite string derived from the base64url-encoded
211+
// keyIdentifier of the certificate's Authority Key Identifier (AKI) and the
212+
// base64url-encoded serial number of the certificate, separated by a period.
213+
// For more details see:
214+
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1.
215+
type CertID struct {
216+
KeyIdentifier []byte
217+
SerialNumber *big.Int
218+
// id is the pre-computed hex encoding of SerialNumber.
219+
id string
220+
}
221+
222+
// SerialHex returns a CertID's id field.
223+
func (c CertID) SerialHex() string {
224+
return c.id
225+
}
226+
227+
// NewCertID takes bytes representing a serial number and authority key
228+
// identifier and returns a CertID or an error.
229+
func NewCertID(serial []byte, akid []byte) (*CertID, error) {
230+
if serial == nil || akid == nil {
231+
return nil, errors.New("must send non-nil bytes")
232+
}
233+
234+
return &CertID{
235+
KeyIdentifier: akid,
236+
SerialNumber: new(big.Int).SetBytes(serial),
237+
id: hex.EncodeToString(serial),
238+
}, nil
239+
}
240+
241+
// SuggestedWindow is a type exposed inside the RenewalInfo resource.
242+
type SuggestedWindow struct {
243+
Start time.Time `json:"start"`
244+
End time.Time `json:"end"`
245+
}
246+
247+
// IsWithin returns true if the given time is within the suggested window,
248+
// inclusive of the start time and exclusive of the end time.
249+
func (window SuggestedWindow) IsWithin(now time.Time) bool {
250+
return !now.Before(window.Start) && now.Before(window.End)
251+
}
252+
253+
// RenewalInfo is a type which is exposed to clients which query the renewalInfo
254+
// endpoint specified in draft-aaron-ari.
255+
type RenewalInfo struct {
256+
SuggestedWindow SuggestedWindow `json:"suggestedWindow"`
257+
}
258+
259+
// RenewalInfoSimple constructs a `RenewalInfo` object and suggested window
260+
// using a very simple renewal calculation: calculate a point 2/3rds of the way
261+
// through the validity period, then give a 2-day window around that. Both the
262+
// `issued` and `expires` timestamps are expected to be UTC.
263+
func RenewalInfoSimple(issued time.Time, expires time.Time) *RenewalInfo {
264+
validity := expires.Add(time.Second).Sub(issued)
265+
renewalOffset := validity / time.Duration(3)
266+
idealRenewal := expires.Add(-renewalOffset)
267+
return &RenewalInfo{
268+
SuggestedWindow: SuggestedWindow{
269+
Start: idealRenewal.Add(-24 * time.Hour),
270+
End: idealRenewal.Add(24 * time.Hour),
271+
},
272+
}
273+
}
274+
275+
// RenewalInfoImmediate constructs a `RenewalInfo` object with a suggested
276+
// window in the past. Per the draft-ietf-acme-ari-01 spec, clients should
277+
// attempt to renew immediately if the suggested window is in the past. The
278+
// passed `now` is assumed to be a timestamp representing the current moment in
279+
// time.
280+
func RenewalInfoImmediate(now time.Time) *RenewalInfo {
281+
oneHourAgo := now.Add(-1 * time.Hour)
282+
return &RenewalInfo{
283+
SuggestedWindow: SuggestedWindow{
284+
Start: oneHourAgo,
285+
End: oneHourAgo.Add(time.Minute * 30),
286+
},
287+
}
288+
}

db/memorystore.go

+55-2
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ type MemoryStore struct {
4646
// key bytes.
4747
accountsByKeyID map[string]*core.Account
4848

49-
ordersByID map[string]*core.Order
50-
ordersByAccountID map[string][]*core.Order
49+
// ordersByIssuedSerial indexes the hex encoding of the certificate's
50+
// SerialNumber.
51+
ordersByIssuedSerial map[string]*core.Order
52+
ordersByID map[string]*core.Order
53+
ordersByAccountID map[string][]*core.Order
5154

5255
authorizationsByID map[string]*core.Authorization
5356

@@ -66,6 +69,7 @@ func NewMemoryStore() *MemoryStore {
6669
accountRand: rand.New(rand.NewSource(time.Now().UnixNano())),
6770
accountsByID: make(map[string]*core.Account),
6871
accountsByKeyID: make(map[string]*core.Account),
72+
ordersByIssuedSerial: make(map[string]*core.Order),
6973
ordersByID: make(map[string]*core.Order),
7074
ordersByAccountID: make(map[string][]*core.Order),
7175
authorizationsByID: make(map[string]*core.Authorization),
@@ -94,6 +98,28 @@ func (m *MemoryStore) GetAccountByKey(key crypto.PublicKey) (*core.Account, erro
9498
return m.accountsByKeyID[keyID], nil
9599
}
96100

101+
// UpdateReplacedOrder takes a serial and marks a parent order as
102+
// replaced/not-replaced or returns an error.
103+
//
104+
// We intentionally don't Lock the database inside this method because the inner
105+
// GetOrderByIssuedSerial which is used elsewhere does an RLock which would
106+
// hang.
107+
func (m *MemoryStore) UpdateReplacedOrder(serial string, shouldBeReplaced bool) error {
108+
if serial == "" {
109+
return acme.InternalErrorProblem("no serial provided")
110+
}
111+
112+
originalOrder, err := m.GetOrderByIssuedSerial(serial)
113+
if err != nil {
114+
return acme.InternalErrorProblem(fmt.Sprintf("could not find an order for the given certificate: %s", err))
115+
}
116+
originalOrder.Lock()
117+
defer originalOrder.Unlock()
118+
originalOrder.IsReplaced = shouldBeReplaced
119+
120+
return nil
121+
}
122+
97123
// Note that this function should *NOT* be used for key changes. It assumes
98124
// the public key associated to the account does not change. Use ChangeAccountKey
99125
// to change the account's public key.
@@ -195,6 +221,19 @@ func (m *MemoryStore) AddOrder(order *core.Order) (int, error) {
195221
return len(m.ordersByID), nil
196222
}
197223

224+
func (m *MemoryStore) AddOrderByIssuedSerial(order *core.Order) error {
225+
m.Lock()
226+
defer m.Unlock()
227+
228+
if order.CertificateObject == nil {
229+
return errors.New("order must have non-empty CertificateObject")
230+
}
231+
232+
m.ordersByIssuedSerial[order.CertificateObject.ID] = order
233+
234+
return nil
235+
}
236+
198237
func (m *MemoryStore) GetOrderByID(id string) *core.Order {
199238
m.RLock()
200239
defer m.RUnlock()
@@ -212,6 +251,20 @@ func (m *MemoryStore) GetOrderByID(id string) *core.Order {
212251
return nil
213252
}
214253

254+
// GetOrderByIssuedSerial returns the order that resulted in the given certificate
255+
// serial. If no such order exists, an error will be returned.
256+
func (m *MemoryStore) GetOrderByIssuedSerial(serial string) (*core.Order, error) {
257+
m.RLock()
258+
defer m.RUnlock()
259+
260+
order, ok := m.ordersByIssuedSerial[serial]
261+
if !ok {
262+
return nil, errors.New("could not find order resulting in the given certificate serial number")
263+
}
264+
265+
return order, nil
266+
}
267+
215268
func (m *MemoryStore) GetOrdersByAccountID(accountID string) []*core.Order {
216269
m.RLock()
217270
defer m.RUnlock()

va/va.go

+17-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/letsencrypt/challtestsrv"
2828
"github.com/letsencrypt/pebble/v2/acme"
2929
"github.com/letsencrypt/pebble/v2/core"
30+
"github.com/letsencrypt/pebble/v2/db"
3031
)
3132

3233
const (
@@ -108,12 +109,18 @@ type VAImpl struct {
108109
strict bool
109110
customResolverAddr string
110111
dnsClient *dns.Client
112+
113+
// The VA having a DB client is indeed strange. This is only used to
114+
// facilitate va.setOrderError changing the ARI related order replacement
115+
// field on failed orders.
116+
db *db.MemoryStore
111117
}
112118

113119
func New(
114120
log *log.Logger,
115121
httpPort, tlsPort int,
116122
strict bool, customResolverAddr string,
123+
db *db.MemoryStore,
117124
) *VAImpl {
118125
va := &VAImpl{
119126
log: log,
@@ -124,6 +131,7 @@ func New(
124131
sleepTime: defaultSleepTime,
125132
strict: strict,
126133
customResolverAddr: customResolverAddr,
134+
db: db,
127135
}
128136

129137
if customResolverAddr != "" {
@@ -209,10 +217,17 @@ func (va VAImpl) setAuthzValid(authz *core.Authorization, chal *core.Challenge)
209217

210218
// setOrderError updates an order with an error from an authorization
211219
// validation.
212-
func (va VAImpl) setOrderError(order *core.Order, err *acme.ProblemDetails) {
220+
func (va VAImpl) setOrderError(order *core.Order, prob *acme.ProblemDetails) {
213221
order.Lock()
214222
defer order.Unlock()
215-
order.Error = err
223+
order.Error = prob
224+
225+
// Mark the parent order as "not replaced yet" so a new replacement order
226+
// can be attempted.
227+
err := va.db.UpdateReplacedOrder(order.Replaces, false)
228+
if err != nil {
229+
va.log.Printf("Error updating replacement order: %s", err)
230+
}
216231
}
217232

218233
// setAuthzInvalid updates an authorization and an associated challenge to be

va/va_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestAuthzRace(_ *testing.T) {
3131

3232
// This whole test can be removed if/when the MemoryStore becomes 100% by value
3333
ms := db.NewMemoryStore()
34-
va := New(log.New(os.Stdout, "Pebble/TestRace", log.LstdFlags), 14000, 15000, false, "")
34+
va := New(log.New(os.Stdout, "Pebble/TestRace", log.LstdFlags), 14000, 15000, false, "", ms)
3535

3636
authz := &core.Authorization{
3737
ID: "auth-id",

0 commit comments

Comments
 (0)