diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfc3ff3d..41cb5e9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: matrix: include: - app: pebble - expose_ports: "14000 15000" + expose_ports: "14000 15000 4003" - app: pebble-challtestsrv expose_ports: "5001 5002 5003 8053 8055 8443" permissions: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f441fc5b..9505bcb9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,8 +33,8 @@ jobs: - name: go install commands run: go install -v -race ./cmd/... - name: launch pebble - run: GORACE="halt_on_error=1" PEBBLE_WFE_NONCEREJECT=0 pebble & - # Perform a test issuance with chisel2.py + run: GORACE="halt_on_error=1" PEBBLE_WFE_NONCEREJECT=0 PEBBLE_CRL_MAX_DELAY=0 pebble -config test/config/pebble-config-crl.json & + # Perform a test issuance and revocation with chisel2.py - name: chisel run: REQUESTS_CA_BUNDLE=./test/certs/pebble.minica.pem python ./test/chisel2.py example.letsencrypt.org elpmaxe.letsencrypt.org eggsampler-linux: diff --git a/README.md b/README.md index 25817e08..e8a8dbae 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,8 @@ Pebble does not currently reuse Pending Authorizations across Orders, however ot ### Avoiding Client HTTPS Errors -Pebble is accessible over HTTPS only and uses a [test +Pebble's ACME API and management interface are accessible over HTTPS only +(the optional [CRL listener](#crls) is plain HTTP). They use a [test certificate](test/certs/localhost/cert.pem) generated using a [test CA](test/certs/pebble.minica.pem) (See [the `test/certs/` directory](test/certs/README.md) for more information). @@ -400,6 +401,54 @@ to retrieve the OCSP status of a certificate, run Pebble with a `pebble-config.j "ocspResponderURL": "http://127.0.0.1:4002", ``` +### CRLs + +Pebble can serve a CRL over plain HTTP. This is disabled by default. To enable +it, set both `crlListenAddress` and `crlBaseURL` in the config file (see +[`test/config/pebble-config-crl.json`](test/config/pebble-config-crl.json)), or +set the matching environment variables. Issued certificates then include a CRL +Distribution Point (CRLDP) pointing at the CRL. + +| Config field | Environment variable | Default | Meaning | +|---|---|---|---| +| `crlListenAddress` | `PEBBLE_CRL_LISTEN_ADDRESS` | none | Listen address, e.g. `0.0.0.0:4003` | +| `crlBaseURL` | `PEBBLE_CRL_BASE_URL` | none | `http://` URL clients use to reach the listener, e.g. `http://localhost:4003/` | +| `crlMaxDelay` | `PEBBLE_CRL_MAX_DELAY` | `15` | Maximum random delay, in seconds, before a revocation appears on the CRL. `0` disables the delay | +| `crlValidity` | `PEBBLE_CRL_VALIDITY` | `604800` (7 days) | Seconds from `thisUpdate` to `nextUpdate`, capped at 10 days | + +Non-empty environment variables override config fields. + +The CRL URL includes the Subject Key Identifier of the leaf-issuing +intermediate, which changes every time Pebble starts. Read the URL from a +certificate's CRLDP or from Pebble's startup log instead of hardcoding it. + +Details: + +- There is one CRL, covering leaf certificates. It is signed by the + leaf-issuing intermediate on every request. +- The random delay keeps clients from relying on instant CRL updates. The + [certificate status](#certificate-status) endpoint and ARI reflect a + revocation immediately. +- Revoked certificates stay on the CRL, even after they expire. +- Entries with reason 0 (`unspecified`) have no `reasonCode` extension. +- Root CRLs, intermediate CRLs, and sharding are not supported. + +Whether or not CRLs are enabled, Pebble accepts only revocation reasons 0, 1, +3, 4, 5 and 9, and CA certificates include the `cRLSign` key usage. + +To enable CRLs with Docker: + +``` +docker run -p 14000:14000 -p 15000:15000 -p 4003:4003 \ + -e PEBBLE_CRL_LISTEN_ADDRESS=0.0.0.0:4003 \ + -e PEBBLE_CRL_BASE_URL=http://localhost:4003/ \ + ghcr.io/letsencrypt/pebble +``` + +A `localhost` base URL only works for clients on the host. For clients in other +containers, use an address they can reach, as in the commented-out CRL settings +in [`docker-compose.yml`](docker-compose.yml). + ### Listing orders Pebble has support for enumerating all orders for an ACME account object according to diff --git a/cmd/pebble/main.go b/cmd/pebble/main.go index f7f4b44a..72e7e0bf 100644 --- a/cmd/pebble/main.go +++ b/cmd/pebble/main.go @@ -1,14 +1,17 @@ package main import ( + "errors" "flag" "fmt" "log" "net/http" + "net/url" "os" "slices" "strconv" "strings" + "time" "github.com/letsencrypt/pebble/v2/ca" "github.com/letsencrypt/pebble/v2/cmd" @@ -19,6 +22,13 @@ import ( var version = "dev" // Default value, to be overridden with ldflags +// CRL settings, in seconds. +const ( + defaultCRLMaxDelay = 15 + defaultCRLValidity = 7 * 24 * 60 * 60 + maxCRLValidity = 10 * 24 * 60 * 60 +) + type config struct { Pebble struct { ListenAddress string @@ -28,6 +38,15 @@ type config struct { Certificate string PrivateKey string OCSPResponderURL string + // Optional CRL support, enabled only when both CRLListenAddress and + // CRLBaseURL are set. + CRLListenAddress string + CRLBaseURL string + // Maximum random delay in seconds before a revocation appears on the + // CRL. Unset means defaultCRLMaxDelay, and 0 means no delay. + CRLMaxDelay *int + // Seconds from thisUpdate to nextUpdate. 0 means defaultCRLValidity. + CRLValidity int // Require External Account Binding for "newAccount" requests ExternalAccountBindingRequired bool ExternalAccountMACKeys map[string]string @@ -125,8 +144,11 @@ func main() { } } + crlConfig, err := loadCRLConfig(&c, logger) + cmd.FailOnError(err, "Invalid CRL configuration") + db := db.NewMemoryStore() - ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, keyAlg, alternateRoots, chainLength, profiles, nil) + ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, keyAlg, alternateRoots, chainLength, profiles, crlConfig) va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress, db) for keyID, key := range c.Pebble.ExternalAccountMACKeys { @@ -168,6 +190,17 @@ func main() { logger.Print("Management interface is disabled") } + if crlConfig != nil { + go func() { + err := http.ListenAndServe(c.Pebble.CRLListenAddress, wfeImpl.CRLHandler()) + cmd.FailOnError(err, "Calling ListenAndServe() for CRL interface") + }() + logger.Printf("CRL interface listening on: %s\n", c.Pebble.CRLListenAddress) + logger.Printf("CRL available at: %s", ca.CRLURL()) + } else { + logger.Print("CRL interface is disabled") + } + logger.Printf("Listening on: %s\n", c.Pebble.ListenAddress) logger.Printf("ACME directory available at: https://%s%s", c.Pebble.ListenAddress, wfe.DirectoryPath) @@ -178,3 +211,84 @@ func main() { muxHandler) cmd.FailOnError(err, "Calling ListenAndServeTLS()") } + +// envInt returns the value of an integer environment variable, or nil if it's +// unset or not an integer. A non-integer value is logged. +func envInt(name string, logger *log.Logger) *int { + val, ok := os.LookupEnv(name) + if !ok { + return nil + } + parsed, err := strconv.ParseInt(val, 10, 0) + if err != nil { + logger.Printf("Ignoring %s=%q: not an integer", name, val) + return nil + } + i := int(parsed) + return &i +} + +// loadCRLConfig combines the CRL config fields with their PEBBLE_CRL_* +// environment overrides, validates them, and returns the CA's CRL +// configuration, or nil if CRLs are disabled. It also writes the effective +// listen address back to c.Pebble.CRLListenAddress. +func loadCRLConfig(c *config, logger *log.Logger) (*ca.CRLConfig, error) { + if val := os.Getenv("PEBBLE_CRL_LISTEN_ADDRESS"); val != "" { + c.Pebble.CRLListenAddress = val + } + if val := os.Getenv("PEBBLE_CRL_BASE_URL"); val != "" { + c.Pebble.CRLBaseURL = val + } + + maxDelay := defaultCRLMaxDelay + if c.Pebble.CRLMaxDelay != nil { + maxDelay = *c.Pebble.CRLMaxDelay + } + if val := envInt("PEBBLE_CRL_MAX_DELAY", logger); val != nil { + maxDelay = *val + } + + validity := c.Pebble.CRLValidity + if val := envInt("PEBBLE_CRL_VALIDITY", logger); val != nil { + validity = *val + } + + listen, base := c.Pebble.CRLListenAddress, c.Pebble.CRLBaseURL + if listen == "" && base == "" { + return nil, nil + } + if listen == "" || base == "" { + return nil, errors.New("crlListenAddress and crlBaseURL must be set together") + } + + u, err := url.Parse(base) + if err != nil { + return nil, fmt.Errorf("parsing crlBaseURL %q: %w", base, err) + } + if u.Scheme != "http" || u.Host == "" { + return nil, fmt.Errorf("crlBaseURL %q must be an absolute http:// URL", base) + } + if !strings.HasSuffix(base, "/") { + base += "/" + } + + if maxDelay < 0 { + return nil, fmt.Errorf("crlMaxDelay must not be negative: %d", maxDelay) + } + + switch { + case validity < 0: + return nil, fmt.Errorf("crlValidity must not be negative: %d", validity) + case validity == 0: + validity = defaultCRLValidity + case validity > maxCRLValidity: + logger.Printf("crlValidity of %d seconds exceeds 10 days, using %d", validity, maxCRLValidity) + validity = maxCRLValidity + } + + return &ca.CRLConfig{ + BaseURL: base, + MaxDelay: int64(maxDelay), + Validity: time.Duration(validity) * time.Second, + }, nil +} diff --git a/cmd/pebble/main_test.go b/cmd/pebble/main_test.go new file mode 100644 index 00000000..a64d2110 --- /dev/null +++ b/cmd/pebble/main_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "io" + "log" + "os" + "testing" + "time" + + "github.com/letsencrypt/pebble/v2/ca" +) + +func intPtr(i int) *int { return &i } + +func TestLoadCRLConfig(t *testing.T) { + envVars := []string{ + "PEBBLE_CRL_LISTEN_ADDRESS", + "PEBBLE_CRL_BASE_URL", + "PEBBLE_CRL_MAX_DELAY", + "PEBBLE_CRL_VALIDITY", + } + const ( + listen = "0.0.0.0:4003" + base = "http://localhost:4003/" + ) + defaultValidity := defaultCRLValidity * time.Second + + testCases := []struct { + name string + listen string + base string + maxDelay *int + validity int + env map[string]string + want *ca.CRLConfig + wantListen string + wantErr bool + }{ + { + name: "disabled by default", + }, + { + name: "enabled with defaults", + listen: listen, + base: base, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: defaultCRLMaxDelay, Validity: defaultValidity}, + wantListen: listen, + }, + { + name: "enabled by env only", + env: map[string]string{"PEBBLE_CRL_LISTEN_ADDRESS": listen, "PEBBLE_CRL_BASE_URL": base}, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: defaultCRLMaxDelay, Validity: defaultValidity}, + wantListen: listen, + }, + { + name: "env overrides config", + listen: "127.0.0.1:1", + base: "http://example.com/", + maxDelay: intPtr(7), + validity: 3600, + env: map[string]string{ + "PEBBLE_CRL_LISTEN_ADDRESS": listen, + "PEBBLE_CRL_BASE_URL": base, + "PEBBLE_CRL_MAX_DELAY": "0", + "PEBBLE_CRL_VALIDITY": "7200", + }, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: 0, Validity: 2 * time.Hour}, + wantListen: listen, + }, + { + name: "empty env strings don't disable CRLs", + listen: listen, + base: base, + env: map[string]string{"PEBBLE_CRL_LISTEN_ADDRESS": "", "PEBBLE_CRL_BASE_URL": ""}, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: defaultCRLMaxDelay, Validity: defaultValidity}, + wantListen: listen, + }, + { + name: "non-integer env values are ignored", + listen: listen, + base: base, + maxDelay: intPtr(3), + validity: 3600, + env: map[string]string{"PEBBLE_CRL_MAX_DELAY": "soon", "PEBBLE_CRL_VALIDITY": "long"}, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: 3, Validity: time.Hour}, + wantListen: listen, + }, + { + name: "only listen address", + listen: listen, + wantErr: true, + }, + { + name: "only base URL", + base: base, + wantErr: true, + }, + { + name: "only base URL from env", + env: map[string]string{"PEBBLE_CRL_BASE_URL": base}, + wantErr: true, + }, + { + name: "https base URL", + listen: listen, + base: "https://localhost:4003/", + wantErr: true, + }, + { + name: "relative base URL", + listen: listen, + base: "/crls/", + wantErr: true, + }, + { + name: "trailing slash added", + listen: listen, + base: "http://localhost:4003/crls", + want: &ca.CRLConfig{BaseURL: "http://localhost:4003/crls/", MaxDelay: defaultCRLMaxDelay, Validity: defaultValidity}, + wantListen: listen, + }, + { + name: "validity over 10 days is clamped", + listen: listen, + base: base, + validity: maxCRLValidity + 1, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: defaultCRLMaxDelay, Validity: maxCRLValidity * time.Second}, + wantListen: listen, + }, + { + name: "validity 0 from env uses default", + listen: listen, + base: base, + validity: 3600, + env: map[string]string{"PEBBLE_CRL_VALIDITY": "0"}, + want: &ca.CRLConfig{BaseURL: base, MaxDelay: defaultCRLMaxDelay, Validity: defaultValidity}, + wantListen: listen, + }, + { + name: "negative validity", + listen: listen, + base: base, + validity: -1, + wantErr: true, + }, + { + name: "negative validity from env", + listen: listen, + base: base, + env: map[string]string{"PEBBLE_CRL_VALIDITY": "-1"}, + wantErr: true, + }, + { + name: "negative max delay", + listen: listen, + base: base, + maxDelay: intPtr(-1), + wantErr: true, + }, + { + name: "negative max delay from env", + listen: listen, + base: base, + env: map[string]string{"PEBBLE_CRL_MAX_DELAY": "-5"}, + wantErr: true, + }, + { + name: "negative values ignored with CRLs disabled", + env: map[string]string{"PEBBLE_CRL_MAX_DELAY": "-1", "PEBBLE_CRL_VALIDITY": "-1"}, + }, + { + name: "explicit zero max delay", + listen: listen, + base: base, + maxDelay: intPtr(0), + want: &ca.CRLConfig{BaseURL: base, MaxDelay: 0, Validity: defaultValidity}, + wantListen: listen, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, name := range envVars { + // t.Setenv registers the original value for restoring after + // the test, even if the variable is then unset. + t.Setenv(name, "") + if val, ok := tc.env[name]; ok { + t.Setenv(name, val) + } else { + _ = os.Unsetenv(name) + } + } + + var c config + c.Pebble.CRLListenAddress = tc.listen + c.Pebble.CRLBaseURL = tc.base + c.Pebble.CRLMaxDelay = tc.maxDelay + c.Pebble.CRLValidity = tc.validity + + got, err := loadCRLConfig(&c, log.New(io.Discard, "", 0)) + if tc.wantErr { + if err == nil { + t.Fatalf("loadCRLConfig() = %+v, want an error", got) + } + return + } + if err != nil { + t.Fatalf("loadCRLConfig() returned error: %s", err) + } + + switch { + case tc.want == nil && got != nil: + t.Errorf("loadCRLConfig() = %+v, want nil", got) + case tc.want != nil && got == nil: + t.Errorf("loadCRLConfig() = nil, want %+v", tc.want) + case tc.want != nil && *got != *tc.want: + t.Errorf("loadCRLConfig() = %+v, want %+v", got, tc.want) + } + if tc.want != nil && c.Pebble.CRLListenAddress != tc.wantListen { + t.Errorf("effective listen address = %q, want %q", c.Pebble.CRLListenAddress, tc.wantListen) + } + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 93afa6e7..414ae992 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,11 @@ services: ports: - 14000:14000 # HTTPS ACME API - 15000:15000 # HTTPS Management API + # - 4003:4003 # HTTP CRLs + # To enable CRLs, uncomment this block and the 4003 port above. + # environment: + # - PEBBLE_CRL_LISTEN_ADDRESS=0.0.0.0:4003 + # - PEBBLE_CRL_BASE_URL=http://10.30.50.2:4003/ networks: acmenet: ipv4_address: 10.30.50.2 diff --git a/test/chisel2.py b/test/chisel2.py index e98f5487..b332c2a5 100644 --- a/test/chisel2.py +++ b/test/chisel2.py @@ -1,7 +1,14 @@ """ -A simple client that uses the Python ACME library to run a test issuance against -a local Pebble server. Unlike chisel.py this version implements the most recent -version of the ACME specification. Usage: +A simple client that uses the Python ACME library to run a test issuance and +revocation against a local Pebble server, and checks Pebble's CRL. Unlike +chisel.py this version implements the most recent version of the ACME +specification. + +Pebble must have CRLs enabled, preferably with no delay: + +$ PEBBLE_CRL_MAX_DELAY=0 pebble -config test/config/pebble-config-crl.json + +Usage: $ virtualenv venv $ . venv/bin/activate @@ -42,6 +49,10 @@ ACCEPTABLE_TOS = os.getenv('ACCEPTABLE_TOS',"data:text/plain,Do%20what%20thou%20wilt") PORT = os.getenv('PORT', '5002') +# How long to wait for a revocation to appear on the CRL. Pebble delays CRL +# entries by up to PEBBLE_CRL_MAX_DELAY seconds (15 by default). +CRL_WAIT = float(os.getenv('CRL_WAIT', '20')) + # URLs to control dns-test-srv SET_TXT = "http://localhost:8055/set-txt" CLEAR_TXT = "http://localhost:8055/clear-txt" @@ -197,6 +208,62 @@ def expect_problem(problem_type, func): if not ok: raise Exception('Expected %s, got no error' % problem_type) +def load_cert(order): + """Return the leaf and issuer certificates from a finalized order.""" + certs = x509.load_pem_x509_certificates(order.fullchain_pem.encode()) + return certs[0], certs[1] + +def revoke(client, cert, reason): + """Revoke cert, a cryptography x509.Certificate, with the given reason.""" + if hasattr(josepy, 'ComparableX509'): + # Older acme releases (with josepy < 2) take a pyOpenSSL certificate + # wrapped in josepy.ComparableX509. + cert = josepy.ComparableX509(OpenSSL.crypto.X509.from_cryptography(cert)) + client.revoke(cert, reason) + +def crl_url(cert): + """Return the CRL distribution point URL from a certificate.""" + crldp = cert.extensions.get_extension_for_class(x509.CRLDistributionPoints) + return crldp.value[0].full_name[0].value + +def fetch_revoked_entry(cert, issuer): + """Fetch the CRL named in cert's CRLDP, check its signature against issuer, + and poll until cert's entry appears. Return the entry.""" + url = crl_url(cert) + deadline = time.time() + CRL_WAIT + while True: + resp = requests.get(url) + resp.raise_for_status() + crl = x509.load_der_x509_crl(resp.content) + if not crl.is_signature_valid(issuer.public_key()): + raise Exception("CRL from %s has an invalid signature" % url) + entry = crl.get_revoked_certificate_by_serial_number(cert.serial_number) + if entry is not None: + return entry + if time.time() > deadline: + raise Exception("serial %x not on CRL %s after %ss" % (cert.serial_number, url, CRL_WAIT)) + time.sleep(1) + +def revoke_and_check_crl(domains): + """Issue and revoke a certificate with keyCompromise, then check that it + appears on the CRL with that reason. Also check that Pebble rejects a + certificateHold revocation.""" + client = make_client() + cert, issuer = load_cert(auth_and_issue(domains, client=client)) + revoke(client, cert, 1) + entry = fetch_revoked_entry(cert, issuer) + reason = entry.extensions.get_extension_for_class(x509.CRLReason).value.reason + if reason != x509.ReasonFlags.key_compromise: + raise Exception("CRL entry has reason %s, want keyCompromise" % reason) + print("Revoked serial %x found on CRL %s" % (cert.serial_number, crl_url(cert))) + + # Use a new account, since Pebble may reuse the first account's valid + # authorizations, which auth_and_issue doesn't handle. + held_client = make_client() + held, _ = load_cert(auth_and_issue(domains, client=held_client)) + expect_problem("urn:ietf:params:acme:error:badRevocationReason", + lambda: revoke(held_client, held, 6)) + if __name__ == "__main__": # Die on SIGINT signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -206,7 +273,7 @@ def expect_problem(problem_type, func): sys.exit(0) try: wait_for_acme_server() - auth_and_issue(domains) + revoke_and_check_crl(domains) except messages.Error as e: print(e) sys.exit(1) diff --git a/test/config/pebble-config-crl.json b/test/config/pebble-config-crl.json new file mode 100644 index 00000000..f91f5724 --- /dev/null +++ b/test/config/pebble-config-crl.json @@ -0,0 +1,30 @@ +{ + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "test/certs/localhost/cert.pem", + "privateKey": "test/certs/localhost/key.pem", + "httpPort": 5002, + "tlsPort": 5001, + "ocspResponderURL": "", + "crlListenAddress": "0.0.0.0:4003", + "crlBaseURL": "http://localhost:4003/", + "externalAccountBindingRequired": false, + "domainBlocklist": ["blocked-domain.example"], + "retryAfter": { + "authz": 3, + "order": 5 + }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } + } +}