Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
116 changes: 115 additions & 1 deletion cmd/pebble/main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Loading
Loading