From e08dd94e723a0e8d005d7c6149a8666e4bf5d877 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Thu, 22 Aug 2024 08:57:20 -0700 Subject: [PATCH] Add support for ACME Profiles (#473) Fixes https://github.com/letsencrypt/pebble/issues/471 --- .golangci.yaml | 2 +- acme/common.go | 1 + ca/ca.go | 49 +++++++++++++++++++++++----------- ca/ca_test.go | 3 ++- cmd/pebble/main.go | 19 ++++++++++--- go.mod | 2 +- test/config/pebble-config.json | 11 +++++++- wfe/wfe.go | 20 ++++++++++++++ 8 files changed, 85 insertions(+), 22 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 8f8d6d67..edb90a44 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -14,6 +14,7 @@ linters: - bidichk - bodyclose - containedctx + - copyloopvar - decorder - dogsled - dupword @@ -21,7 +22,6 @@ linters: - errcheck - errchkjson - errorlint - - exportloopref - forcetypeassert - ginkgolinter - gocheckcompilerdirectives diff --git a/acme/common.go b/acme/common.go index 47a58ace..a97622fd 100644 --- a/acme/common.go +++ b/acme/common.go @@ -54,6 +54,7 @@ type Order struct { Error *ProblemDetails `json:"error,omitempty"` Expires string `json:"expires"` Identifiers []Identifier `json:"identifiers,omitempty"` + Profile string `json:"profile,omitempty"` Finalize string `json:"finalize"` NotBefore string `json:"notBefore,omitempty"` NotAfter string `json:"notAfter,omitempty"` diff --git a/ca/ca.go b/ca/ca.go index 6e0543f6..42200624 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -27,7 +27,7 @@ import ( const ( rootCAPrefix = "Pebble Root CA " intermediateCAPrefix = "Pebble Intermediate CA " - defaultValidityPeriod = 157766400 + defaultValidityPeriod = 7776000 ) type CAImpl struct { @@ -35,9 +35,8 @@ type CAImpl struct { db *db.MemoryStore ocspResponderURL string - chains []*chain - - certValidityPeriod uint64 + chains []*chain + profiles map[string]*Profile } type chain struct { @@ -45,6 +44,11 @@ type chain struct { intermediates []*issuer } +type Profile struct { + Description string + ValidityPeriod uint64 +} + func (c *chain) String() string { fullchain := append(c.intermediates, c.root) n := len(fullchain) @@ -253,7 +257,7 @@ func (ca *CAImpl) newChain(intermediateKey crypto.Signer, intermediateSubject pk return c } -func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.PublicKey, accountID, notBefore, notAfter string, extensions []pkix.Extension) (*core.Certificate, error) { +func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.PublicKey, accountID, notBefore, notAfter, profileName string, extensions []pkix.Extension) (*core.Certificate, error) { if len(domains) == 0 && len(ips) == 0 { return nil, errors.New("must specify at least one domain name or IP address") } @@ -264,6 +268,11 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ } issuer := defaultChain[0] + prof, ok := ca.profiles[profileName] + if !ok { + return nil, fmt.Errorf("unrecgonized profile name %q", profileName) + } + certNotBefore := time.Now() var err error if notBefore != "" { @@ -273,7 +282,7 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ } } - certNotAfter := certNotBefore.Add(time.Duration(ca.certValidityPeriod-1) * time.Second) + certNotAfter := certNotBefore.Add(time.Duration(prof.ValidityPeriod-1) * time.Second) maxNotAfter := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) if certNotAfter.After(maxNotAfter) { certNotAfter = maxNotAfter @@ -337,11 +346,11 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ return newCert, nil } -func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string, alternateRoots int, chainLength int, certificateValidityPeriod uint64) *CAImpl { +func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string, alternateRoots int, chainLength int, profiles map[string]Profile) *CAImpl { ca := &CAImpl{ - log: log, - db: db, - certValidityPeriod: defaultValidityPeriod, + log: log, + db: db, + profiles: make(map[string]*Profile, len(profiles)), } if ocspResponderURL != "" { @@ -361,12 +370,14 @@ func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string, alternate ca.chains[i] = ca.newChain(intermediateKey, intermediateSubject, subjectKeyID, chainLength) } - if certificateValidityPeriod != 0 && certificateValidityPeriod < 9223372038 { - ca.certValidityPeriod = certificateValidityPeriod + for name, prof := range profiles { + if prof.ValidityPeriod <= 0 || prof.ValidityPeriod >= 9223372038 { + prof.ValidityPeriod = defaultValidityPeriod + } + ca.profiles[name] = &prof + ca.log.Printf("Loaded profile %q with certificate validity period of %d seconds", name, prof.ValidityPeriod) } - ca.log.Printf("Using certificate validity period of %d seconds", ca.certValidityPeriod) - return ca } @@ -420,7 +431,7 @@ func (ca *CAImpl) CompleteOrder(order *core.Order) { // issue a certificate for the csr csr := order.ParsedCSR - cert, err := ca.newCertificate(csr.DNSNames, csr.IPAddresses, csr.PublicKey, order.AccountID, order.NotBefore, order.NotAfter, extensions) + cert, err := ca.newCertificate(csr.DNSNames, csr.IPAddresses, csr.PublicKey, order.AccountID, order.NotBefore, order.NotAfter, order.Profile, extensions) if err != nil { ca.log.Printf("Error: unable to issue order: %s", err.Error()) return @@ -506,3 +517,11 @@ func (ca *CAImpl) GetIntermediateKey(no int) *rsa.PrivateKey { } return nil } + +func (ca *CAImpl) GetProfiles() map[string]string { + res := make(map[string]string, len(ca.profiles)) + for name, prof := range ca.profiles { + res[name] = prof.Description + } + return res +} diff --git a/ca/ca_test.go b/ca/ca_test.go index 17d03470..93ac2129 100644 --- a/ca/ca_test.go +++ b/ca/ca_test.go @@ -27,7 +27,7 @@ var ( func makeCa() *CAImpl { logger := log.New(os.Stdout, "Pebble ", log.LstdFlags) db := db.NewMemoryStore() - return New(logger, db, "", 0, 1, 0) + return New(logger, db, "", 0, 1, map[string]Profile{"default": {}}) } func makeCertOrderWithExtensions(extensions []pkix.Extension) core.Order { @@ -50,6 +50,7 @@ func makeCertOrderWithExtensions(extensions []pkix.Extension) core.Order { Status: acme.StatusPending, Expires: time.Now().AddDate(0, 0, 1).UTC().Format(time.RFC3339), Identifiers: []acme.Identifier{}, + Profile: "default", NotBefore: time.Now().UTC().Format(time.RFC3339), NotAfter: time.Now().AddDate(30, 0, 0).UTC().Format(time.RFC3339), }, diff --git a/cmd/pebble/main.go b/cmd/pebble/main.go index 3083659b..40e6e6f6 100644 --- a/cmd/pebble/main.go +++ b/cmd/pebble/main.go @@ -32,12 +32,15 @@ type config struct { ExternalAccountMACKeys map[string]string // Configure policies to deny certain domains DomainBlocklist []string + Profiles map[string]ca.Profile - CertificateValidityPeriod uint64 - RetryAfter struct { + RetryAfter struct { Authz int Order int } + + // Deprecated: use Profiles.ValidityPeriod instead + CertificateValidityPeriod uint64 } } @@ -100,8 +103,18 @@ func main() { chainLength = int(val) } + profiles := c.Pebble.Profiles + if len(profiles) == 0 { + profiles = map[string]ca.Profile{ + "default": { + Description: "The default profile", + ValidityPeriod: 0, // Will be overridden by the CA's default + }, + } + } + db := db.NewMemoryStore() - ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, alternateRoots, chainLength, c.Pebble.CertificateValidityPeriod) + ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, alternateRoots, chainLength, profiles) va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress, db) for keyID, key := range c.Pebble.ExternalAccountMACKeys { diff --git a/go.mod b/go.mod index e0745451..251c2b75 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/letsencrypt/pebble/v2 -go 1.21 +go 1.22 require ( github.com/go-jose/go-jose/v4 v4.0.1 diff --git a/test/config/pebble-config.json b/test/config/pebble-config.json index 3605a116..6fb1363a 100644 --- a/test/config/pebble-config.json +++ b/test/config/pebble-config.json @@ -13,6 +13,15 @@ "authz": 3, "order": 5 }, - "certificateValidityPeriod": 157766400 + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } } } diff --git a/wfe/wfe.go b/wfe/wfe.go index 264fd014..6929a1d4 100644 --- a/wfe/wfe.go +++ b/wfe/wfe.go @@ -599,6 +599,7 @@ func (wfe *WebFrontEndImpl) relativeDirectory(request *http.Request, directory m relativeDir["meta"] = map[string]interface{}{ "termsOfService": ToSURL, "externalAccountRequired": wfe.requireEAB, + "profiles": wfe.ca.GetProfiles(), } directoryJSON, err := marshalIndent(relativeDir) @@ -1724,6 +1725,24 @@ func (wfe *WebFrontEndImpl) NewOrder( return } + profiles := wfe.ca.GetProfiles() + profileName := newOrder.Profile + if profileName == "" { + // In true pebble chaos fashion, pick a random profile for orders that + // don't specify one. + profNames := make([]string, 0, len(profiles)) + for name := range profiles { + profNames = append(profNames, name) + } + profileName = profNames[rand.Intn(len(profiles))] + } + _, ok := profiles[profileName] + if !ok { + wfe.sendError( + acme.MalformedProblem(fmt.Sprintf("Order includes unrecognized profile name %q", profileName)), response) + return + } + var orderDNSs []string var orderIPs []net.IP for _, ident := range newOrder.Identifiers { @@ -1754,6 +1773,7 @@ func (wfe *WebFrontEndImpl) NewOrder( Order: acme.Order{ Status: acme.StatusPending, Expires: expires.UTC().Format(time.RFC3339), + Profile: profileName, // Only the Identifiers, NotBefore and NotAfter from the submitted order // are carried forward Identifiers: uniquenames,