diff --git a/README.md b/README.md index 25817e08..481cca7b 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,14 @@ variable `PEBBLE_VA_NOSLEEP` to `1`. E.g. The maximal number of seconds to sleep can be configured by defining `PEBBLE_VA_SLEEPTIME`. It must be set to a positive integer. +### Testing at a fixed time + +By default Pebble uses the system clock when issuing certificates. To issue CA +and leaf certificates at a fixed time, set `PEBBLE_FAKECLOCK` to an RFC3339 +timestamp. For example: + +`PEBBLE_FAKECLOCK=2030-01-02T03:04:05Z pebble -config ./test/config/pebble-config.json` + ### Skipping Validation If you want to avoid the hassle of having to stand up a challenge response diff --git a/ca/ca.go b/ca/ca.go index e4489e1a..100d69f3 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -32,10 +32,38 @@ const ( defaultValidityPeriod = 7776000 ) +// Clock supplies the current time to the CA. +type Clock interface { + Now() time.Time +} + +// Option configures a CA. +type Option struct { + apply func(*options) +} + +// WithClock configures the CA to use clk when issuing certificates. +func WithClock(clk Clock) Option { + return Option{apply: func(options *options) { + if clk != nil { + options.clk = clk + } + }} +} + +type options struct { + clk Clock +} + +type wallClock struct{} + +func (wallClock) Now() time.Time { return time.Now() } + type CAImpl struct { log *log.Logger db *db.MemoryStore ocspResponderURL string + clk Clock chains []*chain profiles map[string]*Profile @@ -130,11 +158,12 @@ func (ca *CAImpl) makeCACert( signer *issuer, ) (*core.Certificate, error) { serial := makeSerial() + now := ca.clk.Now() template := &x509.Certificate{ Subject: subject, SerialNumber: serial, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(30, 0, 0), + NotBefore: now, + NotAfter: now.AddDate(30, 0, 0), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, @@ -284,7 +313,7 @@ func (ca *CAImpl) newCertificate(domains []string, ips []net.IP, key crypto.Publ return nil, fmt.Errorf("unrecgonized profile name %q", profileName) } - certNotBefore := time.Now() + certNotBefore := ca.clk.Now() var err error if notBefore != "" { certNotBefore, err = time.Parse(time.RFC3339, notBefore) @@ -373,10 +402,18 @@ 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, keyAlg string, alternateRoots int, chainLength int, profiles map[string]Profile) *CAImpl { +func New(log *log.Logger, db *db.MemoryStore, ocspResponderURL string, keyAlg string, alternateRoots int, chainLength int, profiles map[string]Profile, opts ...Option) *CAImpl { + options := options{clk: wallClock{}} + for _, option := range opts { + if option.apply != nil { + option.apply(&options) + } + } + ca := &CAImpl{ log: log, db: db, + clk: options.clk, profiles: make(map[string]*Profile, len(profiles)), } diff --git a/ca/ca_test.go b/ca/ca_test.go index 970bf85b..7061e651 100644 --- a/ca/ca_test.go +++ b/ca/ca_test.go @@ -30,6 +30,54 @@ func makeCa() *CAImpl { return New(logger, db, "", "ecdsa", 0, 1, map[string]Profile{"default": {}}) } +type fixedClock struct { + now time.Time +} + +func (c fixedClock) Now() time.Time { return c.now } + +func TestWithClock(t *testing.T) { + now := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) + logger := log.New(os.Stdout, "Pebble ", log.LstdFlags) + ca := New( + logger, + db.NewMemoryStore(), + "", + "ecdsa", + 0, + 1, + map[string]Profile{"default": {}}, + WithClock(fixedClock{now: now}), + ) + + root := ca.GetRootCert(0).Cert + if !root.NotBefore.Equal(now) { + t.Fatalf("unexpected NotBefore: got %s, want %s", root.NotBefore, now) + } + wantNotAfter := now.AddDate(30, 0, 0) + if !root.NotAfter.Equal(wantNotAfter) { + t.Fatalf("unexpected NotAfter: got %s, want %s", root.NotAfter, wantNotAfter) + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + leaf, err := ca.newCertificate( + []string{"example.com"}, nil, key.Public(), "account", "", "", "default", nil, + ) + if err != nil { + t.Fatal(err) + } + if !leaf.Cert.NotBefore.Equal(now) { + t.Fatalf("unexpected leaf NotBefore: got %s, want %s", leaf.Cert.NotBefore, now) + } + wantLeafNotAfter := now.Add(time.Duration(defaultValidityPeriod-1) * time.Second) + if !leaf.Cert.NotAfter.Equal(wantLeafNotAfter) { + t.Fatalf("unexpected leaf NotAfter: got %s, want %s", leaf.Cert.NotAfter, wantLeafNotAfter) + } +} + func makeCertOrderWithExtensions(extensions []pkix.Extension) core.Order { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { diff --git a/cmd/pebble/clock.go b/cmd/pebble/clock.go new file mode 100644 index 00000000..988361c1 --- /dev/null +++ b/cmd/pebble/clock.go @@ -0,0 +1,30 @@ +package main + +import ( + "errors" + "os" + "time" + + "github.com/letsencrypt/pebble/v2/ca" +) + +const fakeClockEnvVar = "PEBBLE_FAKECLOCK" + +type fixedClock struct { + now time.Time +} + +func (clk fixedClock) Now() time.Time { return clk.now } + +func clockFromEnv() (ca.Clock, error) { + value := os.Getenv(fakeClockEnvVar) + if value == "" { + return nil, nil + } + + now, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, errors.New("must use RFC3339 format") + } + return fixedClock{now: now}, nil +} diff --git a/cmd/pebble/clock_test.go b/cmd/pebble/clock_test.go new file mode 100644 index 00000000..0f600ec1 --- /dev/null +++ b/cmd/pebble/clock_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + "time" +) + +func TestClockFromEnv(t *testing.T) { + t.Run("unset", func(t *testing.T) { + t.Setenv(fakeClockEnvVar, "") + + clk, err := clockFromEnv() + if err != nil { + t.Fatal(err) + } + if clk != nil { + t.Fatal("expected the default wall clock") + } + }) + + t.Run("valid", func(t *testing.T) { + now := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) + t.Setenv(fakeClockEnvVar, now.Format(time.RFC3339)) + + clk, err := clockFromEnv() + if err != nil { + t.Fatal(err) + } + if got := clk.Now(); !got.Equal(now) { + t.Fatalf("unexpected time: got %s, want %s", got, now) + } + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv(fakeClockEnvVar, "not-a-time") + if _, err := clockFromEnv(); err == nil { + t.Fatal("expected an error") + } + }) +} diff --git a/cmd/pebble/main.go b/cmd/pebble/main.go index 45ffaa7d..4e24f148 100644 --- a/cmd/pebble/main.go +++ b/cmd/pebble/main.go @@ -126,7 +126,9 @@ func main() { } db := db.NewMemoryStore() - ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, keyAlg, alternateRoots, chainLength, profiles) + clk, err := clockFromEnv() + cmd.FailOnError(err, "Reading "+fakeClockEnvVar) + ca := ca.New(logger, db, c.Pebble.OCSPResponderURL, keyAlg, alternateRoots, chainLength, profiles, ca.WithClock(clk)) va := va.New(logger, c.Pebble.HTTPPort, c.Pebble.TLSPort, *strictMode, *resolverAddress, db) for keyID, key := range c.Pebble.ExternalAccountMACKeys {