From 0abe0523af6f711c2f4c4a29a3a9a5f91aa9282e Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Fri, 21 Jun 2019 18:40:44 +0200 Subject: [PATCH] Implement and offer alternate chains (RFC 8555 7.4.2) (#234) Allows to offer alternate chains for a certificate according to RFC8555, Section 7.4.2. For this, multiple root and intermediate certs are created (all with the same intermediate private key), and `alternative` relation links are offered during certificate download. A `/roots/` endpoint has also been added to allow downloading the alternate roots (`/roots/0`, `/roots/1` etc.); Pebble also provides `link` headers for its alternate forms in responses to certificate requests. This feature can be enabled by setting the environment variable `PEBBLE_ALTERNATE_ROOTS` to something larger than 0. For example, with `PEBBLE_ALTERNATE_ROOTS=2`: ```.sh $ curl -i -k https://0.0.0.0:14000/root HTTP/2 200 cache-control: public, max-age=0, no-cache content-type: application/pem-certificate-chain; charset=utf-8 link: ;rel="index" link: ;rel="alternate" link: ;rel="alternate" content-length: 1107 date: Sun, 12 May 2019 15:05:37 GMT -----BEGIN CERTIFICATE----- ... $ curl -i -k https://0.0.0.0:14000/roots/0 HTTP/2 200 cache-control: public, max-age=0, no-cache content-type: application/pem-certificate-chain; charset=utf-8 link: ;rel="index" link: ;rel="alternate" link: ;rel="alternate" content-length: 1107 date: Sun, 12 May 2019 15:06:07 GMT -----BEGIN CERTIFICATE----- ... ``` --- README.md | 7 +++ ca/ca.go | 116 ++++++++++++++++++++++------------- cmd/pebble/main.go | 11 +++- core/types.go | 13 ++-- wfe/wfe.go | 147 ++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 230 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 4d35b55f..6e1fb710 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,13 @@ same standards as the Let's Encrypt production CA and their keys. Moreover these keys are exposed by Pebble and will be lost as soon as the process terminates: so they are not safe to use for anything other than testing.** +In case alternative root chains are enabled by setting `PEBBLE_ALTERNATE_ROOTS` to a +positive integer, the root certificates for these can be retrieved by doing a `GET` +request to `https://localhost:14000/roots/0`, `https://localhost:14000/root-keys/1` +`https://localhost:14000/intermediates/2`, `https://localhost:14000/intermediate-keys/3` +etc. These endpoints also send `Link` HTTP headers for all alternative root and +intermediate certificates and keys. + ### OCSP Responder URL Pebble does not support the OCSP protocol as a responder and so does not set diff --git a/ca/ca.go b/ca/ca.go index 613046ff..6874e511 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -29,6 +29,10 @@ type CAImpl struct { db *db.MemoryStore ocspResponderURL string + chains []*chain +} + +type chain struct { root *issuer intermediate *issuer } @@ -105,7 +109,8 @@ func (ca *CAImpl) makeRootCert( DER: der, } if signer != nil && signer.cert != nil { - newCert.Issuer = signer.cert + newCert.Issuers = make([]*core.Certificate, 1) + newCert.Issuers[0] = signer.cert } _, err = ca.db.AddCertificate(newCert) if err != nil { @@ -114,48 +119,55 @@ func (ca *CAImpl) makeRootCert( return newCert, nil } -func (ca *CAImpl) newRootIssuer() error { +func (ca *CAImpl) newRootIssuer() (*issuer, error) { // Make a root private key rk, err := makeKey() if err != nil { - return err + return nil, err } // Make a self-signed root certificate rc, err := ca.makeRootCert(rk, rootCAPrefix, nil) if err != nil { - return err + return nil, err } - ca.root = &issuer{ + ca.log.Printf("Generated new root issuer with serial %s\n", rc.ID) + return &issuer{ key: rk, cert: rc, - } - ca.log.Printf("Generated new root issuer with serial %s\n", rc.ID) - return nil + }, nil } -func (ca *CAImpl) newIntermediateIssuer() error { - if ca.root == nil { - return fmt.Errorf("newIntermediateIssuer() called before newRootIssuer()") - } - - // Make an intermediate private key - ik, err := makeKey() - if err != nil { - return err +func (ca *CAImpl) newIntermediateIssuer(root *issuer, ik crypto.Signer) (*issuer, error) { + if root == nil { + return nil, fmt.Errorf("Internal error: root must not be nil") } // Make an intermediate certificate with the root issuer - ic, err := ca.makeRootCert(ik, intermediateCAPrefix, ca.root) + ic, err := ca.makeRootCert(ik, intermediateCAPrefix, root) if err != nil { - return err + return nil, err } - ca.intermediate = &issuer{ + ca.log.Printf("Generated new intermediate issuer with serial %s\n", ic.ID) + return &issuer{ key: ik, cert: ic, + }, nil +} + +func (ca *CAImpl) newChain(ik crypto.Signer) *chain { + root, err := ca.newRootIssuer() + if err != nil { + panic(fmt.Sprintf("Error creating new root issuer: %s", err.Error())) + } + intermediate, err := ca.newIntermediateIssuer(root, ik) + if err != nil { + panic(fmt.Sprintf("Error creating new intermediate issuer: %s", err.Error())) + } + return &chain{ + root: root, + intermediate: intermediate, } - ca.log.Printf("Generated new intermediate issuer with serial %s\n", ic.ID) - return nil } func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.PublicKey, accountID string) (*core.Certificate, error) { @@ -168,7 +180,7 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ return nil, fmt.Errorf("must specify at least one domain name or IP address") } - issuer := ca.intermediate + issuer := ca.chains[0].intermediate if issuer == nil || issuer.cert == nil { return nil, fmt.Errorf("cannot sign certificate - nil issuer") } @@ -203,13 +215,18 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ return nil, err } + issuers := make([]*core.Certificate, len(ca.chains)) + for i := 0; i < len(ca.chains); i++ { + issuers[i] = ca.chains[i].intermediate.cert + } + hexSerial := hex.EncodeToString(cert.SerialNumber.Bytes()) newCert := &core.Certificate{ ID: hexSerial, AccountID: accountID, Cert: cert, DER: der, - Issuer: issuer.cert, + Issuers: issuers, } _, err = ca.db.AddCertificate(newCert) if err != nil { @@ -218,7 +235,7 @@ 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) *CAImpl { +func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string, alternateRoots int) *CAImpl { ca := &CAImpl{ log: log, db: db, @@ -229,13 +246,13 @@ func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string) *CAImpl { ca.log.Printf("Setting OCSP responder URL for issued certificates to %q", ca.ocspResponderURL) } - err := ca.newRootIssuer() + ik, err := makeKey() if err != nil { - panic(fmt.Sprintf("Error creating new root issuer: %s", err.Error())) + panic(fmt.Sprintf("Error creating new intermediate private key: %s", err.Error())) } - err = ca.newIntermediateIssuer() - if err != nil { - panic(fmt.Sprintf("Error creating new intermediate issuer: %s", err.Error())) + ca.chains = make([]*chain, 1+alternateRoots) + for i := 0; i < len(ca.chains); i++ { + ca.chains[i] = ca.newChain(ik) } return ca } @@ -279,38 +296,53 @@ func (ca *CAImpl) CompleteOrder(order *core.Order) { order.Unlock() } -func (ca *CAImpl) GetRootCert() *core.Certificate { - if ca.root == nil { +func (ca *CAImpl) GetNumberOfRootCerts() int { + return len(ca.chains) +} + +func (ca *CAImpl) getChain(no int) *chain { + if 0 <= no && no < len(ca.chains) { + return ca.chains[no] + } + return nil +} + +func (ca *CAImpl) GetRootCert(no int) *core.Certificate { + chain := ca.getChain(no) + if chain == nil { return nil } - return ca.root.cert + return chain.root.cert } -func (ca *CAImpl) GetRootKey() *rsa.PrivateKey { - if ca.root == nil { +func (ca *CAImpl) GetRootKey(no int) *rsa.PrivateKey { + chain := ca.getChain(no) + if chain == nil { return nil } - switch key := ca.root.key.(type) { + switch key := chain.root.key.(type) { case *rsa.PrivateKey: return key } return nil } -func (ca *CAImpl) GetIntermediateCert() *core.Certificate { - if ca.intermediate == nil { +func (ca *CAImpl) GetIntermediateCert(no int) *core.Certificate { + chain := ca.getChain(no) + if chain == nil { return nil } - return ca.intermediate.cert + return chain.intermediate.cert } -func (ca *CAImpl) GetIntermediateKey() *rsa.PrivateKey { - if ca.intermediate == nil { +func (ca *CAImpl) GetIntermediateKey(no int) *rsa.PrivateKey { + chain := ca.getChain(no) + if chain == nil { return nil } - switch key := ca.intermediate.key.(type) { + switch key := chain.intermediate.key.(type) { case *rsa.PrivateKey: return key } diff --git a/cmd/pebble/main.go b/cmd/pebble/main.go index aea7c288..ecb8ae55 100644 --- a/cmd/pebble/main.go +++ b/cmd/pebble/main.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "os" + "strconv" "github.com/letsencrypt/pebble/ca" "github.com/letsencrypt/pebble/cmd" @@ -57,8 +58,14 @@ func main() { setupCustomDNSResolver(*resolverAddress) } + alternateRoots := 0 + alternateRootsVal := os.Getenv("PEBBLE_ALTERNATE_ROOTS") + if val, err := strconv.ParseInt(alternateRootsVal, 10, 0); err == nil && val >= 0 { + alternateRoots = int(val) + } + db := db.NewMemoryStore() - ca := ca.New(logger, db, c.Pebble.OCSPResponderURL) + ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, alternateRoots) va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode) wfeImpl := wfe.New(logger, db, va, ca, *strictMode) @@ -67,7 +74,7 @@ func main() { logger.Printf("Listening on: %s\n", c.Pebble.ListenAddress) logger.Printf("ACME directory available at: https://%s%s", c.Pebble.ListenAddress, wfe.DirectoryPath) - logger.Printf("Root CA certificate available at: https://%s%s", + logger.Printf("Root CA certificate(s) available at: https://%s%s", c.Pebble.ListenAddress, wfe.RootCertPath) err = http.ListenAndServeTLS( c.Pebble.ListenAddress, diff --git a/core/types.go b/core/types.go index a90c9c68..678cdb46 100644 --- a/core/types.go +++ b/core/types.go @@ -148,7 +148,7 @@ type Certificate struct { ID string Cert *x509.Certificate DER []byte - Issuer *Certificate + Issuers []*Certificate AccountID string } @@ -167,22 +167,25 @@ func (c Certificate) PEM() []byte { return buf.Bytes() } -func (c Certificate) Chain() []byte { +func (c Certificate) Chain(no int) []byte { chain := make([][]byte, 0) // Add the leaf certificate chain = append(chain, c.PEM()) // Add zero or more issuers - issuer := c.Issuer + var issuer *Certificate + if 0 <= no && no < len(c.Issuers) { + issuer = c.Issuers[no] + } for { // if the issuer is nil, or the issuer's issuer is nil then we've reached // the root of the chain and can break - if issuer == nil || issuer.Issuer == nil { + if issuer == nil || len(issuer.Issuers) == 0 { break } chain = append(chain, issuer.PEM()) - issuer = issuer.Issuer + issuer = issuer.Issuers[0] } // Return the chain, leaf cert first diff --git a/wfe/wfe.go b/wfe/wfe.go index 0d1818f3..380f91a1 100644 --- a/wfe/wfe.go +++ b/wfe/wfe.go @@ -54,10 +54,10 @@ const ( // 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. - RootCertPath = "/root" - rootKeyPath = "/root-key" - intermediateCertPath = "/intermediate" - intermediateKeyPath = "/intermediate-key" + RootCertPath = "/roots/" + rootKeyPath = "/root-keys/" + intermediateCertPath = "/intermediates/" + intermediateKeyPath = "/intermediate-keys/" // How long do pending authorizations last before expiring? pendingAuthzExpire = time.Hour @@ -232,17 +232,41 @@ func (wfe *WebFrontEndImpl) sendError(prob *acme.ProblemDetails, response http.R _, _ = response.Write(problemDoc) } +type certGetter func(no int) *core.Certificate +type keyGetter func(no int) *rsa.PrivateKey + func (wfe *WebFrontEndImpl) handleCert( - cert *core.Certificate) func( + certGet certGetter, + relPath string) func( ctx context.Context, response http.ResponseWriter, request *http.Request) { return func(ctx context.Context, response http.ResponseWriter, request *http.Request) { + // Check for parameter + no, err := strconv.Atoi(request.URL.Path) + if err != nil { + response.WriteHeader(http.StatusNotFound) + return + } + + // Get hold of root certificate + cert := certGet(no) if cert == nil { - response.WriteHeader(http.StatusServiceUnavailable) + response.WriteHeader(http.StatusNotFound) return } + // Add links to alternate roots + basePath := wfe.relativeEndpoint(request, relPath) + for i := 0; i < wfe.ca.GetNumberOfRootCerts(); i++ { + if no == i { + continue + } + path := fmt.Sprintf("%s%d", basePath, i) + response.Header().Add("Link", link(path, "alternate")) + } + + // Write main response response.Header().Set("Content-Type", "application/pem-certificate-chain; charset=utf-8") response.WriteHeader(http.StatusOK) _, _ = response.Write(cert.PEM()) @@ -250,19 +274,40 @@ func (wfe *WebFrontEndImpl) handleCert( } func (wfe *WebFrontEndImpl) handleKey( - key *rsa.PrivateKey) func( + keyGet keyGetter, + relPath string) func( ctx context.Context, response http.ResponseWriter, request *http.Request) { return func(ctx context.Context, response http.ResponseWriter, request *http.Request) { + // Check for parameter + no, err := strconv.Atoi(request.URL.Path) + if err != nil { + response.WriteHeader(http.StatusNotFound) + return + } + + // Get hold of root certificate's key + key := keyGet(no) if key == nil { - response.WriteHeader(http.StatusServiceUnavailable) + response.WriteHeader(http.StatusNotFound) return } + // Add links to alternate root keys + basePath := wfe.relativeEndpoint(request, relPath) + for i := 0; i < wfe.ca.GetNumberOfRootCerts(); i++ { + if no == i { + continue + } + path := fmt.Sprintf("%s%d", basePath, i) + response.Header().Add("Link", link(path, "alternate")) + } + + // Write main response var buf bytes.Buffer - err := pem.Encode(&buf, &pem.Block{ + err = pem.Encode(&buf, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), }) @@ -277,16 +322,32 @@ func (wfe *WebFrontEndImpl) handleKey( } } +func (wfe *WebFrontEndImpl) handleRedirect( + relPath string) func( + ctx context.Context, + response http.ResponseWriter, + request *http.Request) { + return func(ctx context.Context, response http.ResponseWriter, request *http.Request) { + response.Header().Set("Location", wfe.relativeEndpoint(request, relPath)) + response.WriteHeader(http.StatusMovedPermanently) + _, _ = response.Write([]byte("Please update your URLs!\n")) + } +} + func (wfe *WebFrontEndImpl) Handler() http.Handler { m := http.NewServeMux() // GET only handlers wfe.HandleFunc(m, DirectoryPath, wfe.Directory, "GET") // Note for noncePath: "GET" also implies "HEAD" wfe.HandleFunc(m, noncePath, wfe.Nonce, "GET") - wfe.HandleFunc(m, RootCertPath, wfe.handleCert(wfe.ca.GetRootCert()), "GET") - wfe.HandleFunc(m, rootKeyPath, wfe.handleKey(wfe.ca.GetRootKey()), "GET") - wfe.HandleFunc(m, intermediateCertPath, wfe.handleCert(wfe.ca.GetIntermediateCert()), "GET") - wfe.HandleFunc(m, intermediateKeyPath, wfe.handleKey(wfe.ca.GetIntermediateKey()), "GET") + wfe.HandleFunc(m, RootCertPath, wfe.handleCert(wfe.ca.GetRootCert, RootCertPath), "GET") + wfe.HandleFunc(m, rootKeyPath, wfe.handleKey(wfe.ca.GetRootKey, rootKeyPath), "GET") + wfe.HandleFunc(m, intermediateCertPath, wfe.handleCert(wfe.ca.GetIntermediateCert, intermediateCertPath), "GET") + wfe.HandleFunc(m, intermediateKeyPath, wfe.handleKey(wfe.ca.GetIntermediateKey, intermediateKeyPath), "GET") + wfe.HandleFunc(m, "/root", wfe.handleRedirect(RootCertPath+"0"), "GET") + wfe.HandleFunc(m, "/root-key", wfe.handleRedirect(rootKeyPath+"0"), "GET") + wfe.HandleFunc(m, "/intermediate", wfe.handleRedirect(intermediateCertPath+"0"), "GET") + wfe.HandleFunc(m, "/intermediate-key", wfe.handleRedirect(intermediateKeyPath+"0"), "GET") // POST only handlers wfe.HandleFunc(m, newAccountPath, wfe.NewAccount, "POST") @@ -1951,6 +2012,48 @@ func (wfe *WebFrontEndImpl) updateChallenge( } } +// Parse the URL to extract alternate number (if available, default 0). Returns the +// remaining URL, the number (0 or larger), and an error (or nil for success). +// +// If the URL contains "/alternate/", everything following that will be interpreted as +// the number. If it cannot be parsed as an integer, or the number is negative, an error +// will be returned. The remaining URL is everything before "/alternate/". +func getAlternateNo(url string) (string, int, error) { + urlSplit := strings.SplitN(url, "/alternate/", 2) + if len(urlSplit) == 0 { + // URL is the empty string: return + return url, 0, nil + } + if len(urlSplit) == 1 { + // URL does not contain "/alternate/". + return url, 0, nil + } + no, err := strconv.Atoi(urlSplit[1]) + if err != nil { + return url, 0, err + } + if no < 0 { + return url, 0, fmt.Errorf("number is negative") + } + return urlSplit[0], no, nil +} + +// Adds HTTP Link headers for alternate versions of the resource. To the given +// URL, "/alternate/" will be added as the address of the alternative. Will +// add links to all alternatives from 0 up to number-1 except for no. +func addAlternateLinks(response http.ResponseWriter, url string, no int, number int) { + if no != 0 { + response.Header().Add("Link", link(url, "alternate")) + } + for i := 1; i < number; i++ { + if no == i { + continue + } + path := fmt.Sprintf("%s/alternate/%d", url, i) + response.Header().Add("Link", link(path, "alternate")) + } +} + func (wfe *WebFrontEndImpl) Certificate( ctx context.Context, response http.ResponseWriter, @@ -1966,7 +2069,12 @@ func (wfe *WebFrontEndImpl) Certificate( return } - serial := strings.TrimPrefix(request.URL.Path, certPath) + serialAlt := strings.TrimPrefix(request.URL.Path, certPath) + serial, no, err := getAlternateNo(serialAlt) + if err != nil { + response.WriteHeader(http.StatusNotFound) + return + } cert := wfe.db.GetCertificateByID(serial) if cert == nil { response.WriteHeader(http.StatusNotFound) @@ -1980,9 +2088,18 @@ func (wfe *WebFrontEndImpl) Certificate( return } + if no >= len(cert.Issuers) { + response.WriteHeader(http.StatusNotFound) + return + } + + // Add links to alternate roots + basePath := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", certPath, serial)) + addAlternateLinks(response, basePath, no, len(cert.Issuers)) + response.Header().Set("Content-Type", "application/pem-certificate-chain; charset=utf-8") response.WriteHeader(http.StatusOK) - _, _ = response.Write(cert.Chain()) + _, _ = response.Write(cert.Chain(no)) } func (wfe *WebFrontEndImpl) writeJSONResponse(response http.ResponseWriter, status int, v interface{}) error {