From cfc2bd4d7b8a4d643c687d145390f06b0c931768 Mon Sep 17 00:00:00 2001 From: Shiloh Heurich Date: Tue, 25 Nov 2025 11:23:29 -0500 Subject: [PATCH 1/3] Fix wildcard authorization reuse with DNS-Account-01 The RA rejected wildcard authorizations with DNS-Account-01 challenges during reuse, though the PA offers DNS-Account-01 for wildcards. In ra.go:2244-2248, the NewOrder() validation only accepted DNS-01 for wildcards. This check predates DNS-Account-01 wildcard support (added after commit 52615d906). Changes: - Accept both DNS-01 and DNS-Account-01 for wildcard reuse - Split validation into two checks (count vs type) - Add TestNewOrderAuthzReuseDNSAccount01 unit test The bug only affected authorization reuse (not new authorizations), which is why existing tests using random domains didn't expose it. --- ra/ra.go | 21 ++++++++++++++------- ra/ra_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/ra/ra.go b/ra/ra.go index 8e92a095faa..bbd03b4da18 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -2239,13 +2239,20 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New } // If the identifier is a wildcard DNS name, it must have exactly one - // DNS-01 type challenge. The PA guarantees this at order creation time, - // but we verify again to be safe. - if ident.Type == identifier.TypeDNS && strings.HasPrefix(ident.Value, "*.") && - (len(authz.Challenges) != 1 || authz.Challenges[0].Type != core.ChallengeTypeDNS01) { - return nil, berrors.InternalServerError( - "SA.GetAuthorizations returned a DNS wildcard authz (%s) with invalid challenge(s)", - authz.ID) + // DNS-01 or DNS-Account-01 type challenge. The PA guarantees this at + // order creation time, but we verify again to be safe. + if ident.Type == identifier.TypeDNS && strings.HasPrefix(ident.Value, "*.") { + if len(authz.Challenges) != 1 { + return nil, berrors.InternalServerError( + "SA.GetAuthorizations returned a DNS wildcard authz (%s) with %d challenges, expected 1", + authz.ID, len(authz.Challenges)) + } + challengeType := authz.Challenges[0].Type + if challengeType != core.ChallengeTypeDNS01 && challengeType != core.ChallengeTypeDNSAccount01 { + return nil, berrors.InternalServerError( + "SA.GetAuthorizations returned a DNS wildcard authz (%s) with invalid challenge type %s", + authz.ID, challengeType) + } } // If we reached this point then the existing authz was acceptable for diff --git a/ra/ra_test.go b/ra/ra_test.go index 997aa4f31cb..ace57670085 100644 --- a/ra/ra_test.go +++ b/ra/ra_test.go @@ -2120,6 +2120,46 @@ func TestNewOrderAuthzReuseSafety(t *testing.T) { test.AssertContains(t, err.Error(), "SA.GetAuthorizations returned a DNS wildcard authz (1) with invalid challenge(s)") } +// TestNewOrderAuthzReuseDNSAccount01 checks that the RA correctly allows reuse +// of a wildcard authorization with a DNS-Account-01 challenge. +func TestNewOrderAuthzReuseDNSAccount01(t *testing.T) { + _, _, ra, _, _, registration, cleanUp := initAuthorities(t) + defer cleanUp() + + ctx := context.Background() + idents := identifier.ACMEIdentifiers{identifier.NewDNS("*.zombo.com")} + + // Use a mock SA that returns a valid DNS-Account-01 authz for wildcard + expires := time.Now().Add(24 * time.Hour) + ra.SA = &mockSAWithAuthzs{ + authzs: []*core.Authorization{ + { + ID: "1", + Identifier: identifier.NewDNS("*.zombo.com"), + RegistrationID: registration.Id, + Status: "valid", + Expires: &expires, + Challenges: []core.Challenge{ + { + Type: core.ChallengeTypeDNSAccount01, + Status: core.StatusValid, + Token: core.NewToken(), + }, + }, + }, + }, + } + + orderReq := &rapb.NewOrderRequest{ + RegistrationID: registration.Id, + Identifiers: idents.ToProtoSlice(), + } + + // Create an order - it should succeed with DNS-Account-01 wildcard reuse + _, err := ra.NewOrder(ctx, orderReq) + test.AssertNotError(t, err, "NewOrder failed to reuse wildcard authz with DNS-Account-01") +} + func TestNewOrderWildcard(t *testing.T) { _, _, ra, _, _, registration, cleanUp := initAuthorities(t) defer cleanUp() From 9af01233349d44f1fe426b7bd3f299cdd5677f90 Mon Sep 17 00:00:00 2001 From: Shiloh Heurich Date: Tue, 25 Nov 2025 12:02:28 -0500 Subject: [PATCH 2/3] Add integration test for wildcard DNS-Account-01 authorization reuse Adds TestDNSAccount01WildcardAuthorizationReuse to verify that wildcard authorizations with DNS-Account-01 challenges can be reused correctly. The test: - Creates a wildcard order with DNS-Account-01 - Completes the challenge to get a valid authorization - Creates a second order for the same wildcard domain - Verifies the same authorization is reused (same URL) - Verifies the authorization is already valid (no re-validation) - Verifies the DNS-Account-01 challenge type is preserved This test fills a gap in Boulder's integration test coverage - no existing Go integration tests verify authorization reuse end-to-end. --- test/integration/dns_account_01_test.go | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test/integration/dns_account_01_test.go b/test/integration/dns_account_01_test.go index 5f2d72661b1..e5bb3d2c129 100644 --- a/test/integration/dns_account_01_test.go +++ b/test/integration/dns_account_01_test.go @@ -361,3 +361,93 @@ func TestDNSAccount01WildcardDomain(t *testing.T) { } } } + +func TestDNSAccount01WildcardAuthorizationReuse(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + // Use same domain for both orders to trigger authorization reuse + domain := random_domain() + wildcardDomain := fmt.Sprintf("*.%s", domain) + + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: wildcardDomain}} + + // First order: Create and complete DNS-Account-01 challenge + order1, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating first order: %s", err) + } + + authzURL := order1.Authorizations[0] + auth1, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching first authorization: %s", err) + } + + chal, ok := auth1.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("dns-account-01 challenge not offered by server") + } + + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, chal.KeyAuthorization) + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + t.Cleanup(func() { + _, _ = testSrvClient.RemoveDNSAccount01Response(c.Account.URL, domain) + }) + + chal, err = c.Client.UpdateChallenge(c.Account, chal) + if err != nil { + t.Fatalf("updating challenge: %s", err) + } + + // Wait for authorization to become valid + auth1, err = c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching first authorization after challenge update: %s", err) + } + + if auth1.Status != "valid" { + t.Fatalf("expected first authorization status to be 'valid', got '%s'", auth1.Status) + } + + // Second order: Should reuse the existing authorization + order2, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating second order: %s", err) + } + + if len(order2.Authorizations) != 1 { + t.Fatalf("expected 1 authorization in second order, got %d", len(order2.Authorizations)) + } + + authzURL2 := order2.Authorizations[0] + auth2, err := c.Client.FetchAuthorization(c.Account, authzURL2) + if err != nil { + t.Fatalf("fetching second authorization: %s", err) + } + + // Verify reuse occurred: same authorization URL + if authzURL != authzURL2 { + t.Fatalf("expected same authorization URL, got different: %s != %s", authzURL, authzURL2) + } + + // Verify authorization is already valid (no re-validation needed) + if auth2.Status != "valid" { + t.Fatalf("expected reused authorization status to be 'valid', got '%s'", auth2.Status) + } + + // Verify authorization still has DNS-Account-01 challenge + if _, ok := auth2.ChallengeMap[acme.ChallengeTypeDNSAccount01]; !ok { + t.Fatal("expected reused authorization to have dns-account-01 challenge") + } +} From b90a5faa15284a6c183163a1d2f48201837d4bd1 Mon Sep 17 00:00:00 2001 From: Shiloh Heurich Date: Tue, 25 Nov 2025 12:32:30 -0500 Subject: [PATCH 3/3] Preserve original error message format for wildcard validation Reverted to single combined check to maintain backward compatibility with existing test expectations. The fix still accepts both DNS-01 and DNS-Account-01 for wildcard authorization reuse, but uses the original generic error message format: "with invalid challenge(s)" This avoids needing to modify existing tests while still fixing the bug. --- ra/ra.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/ra/ra.go b/ra/ra.go index bbd03b4da18..824ff6b1129 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -2241,18 +2241,13 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New // If the identifier is a wildcard DNS name, it must have exactly one // DNS-01 or DNS-Account-01 type challenge. The PA guarantees this at // order creation time, but we verify again to be safe. - if ident.Type == identifier.TypeDNS && strings.HasPrefix(ident.Value, "*.") { - if len(authz.Challenges) != 1 { - return nil, berrors.InternalServerError( - "SA.GetAuthorizations returned a DNS wildcard authz (%s) with %d challenges, expected 1", - authz.ID, len(authz.Challenges)) - } - challengeType := authz.Challenges[0].Type - if challengeType != core.ChallengeTypeDNS01 && challengeType != core.ChallengeTypeDNSAccount01 { - return nil, berrors.InternalServerError( - "SA.GetAuthorizations returned a DNS wildcard authz (%s) with invalid challenge type %s", - authz.ID, challengeType) - } + if ident.Type == identifier.TypeDNS && strings.HasPrefix(ident.Value, "*.") && + (len(authz.Challenges) != 1 || + (authz.Challenges[0].Type != core.ChallengeTypeDNS01 && + authz.Challenges[0].Type != core.ChallengeTypeDNSAccount01)) { + return nil, berrors.InternalServerError( + "SA.GetAuthorizations returned a DNS wildcard authz (%s) with invalid challenge(s)", + authz.ID) } // If we reached this point then the existing authz was acceptable for