diff --git a/backend/acme_server_test.go b/backend/acme_server_test.go index aab8388..85ec85e 100644 --- a/backend/acme_server_test.go +++ b/backend/acme_server_test.go @@ -103,7 +103,7 @@ func (b *testBackend) startACMEServer(t *testing.T, opts ...AcmeServerOption) *a // generate self-signed certificate for TLS localIPs := []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback} expiration := time.Now().Add(365 * 24 * time.Hour) - cert, key := generateSelfSignedCert(t, "localhost", localIPs, expiration) + cert, key := generateSelfSignedCert(t, "localhost", localIPs, time.Now(), expiration) // Create TLS config tlsConfig := &tls.Config{ diff --git a/backend/cert.go b/backend/cert.go index ec5d21e..47bcbb4 100644 --- a/backend/cert.go +++ b/backend/cert.go @@ -31,6 +31,14 @@ func getCert(ctx context.Context, storage logical.Storage, path string) (*cert, return cert, nil } +func (c *cert) NotBefore() time.Time { + if len(c.CertificateChain) > 0 { + return c.CertificateChain[0].NotBefore + } + + return time.Time{} +} + func (c *cert) NotAfter() time.Time { if len(c.CertificateChain) > 0 { return c.CertificateChain[0].NotAfter @@ -39,6 +47,15 @@ func (c *cert) NotAfter() time.Time { return time.Time{} } +func (c *cert) RenewalDeadline() time.Time { + var ( + validDuration = c.NotAfter().Sub(c.NotBefore()) + twoThirdsDuration = validDuration * 2 / 3 + deadline = c.NotBefore().Add(twoThirdsDuration) + ) + return deadline +} + func (c *cert) write(ctx context.Context, storage logical.Storage, path string) error { entry, err := logical.StorageEntryJSON(path, c) if err != nil { diff --git a/backend/cert_test.go b/backend/cert_test.go index f7ef519..65b0906 100644 --- a/backend/cert_test.go +++ b/backend/cert_test.go @@ -30,8 +30,8 @@ func TestCert_Serialization(t *testing.T) { storage := &logical.InmemStorage{} notAfter := time.Now().Add(265 * 24 * time.Hour) - leaf, key := generateSelfSignedCert(t, "test.example.com", nil, notAfter) - intermediate, _ := generateSelfSignedCert(t, "ca.example.com", nil, notAfter) + leaf, key := generateSelfSignedCert(t, "test.example.com", nil, time.Now(), notAfter) + intermediate, _ := generateSelfSignedCert(t, "ca.example.com", nil, time.Now(), notAfter) originalCert := &cert{ CertificateChain: []*x509.Certificate{leaf, intermediate}, @@ -57,11 +57,12 @@ func assertCertEqual(t *testing.T, expected *cert, actual *cert) { actualCert := actual.CertificateChain[ii] assert.Equal(t, expectedCert.Raw, actualCert.Raw) assert.Equal(t, expectedCert.Subject, actualCert.Subject) + assert.Equal(t, expectedCert.NotBefore, actualCert.NotBefore) assert.Equal(t, expectedCert.NotAfter, actualCert.NotAfter) } } -func generateSelfSignedCert(t *testing.T, fqdn string, ips []net.IP, notAfter time.Time) (*x509.Certificate, accountKey) { +func generateSelfSignedCert(t *testing.T, fqdn string, ips []net.IP, notBefore, notAfter time.Time) (*x509.Certificate, accountKey) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) @@ -75,7 +76,7 @@ func generateSelfSignedCert(t *testing.T, fqdn string, ips []net.IP, notAfter ti StreetAddress: []string{""}, PostalCode: []string{""}, }, - NotBefore: time.Now(), + NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, diff --git a/backend/path_cert.go b/backend/path_cert.go index 3dcaebd..a5b13c7 100644 --- a/backend/path_cert.go +++ b/backend/path_cert.go @@ -77,8 +77,9 @@ func (b *backend) certsRead(ctx context.Context, req *logical.Request, data *fra } if c != nil && len(c.CertificateChain) > 0 && c.Key.PrivateKey != nil { - notAfter := c.NotAfter() - if !notAfter.IsZero() && time.Until(notAfter) > 30*24*time.Hour { + + timeUntilRenwal := time.Until(c.RenewalDeadline()) + if timeUntilRenwal > 0 { return b.certResponse(ctx, c, req, account, provider) } } @@ -198,8 +199,7 @@ func (b *backend) certResponse(ctx context.Context, c *cert, req *logical.Reques } keyPem := pem.EncodeToMemory(block) - ttlUntilExpiration := time.Until(c.NotAfter()) - ttl := ttlUntilExpiration - 30*24*time.Hour + ttl := time.Until(c.RenewalDeadline()) if ttl < 1*time.Hour { ttl = 1 * time.Hour } diff --git a/backend/path_cert_test.go b/backend/path_cert_test.go index ed1872f..b047cd0 100644 --- a/backend/path_cert_test.go +++ b/backend/path_cert_test.go @@ -25,7 +25,7 @@ func TestPathCerts_ExistingCertificate(t *testing.T) { path := MakeDNS01Path(account, provider, fqdn) notAfter := time.Now().Add(90 * 24 * time.Hour) - leaf, key := generateSelfSignedCert(t, fqdn, nil, notAfter) + leaf, key := generateSelfSignedCert(t, fqdn, nil, time.Now(), notAfter) originalCert := &cert{ CertificateChain: []*x509.Certificate{leaf}, @@ -236,7 +236,7 @@ func TestPathCerts_Renew(t *testing.T) { require.NoError(t, resp.Error()) notAfter := time.Now().Add(-time.Hour) - leaf, key := generateSelfSignedCert(t, fqdn, nil, notAfter) + leaf, key := generateSelfSignedCert(t, fqdn, nil, time.Now(), notAfter) originalCert := &cert{ CertificateChain: []*x509.Certificate{leaf}, @@ -354,7 +354,7 @@ func TestPathCerts_LeaseRevoke(t *testing.T) { path := MakeDNS01Path(account, provider, fqdn) notAfter := time.Now().Add(90 * 24 * time.Hour) - leaf, key := generateSelfSignedCert(t, fqdn, nil, notAfter) + leaf, key := generateSelfSignedCert(t, fqdn, nil, time.Now(), notAfter) originalCert := &cert{ CertificateChain: []*x509.Certificate{leaf}, @@ -500,3 +500,144 @@ func TestPathCerts_IssuesNew_Wildcard(t *testing.T) { assert.Contains(t, certcrypto.ExtractDomains(certs[0]), fqdn) assertCertMatchesKey(t, certs[0], key) } + +func TestPathCerts_Renews_TwoThirds(t *testing.T) { + type testCase struct { + Desc string + NotBefore time.Time + NotAfter time.Time + ShouldRenew bool + } + + tests := []testCase{ + // Current 90 day certs + { + Desc: "90d-cert do-not-renew", + NotBefore: time.Now(), + NotAfter: time.Now().Add(90 * 24 * time.Hour), + ShouldRenew: false, + }, + { + Desc: "90d-cert needs-renew", + NotBefore: time.Now().Add(-61 * 24 * time.Hour), + NotAfter: time.Now().Add(29 * 24 * time.Hour), + ShouldRenew: true, + }, + + // Current shortlived (6 day) certs + { + Desc: "6d-cert do-not-renew", + NotBefore: time.Now(), + NotAfter: time.Now().Add(6 * 24 * time.Hour), + ShouldRenew: false, + }, + { + Desc: "6d-cert needs-renew", + NotBefore: time.Now().Add(-5 * 24 * time.Hour), + NotAfter: time.Now().Add(1 * 24 * time.Hour), + ShouldRenew: true, + }, + + // Future 64 day certs + { + Desc: "64d-cert do-not-renew", + NotBefore: time.Now(), + NotAfter: time.Now().Add(64 * 24 * time.Hour), + ShouldRenew: false, + }, + { + Desc: "64d-cert needs-renew", + NotBefore: time.Now().Add(-41 * 24 * time.Hour), + NotAfter: time.Now().Add(20 * 24 * time.Hour), + ShouldRenew: true, + }, + + // Future 45 day certs + { + Desc: "45d-cert do-not-renew", + NotBefore: time.Now(), + NotAfter: time.Now().Add(45 * 24 * time.Hour), + ShouldRenew: false, + }, + { + Desc: "45d-cert needs-renew", + NotBefore: time.Now().Add(-31 * 24 * time.Hour), + NotAfter: time.Now().Add(14 * 24 * time.Hour), + ShouldRenew: true, + }, + } + + for _, tc := range tests { + + func() { + b := createTestBackend(t) + + as := b.startACMEServer(t) + defer as.Close() + + const ( + account = "test-account" + provider = "test-dns" + fqdn = "test.example.com" + ) + + path := MakeDNS01Path(account, provider, fqdn) + + b.RegisterDNSProvider(provider, func() (challenge.Provider, error) { + return as, nil + }) + + accountPath := "accounts/" + account + req := &logical.Request{ + Path: accountPath, + Operation: logical.UpdateOperation, + Data: map[string]interface{}{ + "email": "test@example.com", + "directory_url": as.DirectoryURL, + "tos_agreed": true, + }, + } + + resp, err := b.HandleRequest(t, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NoError(t, resp.Error()) + + leaf, key := generateSelfSignedCert(t, fqdn, nil, tc.NotBefore, tc.NotAfter) + + originalCert := &cert{ + CertificateChain: []*x509.Certificate{leaf}, + Key: key, + } + err = originalCert.write(t.Context(), b.Storage, path) + require.NoError(t, err) + + req = &logical.Request{ + Path: path, + Operation: logical.ReadOperation, + } + + resp, err = b.HandleRequest(t, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NoError(t, resp.Error()) + + certs, err := certcrypto.ParsePEMBundle([]byte(resp.Data["certificate"].(string))) + require.NoError(t, err) + require.NotEmpty(t, certs) + + privKey, err := certcrypto.ParsePEMPrivateKey([]byte(resp.Data["private_key"].(string))) + require.NoError(t, err) + require.NotNil(t, privKey) + + assert.Contains(t, certcrypto.ExtractDomains(certs[0]), fqdn) + if tc.ShouldRenew { + assertCertMatchesKey(t, certs[0], privKey) + assert.Truef(t, certs[0].NotAfter.After(tc.NotAfter), "%s", tc.Desc) + } else { + assert.Equalf(t, tc.NotAfter.Year(), certs[0].NotAfter.Year(), tc.Desc) + assert.Equalf(t, tc.NotAfter.YearDay(), certs[0].NotAfter.YearDay(), tc.Desc) + } + }() + } +}