From a00821ada60a92f6d79b5b19e97f2d62f71b0df9 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Wed, 5 Mar 2025 17:32:25 -0600 Subject: [PATCH] Scale ARI suggested window to cert lifetime (#8024) Compute the width of the ARI suggested renewal window as 2% of the validity period. This means that 90-day certificates have their suggested window shrink slightly from 48 hours to 43.2 hours, and gives six-day (160h) certs a suggested window 3.2 hours wide. Also move the center of that window to the midpoint of the certificate validity period for certs which are valid for less than 10 days, so that operators have (proportionally) a little more time to respond to renewal issues. Fixes https://github.com/letsencrypt/boulder/issues/7996 --- core/objects.go | 13 ++++-- test/config-next/ca.json | 14 ++++++ test/config-next/ra.json | 6 +++ test/config-next/wfe2.json | 3 +- test/config/ca.json | 13 ++++++ test/config/ra.json | 5 +++ test/config/wfe2.json | 3 +- test/integration/ari_test.go | 82 ++++++++++++++++++++++++++++-------- wfe2/wfe_test.go | 15 +++++-- 9 files changed, 127 insertions(+), 27 deletions(-) diff --git a/core/objects.go b/core/objects.go index 33f14d92d..a17bb68ee 100644 --- a/core/objects.go +++ b/core/objects.go @@ -465,16 +465,21 @@ type RenewalInfo struct { // RenewalInfoSimple constructs a `RenewalInfo` object and suggested window // using a very simple renewal calculation: calculate a point 2/3rds of the way -// through the validity period, then give a 2-day window around that. Both the -// `issued` and `expires` timestamps are expected to be UTC. +// through the validity period (or halfway through, for short-lived certs), then +// give a 2%-of-validity wide window around that. Both the `issued` and +// `expires` timestamps are expected to be UTC. func RenewalInfoSimple(issued time.Time, expires time.Time) RenewalInfo { validity := expires.Add(time.Second).Sub(issued) renewalOffset := validity / time.Duration(3) + if validity < 10*24*time.Hour { + renewalOffset = validity / time.Duration(2) + } idealRenewal := expires.Add(-renewalOffset) + margin := validity / time.Duration(100) return RenewalInfo{ SuggestedWindow: SuggestedWindow{ - Start: idealRenewal.Add(-24 * time.Hour).Truncate(time.Second), - End: idealRenewal.Add(24 * time.Hour).Truncate(time.Second), + Start: idealRenewal.Add(-1 * margin).Truncate(time.Second), + End: idealRenewal.Add(margin).Truncate(time.Second), }, } } diff --git a/test/config-next/ca.json b/test/config-next/ca.json index 1fb58b416..b3b254220 100644 --- a/test/config-next/ca.json +++ b/test/config-next/ca.json @@ -100,6 +100,20 @@ "ignoredLints": [ "w_ext_subject_key_identifier_missing_sub_cert" ] + }, + "shortlived": { + "allowMustStaple": true, + "omitCommonName": true, + "omitKeyEncipherment": true, + "omitClientAuth": true, + "omitSKID": true, + "includeCRLDistributionPoints": true, + "maxValidityPeriod": "160h", + "maxValidityBackdate": "1h5m", + "lintConfig": "test/config-next/zlint.toml", + "ignoredLints": [ + "w_ext_subject_key_identifier_missing_sub_cert" + ] } }, "crlProfile": { diff --git a/test/config-next/ra.json b/test/config-next/ra.json index 83fc6e967..f52f830a6 100644 --- a/test/config-next/ra.json +++ b/test/config-next/ra.json @@ -48,6 +48,12 @@ "validAuthzLifetime": "7h", "orderLifetime": "7h", "maxNames": 10 + }, + "shortlived": { + "pendingAuthzLifetime": "7h", + "validAuthzLifetime": "7h", + "orderLifetime": "7h", + "maxNames": 10 } }, "defaultProfileName": "legacy", diff --git a/test/config-next/wfe2.json b/test/config-next/wfe2.json index eb8e98442..46b860bd3 100644 --- a/test/config-next/wfe2.json +++ b/test/config-next/wfe2.json @@ -129,7 +129,8 @@ }, "certProfiles": { "legacy": "The normal profile you know and love", - "modern": "Profile 2: Electric Boogaloo" + "modern": "Profile 2: Electric Boogaloo", + "shortlived": "Like modern, but smaller" }, "unpause": { "hmacKey": { diff --git a/test/config/ca.json b/test/config/ca.json index a61df7e7c..bd75f9eb8 100644 --- a/test/config/ca.json +++ b/test/config/ca.json @@ -68,6 +68,19 @@ "ignoredLints": [ "w_ext_subject_key_identifier_missing_sub_cert" ] + }, + "shortlived": { + "allowMustStaple": true, + "omitCommonName": true, + "omitKeyEncipherment": true, + "omitClientAuth": true, + "omitSKID": true, + "maxValidityPeriod": "160h", + "maxValidityBackdate": "1h5m", + "lintConfig": "test/config-next/zlint.toml", + "ignoredLints": [ + "w_ext_subject_key_identifier_missing_sub_cert" + ] } }, "crlProfile": { diff --git a/test/config/ra.json b/test/config/ra.json index 23c277c6c..765c5561a 100644 --- a/test/config/ra.json +++ b/test/config/ra.json @@ -50,6 +50,11 @@ "pendingAuthzLifetime": "7h", "validAuthzLifetime": "7h", "orderLifetime": "7h" + }, + "shortlived": { + "pendingAuthzLifetime": "7h", + "validAuthzLifetime": "7h", + "orderLifetime": "7h" } }, "defaultProfileName": "legacy", diff --git a/test/config/wfe2.json b/test/config/wfe2.json index 74a3aa14d..087fe8da3 100644 --- a/test/config/wfe2.json +++ b/test/config/wfe2.json @@ -134,7 +134,8 @@ }, "certProfiles": { "legacy": "The normal profile you know and love", - "modern": "Profile 2: Electric Boogaloo" + "modern": "Profile 2: Electric Boogaloo", + "shortlived": "Like modern, but smaller" }, "unpause": { "hmacKey": { diff --git a/test/integration/ari_test.go b/test/integration/ari_test.go index 6de8c7f86..f5777b759 100644 --- a/test/integration/ari_test.go +++ b/test/integration/ari_test.go @@ -24,14 +24,12 @@ type certID struct { SerialNumber *big.Int } -func TestARI(t *testing.T) { +func TestARIAndReplacement(t *testing.T) { t.Parallel() - // Create an account. + // Setup client, err := makeClient("mailto:example@letsencrypt.org") test.AssertNotError(t, err, "creating acme client") - - // Create a private key. key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "creating random cert key") @@ -45,11 +43,12 @@ func TestARI(t *testing.T) { cert := ir.certs[0] ari, err := client.GetRenewalInfo(cert) test.AssertNotError(t, err, "ARI request should have succeeded") - test.AssertEquals(t, ari.SuggestedWindow.Start.Sub(time.Now()).Round(time.Hour), 1415*time.Hour) - test.AssertEquals(t, ari.SuggestedWindow.End.Sub(time.Now()).Round(time.Hour), 1463*time.Hour) + test.AssertEquals(t, ari.SuggestedWindow.Start.Sub(time.Now()).Round(time.Hour), 1418*time.Hour) + test.AssertEquals(t, ari.SuggestedWindow.End.Sub(time.Now()).Round(time.Hour), 1461*time.Hour) test.AssertEquals(t, ari.RetryAfter.Sub(time.Now()).Round(time.Hour), 6*time.Hour) - // Make a new order which indicates that it replaces the cert issued above. + // Make a new order which indicates that it replaces the cert issued above, + // and verify that the replacement order succeeds. _, order, err := makeClientAndOrder(client, key, []string{name}, true, "", cert) test.AssertNotError(t, err, "failed to issue test cert") replaceID, err := acme.GenerateARICertID(cert) @@ -57,34 +56,83 @@ func TestARI(t *testing.T) { test.AssertEquals(t, order.Replaces, replaceID) test.AssertNotEquals(t, order.Replaces, "") - // Try it again and verify it fails + // Try another replacement order and verify that it fails. _, order, err = makeClientAndOrder(client, key, []string{name}, true, "", cert) test.AssertError(t, err, "subsequent ARI replacements for a replaced cert should fail, but didn't") +} - // Revoke the cert and re-request ARI. The renewal window should now be in - // the past indicating to the client that a renewal should happen - // immediately. +func TestARIShortLived(t *testing.T) { + t.Parallel() + + // Setup + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Issue a short-lived cert, request ARI, and check that both the suggested + // window and the retry-after header are approximately the right amount of + // time in the future. + name := random_domain() + ir, err := authAndIssue(client, key, []string{name}, true, "shortlived") + test.AssertNotError(t, err, "failed to issue test cert") + + cert := ir.certs[0] + ari, err := client.GetRenewalInfo(cert) + test.AssertNotError(t, err, "ARI request should have succeeded") + test.AssertEquals(t, ari.SuggestedWindow.Start.Sub(time.Now()).Round(time.Hour), 78*time.Hour) + test.AssertEquals(t, ari.SuggestedWindow.End.Sub(time.Now()).Round(time.Hour), 81*time.Hour) + test.AssertEquals(t, ari.RetryAfter.Sub(time.Now()).Round(time.Hour), 6*time.Hour) +} + +func TestARIRevoked(t *testing.T) { + t.Parallel() + + // Setup + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") + + // Issue a cert, revoke it, request ARI, and check that the suggested window + // is in the past, indicating that a renewal should happen immediately. + name := random_domain() + ir, err := authAndIssue(client, key, []string{name}, true, "") + test.AssertNotError(t, err, "failed to issue test cert") + + cert := ir.certs[0] err = client.RevokeCertificate(client.Account, cert, client.PrivateKey, 0) test.AssertNotError(t, err, "failed to revoke cert") - ari, err = client.GetRenewalInfo(cert) + ari, err := client.GetRenewalInfo(cert) test.AssertNotError(t, err, "ARI request should have succeeded") test.Assert(t, ari.SuggestedWindow.End.Before(time.Now()), "suggested window should end in the past") test.Assert(t, ari.SuggestedWindow.Start.Before(ari.SuggestedWindow.End), "suggested window should start before it ends") +} + +func TestARIForPrecert(t *testing.T) { + t.Parallel() + + // Setup + client, err := makeClient("mailto:example@letsencrypt.org") + test.AssertNotError(t, err, "creating acme client") + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "creating random cert key") // Try to make a new cert for a new domain, but sabotage the CT logs so - // issuance fails. Recover the precert from CT, then request ARI and check - // that it fails, because we don't serve ARI for non-issued certs. - name = random_domain() + // issuance fails. + name := random_domain() err = ctAddRejectHost(name) test.AssertNotError(t, err, "failed to add ct-test-srv reject host") _, err = authAndIssue(client, key, []string{name}, true, "") test.AssertError(t, err, "expected error from authAndIssue, was nil") - cert, err = ctFindRejection([]string{name}) + // Recover the precert from CT, then request ARI and check + // that it fails, because we don't serve ARI for non-issued certs. + cert, err := ctFindRejection([]string{name}) test.AssertNotError(t, err, "failed to find rejected precert") - ari, err = client.GetRenewalInfo(cert) + _, err = client.GetRenewalInfo(cert) test.AssertError(t, err, "ARI request should have failed") test.AssertEquals(t, err.(acme.Problem).Status, 404) } diff --git a/wfe2/wfe_test.go b/wfe2/wfe_test.go index fca519421..f89b350d7 100644 --- a/wfe2/wfe_test.go +++ b/wfe2/wfe_test.go @@ -3939,19 +3939,19 @@ func makeARICertID(leaf *x509.Certificate) (string, error) { } func TestCountNewOrderWithReplaces(t *testing.T) { - wfe, _, signer := setupWFE(t) + wfe, fc, signer := setupWFE(t) - expectExpiry := time.Now().AddDate(0, 0, 1) // Pick a random issuer to "issue" expectCert. var issuer *issuance.Certificate for _, v := range wfe.issuerCertificates { issuer = v break } - testKey, _ := rsa.GenerateKey(rand.Reader, 1024) + testKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) expectSerial := big.NewInt(1337) expectCert := &x509.Certificate{ - NotAfter: expectExpiry, + NotBefore: fc.Now(), + NotAfter: fc.Now().AddDate(0, 0, 90), DNSNames: []string{"example.com"}, SerialNumber: expectSerial, AuthorityKeyId: issuer.SubjectKeyId, @@ -3967,11 +3967,18 @@ func TestCountNewOrderWithReplaces(t *testing.T) { RegistrationID: 1, Serial: core.SerialToString(expectSerial), Der: expectDer, + Issued: timestamppb.New(expectCert.NotBefore), + Expires: timestamppb.New(expectCert.NotAfter), }, } mux := wfe.Handler(metrics.NoopRegisterer) responseWriter := httptest.NewRecorder() + // Set the fake clock forward to 1s past the suggested renewal window start + // time. + renewalWindowStart := core.RenewalInfoSimple(expectCert.NotBefore, expectCert.NotAfter).SuggestedWindow.Start + fc.Set(renewalWindowStart.Add(time.Second)) + body := fmt.Sprintf(` { "Identifiers": [