diff --git a/bdns/mocks.go b/bdns/mocks.go index fe7d07c2920..b72b674a283 100644 --- a/bdns/mocks.go +++ b/bdns/mocks.go @@ -20,6 +20,43 @@ type MockClient struct { // LookupTXT is a mock func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string, ResolverAddrs, error) { + // Use the example account-specific label prefix derived from + // "https://example.com/acme/acct/ExampleAccount" + const accountLabelPrefix = "_ujmmovf2vn55tgye._acme-challenge" + + if hostname == accountLabelPrefix+".servfail.com" { + // Mirror dns-01 servfail behaviour + return nil, ResolverAddrs{"MockClient"}, fmt.Errorf("SERVFAIL") + } + if hostname == accountLabelPrefix+".good-dns01.com" { + // Mirror dns-01 good record + // base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" + // + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI")) + return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == accountLabelPrefix+".wrong-dns01.com" { + // Mirror dns-01 wrong record + return []string{"a"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == accountLabelPrefix+".wrong-many-dns01.com" { + // Mirror dns-01 wrong-many record + return []string{"a", "b", "c", "d", "e"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == accountLabelPrefix+".long-dns01.com" { + // Mirror dns-01 long record + return []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == accountLabelPrefix+".no-authority-dns01.com" { + // Mirror dns-01 no-authority good record + // base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0" + // + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI")) + return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil + } + if hostname == accountLabelPrefix+".empty-txts.com" { + // Mirror dns-01 zero TXT records + return []string{}, ResolverAddrs{"MockClient"}, nil + } + if hostname == "_acme-challenge.servfail.com" { return nil, ResolverAddrs{"MockClient"}, fmt.Errorf("SERVFAIL") } @@ -48,6 +85,8 @@ func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string, if hostname == "_acme-challenge.empty-txts.com" { return []string{}, ResolverAddrs{"MockClient"}, nil } + + // Default fallback return []string{"hostname"}, ResolverAddrs{"MockClient"}, nil } diff --git a/cmd/config.go b/cmd/config.go index 9c7f2dc4a7f..d9b62fd32d3 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -94,7 +94,7 @@ func (d *DBConfig) URL() (string, error) { // it should offer. type PAConfig struct { DBConfig `validate:"-"` - Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01,endkeys"` + Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01 dns-account-01,endkeys"` Identifiers map[identifier.IdentifierType]bool `validate:"omitempty,dive,keys,oneof=dns ip,endkeys"` } diff --git a/features/features.go b/features/features.go index af7b2f7f097..0c922f3eb7b 100644 --- a/features/features.go +++ b/features/features.go @@ -80,6 +80,12 @@ type Config struct { // StoreARIReplacesInOrders causes the SA to store and retrieve the optional // ARI replaces field in the orders table. StoreARIReplacesInOrders bool + + // DNSAccount01Enabled controls support for the dns-account-01 challenge + // type. When enabled, the server can offer and validate this challenge + // during certificate issuance. This flag must be set to true in the + // RA, VA, and WFE2 services for full functionality. + DNSAccount01Enabled bool } var fMu = new(sync.RWMutex) diff --git a/policy/pa.go b/policy/pa.go index 961b67cb64a..ab17bd89dac 100644 --- a/policy/pa.go +++ b/policy/pa.go @@ -18,6 +18,7 @@ import ( "github.com/letsencrypt/boulder/core" berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" "github.com/letsencrypt/boulder/iana" "github.com/letsencrypt/boulder/identifier" blog "github.com/letsencrypt/boulder/log" @@ -606,20 +607,28 @@ func (pa *AuthorityImpl) checkBlocklists(ident identifier.ACMEIdentifier) error func (pa *AuthorityImpl) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) { switch ident.Type { case identifier.TypeDNS: - // If the identifier is for a DNS wildcard name we only provide a DNS-01 - // challenge, to comply with the BRs Sections 3.2.2.4.19 and 3.2.2.4.20 - // stating that ACME HTTP-01 and TLS-ALPN-01 are not suitable for validating - // Wildcard Domains. + // If the identifier is for a DNS wildcard name we only provide DNS-01 + // or DNS-ACCOUNT-01 challenges, to comply with the BRs Sections 3.2.2.4.19 + // and 3.2.2.4.20 stating that ACME HTTP-01 and TLS-ALPN-01 are not + // suitable for validating Wildcard Domains. if strings.HasPrefix(ident.Value, "*.") { - return []core.AcmeChallenge{core.ChallengeTypeDNS01}, nil + challenges := []core.AcmeChallenge{core.ChallengeTypeDNS01} + if features.Get().DNSAccount01Enabled { + challenges = append(challenges, core.ChallengeTypeDNSAccount01) + } + return challenges, nil } // Return all challenge types we support for non-wildcard DNS identifiers. - return []core.AcmeChallenge{ + challenges := []core.AcmeChallenge{ core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeTLSALPN01, - }, nil + } + if features.Get().DNSAccount01Enabled { + challenges = append(challenges, core.ChallengeTypeDNSAccount01) + } + return challenges, nil case identifier.TypeIP: // Only HTTP-01 and TLS-ALPN-01 are suitable for IP address identifiers // per RFC 8738, Sec. 4. diff --git a/policy/pa_test.go b/policy/pa_test.go index 6cb8c9de7be..f4b44701a7b 100644 --- a/policy/pa_test.go +++ b/policy/pa_test.go @@ -19,9 +19,10 @@ import ( func paImpl(t *testing.T) *AuthorityImpl { enabledChallenges := map[core.AcmeChallenge]bool{ - core.ChallengeTypeHTTP01: true, - core.ChallengeTypeDNS01: true, - core.ChallengeTypeTLSALPN01: true, + core.ChallengeTypeHTTP01: true, + core.ChallengeTypeDNS01: true, + core.ChallengeTypeTLSALPN01: true, + core.ChallengeTypeDNSAccount01: true, } enabledIdentifiers := map[identifier.IdentifierType]bool{ @@ -457,56 +458,122 @@ func TestChallengeTypesFor(t *testing.T) { t.Parallel() pa := paImpl(t) - testCases := []struct { - name string - ident identifier.ACMEIdentifier - wantChalls []core.AcmeChallenge - wantErr string - }{ - { - name: "dns", - ident: identifier.NewDNS("example.com"), - wantChalls: []core.AcmeChallenge{ - core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeTLSALPN01, + t.Run("DNSAccount01Enabled=true", func(t *testing.T) { + features.Set(features.Config{DNSAccount01Enabled: true}) + t.Cleanup(features.Reset) + + testCases := []struct { + name string + ident identifier.ACMEIdentifier + wantChalls []core.AcmeChallenge + wantErr string + }{ + { + name: "dns", + ident: identifier.NewDNS("example.com"), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeHTTP01, + core.ChallengeTypeDNS01, + core.ChallengeTypeTLSALPN01, + core.ChallengeTypeDNSAccount01, + }, }, - }, - { - name: "dns wildcard", - ident: identifier.NewDNS("*.example.com"), - wantChalls: []core.AcmeChallenge{ - core.ChallengeTypeDNS01, + { + name: "dns wildcard", + ident: identifier.NewDNS("*.example.com"), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeDNS01, + core.ChallengeTypeDNSAccount01, + }, }, - }, - { - name: "ip", - ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")), - wantChalls: []core.AcmeChallenge{ - core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01, + { + name: "ip", + ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01, + }, }, - }, - { - name: "invalid", - ident: identifier.ACMEIdentifier{Type: "fnord", Value: "uh-oh, Spaghetti-Os[tm]"}, - wantErr: "unrecognized identifier type", - }, - } + { + name: "invalid", + ident: identifier.ACMEIdentifier{Type: "fnord", Value: "uh-oh, Spaghetti-Os[tm]"}, + wantErr: "unrecognized identifier type", + }, + } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - challs, err := pa.ChallengeTypesFor(tc.ident) + for _, tc := range testCases { + tc := tc // Capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + challs, err := pa.ChallengeTypesFor(tc.ident) - if len(tc.wantChalls) != 0 { - test.AssertNotError(t, err, "should have succeeded") - test.AssertDeepEquals(t, challs, tc.wantChalls) - } + if len(tc.wantChalls) != 0 { + test.AssertNotError(t, err, "should have succeeded") + test.AssertDeepEquals(t, challs, tc.wantChalls) + } - if tc.wantErr != "" { - test.AssertError(t, err, "should have errored") - test.AssertContains(t, err.Error(), tc.wantErr) - } - }) - } + if tc.wantErr != "" { + test.AssertError(t, err, "should have errored") + test.AssertContains(t, err.Error(), tc.wantErr) + } + }) + } + }) + + t.Run("DNSAccount01Enabled=false", func(t *testing.T) { + features.Set(features.Config{DNSAccount01Enabled: false}) + t.Cleanup(features.Reset) + + testCases := []struct { + name string + ident identifier.ACMEIdentifier + wantChalls []core.AcmeChallenge + wantErr string + }{ + { + name: "dns", + ident: identifier.NewDNS("example.com"), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeHTTP01, + core.ChallengeTypeDNS01, + core.ChallengeTypeTLSALPN01, + // DNSAccount01 excluded + }, + }, + { + name: "wildcard", + ident: identifier.NewDNS("*.example.com"), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeDNS01, + // DNSAccount01 excluded + }, + }, + { + name: "ip", + ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")), + wantChalls: []core.AcmeChallenge{ + core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01, + }, + }, + } + + for _, tc := range testCases { + tc := tc // Capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + challs, err := pa.ChallengeTypesFor(tc.ident) + + if len(tc.wantChalls) != 0 { + test.AssertNotError(t, err, "should have succeeded") + test.AssertDeepEquals(t, challs, tc.wantChalls) + } + + if tc.wantErr != "" { + test.AssertError(t, err, "should have errored") + test.AssertContains(t, err.Error(), tc.wantErr) + } + }) + } + }) } // TestMalformedExactBlocklist tests that loading a YAML policy file with an diff --git a/test/chall-test-srv-client/client.go b/test/chall-test-srv-client/client.go index 84a327570f0..4d93e165f72 100644 --- a/test/chall-test-srv-client/client.go +++ b/test/chall-test-srv-client/client.go @@ -3,6 +3,7 @@ package challtestsrvclient import ( "bytes" "crypto/sha256" + "encoding/base32" "encoding/base64" "encoding/json" "fmt" @@ -400,6 +401,80 @@ func (c *Client) RemoveDNS01Response(host string) ([]byte, error) { return resp, nil } +// AddDNSAccount01Response adds an ACME DNS-ACCOUNT-01 challenge response for the +// provided host to the challenge test server's DNS interfaces. The TXT record +// name is constructed using the accountURL, and the TXT record value is the +// base64url encoded SHA-256 hash of the provided value. Any failure returns an +// error that includes the relevant operation and the payload. +func (c *Client) AddDNSAccount01Response(accountURL, host, value string) ([]byte, error) { + if accountURL == "" { + return nil, fmt.Errorf("accountURL cannot be empty") + } + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + label, err := calculateDNSAccount01Label(accountURL) + if err != nil { + return nil, fmt.Errorf("error calculating DNS label: %v", err) + } + host = fmt.Sprintf("%s._acme-challenge.%s", label, host) + if !strings.HasSuffix(host, ".") { + host += "." + } + h := sha256.Sum256([]byte(value)) + value = base64.RawURLEncoding.EncodeToString(h[:]) + payload := map[string]string{"host": host, "value": value} + resp, err := c.postURL(addTXT, payload) + if err != nil { + return nil, fmt.Errorf( + "while adding DNS-ACCOUNT-01 response for host %q, val %q (payload: %v): %w", + host, value, payload, err, + ) + } + return resp, nil +} + +// RemoveDNSAccount01Response removes an ACME DNS-ACCOUNT-01 challenge +// response for the provided host and accountURL combination from the +// challenge test server's DNS interfaces. The TXT record name is +// constructed using the accountURL. Any failure returns an error +// that includes both the relevant operation and the payload. +func (c *Client) RemoveDNSAccount01Response(accountURL, host string) ([]byte, error) { + if accountURL == "" { + return nil, fmt.Errorf("accountURL cannot be empty") + } + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + label, err := calculateDNSAccount01Label(accountURL) + if err != nil { + return nil, fmt.Errorf("error calculating DNS label: %v", err) + } + host = fmt.Sprintf("%s._acme-challenge.%s", label, host) + if !strings.HasSuffix(host, ".") { + host += "." + } + payload := map[string]string{"host": host} + resp, err := c.postURL(delTXT, payload) + if err != nil { + return nil, fmt.Errorf( + "while removing DNS-ACCOUNT-01 response for host %q (payload: %v): %w", + host, payload, err, + ) + } + return resp, nil +} + +func calculateDNSAccount01Label(accountURL string) (string, error) { + if accountURL == "" { + return "", fmt.Errorf("account URL cannot be empty") + } + + h := sha256.Sum256([]byte(accountURL)) + label := fmt.Sprintf("_%s", strings.ToLower(base32.StdEncoding.EncodeToString(h[:10]))) + return label, nil +} + // DNSRequest is a single DNS request in the request history. type DNSRequest struct { Question struct { diff --git a/test/chisel2.py b/test/chisel2.py index 6cf99efaf58..d636ca224d8 100644 --- a/test/chisel2.py +++ b/test/chisel2.py @@ -96,6 +96,19 @@ def get_chall(authz, typ): return chall_body raise Exception("No %s challenge found" % typ.typ) +def get_any_supported_chall(authz): + """ + Return the first supported challenge from the given authorization. + Supports HTTP01, DNS01, and TLSALPN01 challenges. + + Note: DNS-ACCOUNT-01 challenge type is excluded from the list of supported + challenge types until the Python ACME library adds support for it. + """ + for chall_body in authz.body.challenges: + if isinstance(chall_body.chall, (challenges.HTTP01, challenges.DNS01, challenges.TLSALPN01)): + return chall_body + raise Exception("No supported challenge types found in authorization") + def make_csr(domains): key = OpenSSL.crypto.PKey() key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) diff --git a/test/config-next/ra.json b/test/config-next/ra.json index bf73cae1d6c..172fef2a5f5 100644 --- a/test/config-next/ra.json +++ b/test/config-next/ra.json @@ -135,11 +135,6 @@ } } }, - "features": { - "AsyncFinalize": true, - "AutomaticallyPauseZombieClients": true, - "NoPendingAuthzReuse": true - }, "ctLogs": { "stagger": "500ms", "logListFile": "test/ct-test-srv/log_list.json", @@ -163,13 +158,20 @@ "C1", "F1" ] + }, + "features": { + "AsyncFinalize": true, + "AutomaticallyPauseZombieClients": true, + "DNSAccount01Enabled": true, + "NoPendingAuthzReuse": true } }, "pa": { "challenges": { "http-01": true, "dns-01": true, - "tls-alpn-01": true + "tls-alpn-01": true, + "dns-account-01": true }, "identifiers": { "dns": true, diff --git a/test/config-next/remoteva-a.json b/test/config-next/remoteva-a.json index 43f22840c6a..f931ba183f3 100644 --- a/test/config-next/remoteva-a.json +++ b/test/config-next/remoteva-a.json @@ -35,11 +35,14 @@ } }, "accountURIPrefixes": [ - "http://boulder.service.consul:4000/acme/reg/", - "http://boulder.service.consul:4001/acme/acct/" + "http://boulder.service.consul:4001/acme/acct/", + "http://boulder.service.consul:4000/acme/reg/" ], "perspective": "dadaist", - "rir": "ARIN" + "rir": "ARIN", + "features": { + "DNSAccount01Enabled": true + } }, "syslog": { "stdoutlevel": 4, diff --git a/test/config-next/remoteva-b.json b/test/config-next/remoteva-b.json index 7595a8b4e58..937d6635d1c 100644 --- a/test/config-next/remoteva-b.json +++ b/test/config-next/remoteva-b.json @@ -35,11 +35,14 @@ } }, "accountURIPrefixes": [ - "http://boulder.service.consul:4000/acme/reg/", - "http://boulder.service.consul:4001/acme/acct/" + "http://boulder.service.consul:4001/acme/acct/", + "http://boulder.service.consul:4000/acme/reg/" ], "perspective": "surrealist", - "rir": "RIPE" + "rir": "RIPE", + "features": { + "DNSAccount01Enabled": true + } }, "syslog": { "stdoutlevel": 4, diff --git a/test/config-next/remoteva-c.json b/test/config-next/remoteva-c.json index a5ca7ffa5c7..6fc58b2b2dc 100644 --- a/test/config-next/remoteva-c.json +++ b/test/config-next/remoteva-c.json @@ -35,11 +35,14 @@ } }, "accountURIPrefixes": [ - "http://boulder.service.consul:4000/acme/reg/", - "http://boulder.service.consul:4001/acme/acct/" + "http://boulder.service.consul:4001/acme/acct/", + "http://boulder.service.consul:4000/acme/reg/" ], "perspective": "cubist", - "rir": "ARIN" + "rir": "ARIN", + "features": { + "DNSAccount01Enabled": true + } }, "syslog": { "stdoutlevel": 4, diff --git a/test/config-next/va.json b/test/config-next/va.json index a0bef772ec8..3b7dba6768e 100644 --- a/test/config-next/va.json +++ b/test/config-next/va.json @@ -60,9 +60,12 @@ } ], "accountURIPrefixes": [ - "http://boulder.service.consul:4000/acme/reg/", - "http://boulder.service.consul:4001/acme/acct/" - ] + "http://boulder.service.consul:4001/acme/acct/", + "http://boulder.service.consul:4000/acme/reg/" + ], + "features": { + "DNSAccount01Enabled": true + } }, "syslog": { "stdoutlevel": 6, diff --git a/test/integration/dns_account_01_test.go b/test/integration/dns_account_01_test.go new file mode 100644 index 00000000000..5f2d72661b1 --- /dev/null +++ b/test/integration/dns_account_01_test.go @@ -0,0 +1,363 @@ +//go:build integration + +package integration + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/eggsampler/acme/v3" +) + +func TestDNSAccount01HappyPath(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + domain := random_domain() + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: domain}} + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + authzURL := order.Authorizations[0] + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + chal, ok := auth.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) + } + + // Check that the authorization status has changed + auth, err = c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization after challenge update: %s", err) + } + + if auth.Status != "valid" { + t.Fatalf("expected authorization status to be 'valid', got '%s'", auth.Status) + } +} + +func TestDNSAccount01WrongTXTRecord(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + domain := random_domain() + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: domain}} + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + authzURL := order.Authorizations[0] + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("dns-account-01 challenge not offered by server") + } + + // Add a wrong TXT record + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "wrong-digest") + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + t.Cleanup(func() { + _, _ = testSrvClient.RemoveDNSAccount01Response(c.Account.URL, domain) + }) + + _, err = c.Client.UpdateChallenge(c.Account, chal) + if err == nil { + t.Fatalf("updating challenge: expected error, got nil") + } + prob, ok := err.(acme.Problem) + if !ok { + t.Fatalf("updating challenge: expected acme.Problem error, got %T", err) + } + if prob.Type != "urn:ietf:params:acme:error:unauthorized" { + t.Fatalf("updating challenge: expected unauthorized error, got %s", prob.Type) + } + if !strings.Contains(prob.Detail, "Incorrect TXT record") { + t.Fatalf("updating challenge: expected Incorrect TXT record error, got %s", prob.Detail) + } +} + +func TestDNSAccount01NoTXTRecord(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + domain := random_domain() + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: domain}} + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + authzURL := order.Authorizations[0] + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("dns-account-01 challenge not offered by server") + } + + // Skip adding a TXT record + + _, err = c.Client.UpdateChallenge(c.Account, chal) + if err == nil { + t.Fatalf("updating challenge: expected error, got nil") + } + prob, ok := err.(acme.Problem) + if !ok { + t.Fatalf("updating challenge: expected acme.Problem error, got %T", err) + } + if prob.Type != "urn:ietf:params:acme:error:unauthorized" { + t.Fatalf("updating challenge: expected unauthorized error, got %s", prob.Type) + } + if !strings.Contains(prob.Detail, "No TXT record found") { + t.Fatalf("updating challenge: expected No TXT record found error, got %s", prob.Detail) + } +} + +func TestDNSAccount01MultipleTXTRecordsNoneMatch(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + domain := random_domain() + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: domain}} + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + authzURL := order.Authorizations[0] + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("dns-account-01 challenge not offered by server") + } + + // Add multiple wrong TXT records + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "wrong-digest-1") + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "wrong-digest-2") + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + t.Cleanup(func() { + _, _ = testSrvClient.RemoveDNSAccount01Response(c.Account.URL, domain) + }) + + _, err = c.Client.UpdateChallenge(c.Account, chal) + if err == nil { + t.Fatalf("updating challenge: expected error, got nil") + } + prob, ok := err.(acme.Problem) + if !ok { + t.Fatalf("updating challenge: expected acme.Problem error, got %T", err) + } + if prob.Type != "urn:ietf:params:acme:error:unauthorized" { + t.Fatalf("updating challenge: expected unauthorized error, got %s", prob.Type) + } + if !strings.Contains(prob.Detail, "Incorrect TXT record") { + t.Fatalf("updating challenge: expected Incorrect TXT record error, got %s", prob.Detail) + } +} + +func TestDNSAccount01MultipleTXTRecordsOneMatches(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + domain := random_domain() + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{{Type: "dns", Value: domain}} + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + authzURL := order.Authorizations[0] + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + chal, ok := auth.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("dns-account-01 challenge not offered by server") + } + + // Add multiple TXT records, one of which is correct + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "wrong-digest-1") + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, chal.KeyAuthorization) + if err != nil { + t.Fatalf("adding DNS response: %s", err) + } + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "wrong-digest-2") + 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: expected no error, got %s", err) + } + + // Check that the authorization status has changed + auth, err = c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization after challenge update: %s", err) + } + + if auth.Status != "valid" { + t.Fatalf("expected authorization status to be 'valid', got '%s'", auth.Status) + } +} + +func TestDNSAccount01WildcardDomain(t *testing.T) { + t.Parallel() + + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config" { + t.Skip("Test requires dns-account-01 to be enabled") + } + + hostDomain := randomDomain(t) + wildcardDomain := fmt.Sprintf("*.%s", randomDomain(t)) + + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + idents := []acme.Identifier{ + {Type: "dns", Value: hostDomain}, + {Type: "dns", Value: wildcardDomain}, + } + + order, err := c.Client.NewOrder(c.Account, idents) + if err != nil { + t.Fatalf("creating new order: %s", err) + } + + for _, authzURL := range order.Authorizations { + auth, err := c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization: %s", err) + } + + isWildcard := strings.HasPrefix(auth.Identifier.Value, "*.") + domain := auth.Identifier.Value + if isWildcard { + domain = strings.TrimPrefix(domain, "*.") + } + + chal, ok := auth.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) + } + + // Check that the authorization status has changed + auth, err = c.Client.FetchAuthorization(c.Account, authzURL) + if err != nil { + t.Fatalf("fetching authorization after challenge update: %s", err) + } + + if auth.Status != "valid" { + t.Fatalf("expected authorization status to be 'valid', got '%s'", auth.Status) + } + } +} diff --git a/test/v2_integration.py b/test/v2_integration.py index 0ada08d2922..45b84116bac 100644 --- a/test/v2_integration.py +++ b/test/v2_integration.py @@ -190,7 +190,7 @@ def test_failed_validation_limit(): threshold = 3 for _ in range(threshold): order = client.new_order(csr_pem) - chall = order.authorizations[0].body.challenges[0] + chall = chisel2.get_any_supported_chall(order.authorizations[0]) client.answer_challenge(chall, chall.response(client.net.key)) try: client.poll_and_finalize(order) @@ -606,8 +606,9 @@ def test_order_reuse_failed_authz(): order = client.new_order(csr_pem) firstOrderURI = order.uri - # Pick the first authz's first challenge, doesn't matter what type it is - chall_body = order.authorizations[0].body.challenges[0] + # Pick the first authz's first supported challenge, doesn't matter what + # type it is + chall_body = chisel2.get_any_supported_chall(order.authorizations[0]) # Answer it, but with nothing set up to solve the challenge request client.answer_challenge(chall_body, chall_body.response(client.net.key)) diff --git a/va/config/config.go b/va/config/config.go index e4faf4ce117..2fc376e45f7 100644 --- a/va/config/config.go +++ b/va/config/config.go @@ -28,6 +28,9 @@ type Common struct { DNSTimeout config.Duration `validate:"required"` DNSAllowLoopbackAddresses bool + // AccountURIPrefixes is a list of prefixes used to construct account URIs. + // The first prefix in the list is used for dns-account-01 challenges. + // All of the prefixes are used for CAA accounturi validation. AccountURIPrefixes []string `validate:"min=1,dive,required,url"` } diff --git a/va/dns.go b/va/dns.go index d1639d2a5f7..ba854f89f44 100644 --- a/va/dns.go +++ b/va/dns.go @@ -4,9 +4,12 @@ import ( "context" "crypto/sha256" "crypto/subtle" + "encoding/base32" "encoding/base64" + "errors" "fmt" "net/netip" + "strings" "github.com/letsencrypt/boulder/bdns" "github.com/letsencrypt/boulder/core" @@ -48,19 +51,64 @@ func availableAddresses(allAddrs []netip.Addr) (v4 []netip.Addr, v6 []netip.Addr return } +// validateDNSAccount01 handles the dns-account-01 challenge by calculating +// the account-specific DNS query domain and expected digest, then calling +// the common DNS validation logic. +// This implements draft-ietf-acme-dns-account-label-01, and is permitted by +// CAB/F Ballot SC-84, which was incorporated into BR v2.1.4. +func (va *ValidationAuthorityImpl) validateDNSAccount01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string, accountURI string) ([]core.ValidationRecord, error) { + if ident.Type != identifier.TypeDNS { + return nil, berrors.MalformedError("Identifier type for DNS-ACCOUNT-01 challenge was not DNS") + } + if accountURI == "" { + return nil, berrors.InternalServerError("accountURI must be provided for dns-account-01") + } + + // Calculate the DNS prefix label based on the account URI + sha256sum := sha256.Sum256([]byte(accountURI)) + prefixBytes := sha256sum[0:10] // First 10 bytes + prefixLabel := strings.ToLower(base32.StdEncoding.EncodeToString(prefixBytes)) + + // Construct the challenge prefix specific to DNS-ACCOUNT-01 + challengePrefix := fmt.Sprintf("_%s.%s", prefixLabel, core.DNSPrefix) + va.log.Debugf("DNS-ACCOUNT-01: Querying TXT for %q (derived from account URI %q)", fmt.Sprintf("%s.%s", challengePrefix, ident.Value), accountURI) + + // Call the common validation logic + records, err := va.validateDNS(ctx, ident, challengePrefix, keyAuthorization) + if err != nil { + // Check if the error returned by validateDNS is of the Unauthorized type + if errors.Is(err, berrors.Unauthorized) { + // Enrich any UnauthorizedError from validateDNS with the account URI + enrichedError := berrors.UnauthorizedError("%s (account: %q)", err.Error(), accountURI) + return nil, enrichedError + } + // For other error types, return as is + return nil, err + } + + return records, nil +} + func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string) ([]core.ValidationRecord, error) { if ident.Type != identifier.TypeDNS { - va.log.Infof("Identifier type for DNS challenge was not DNS: %s", ident) - return nil, berrors.MalformedError("Identifier type for DNS challenge was not DNS") + return nil, berrors.MalformedError("Identifier type for DNS-01 challenge was not DNS") } + // Call the common validation logic + return va.validateDNS(ctx, ident, core.DNSPrefix, keyAuthorization) +} + +// validateDNS performs the DNS TXT lookup and validation logic. +func (va *ValidationAuthorityImpl) validateDNS(ctx context.Context, ident identifier.ACMEIdentifier, challengePrefix string, keyAuthorization string) ([]core.ValidationRecord, error) { // Compute the digest of the key authorization file h := sha256.New() h.Write([]byte(keyAuthorization)) authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + // Construct the full challenge subdomain by concatenating prefix with identifier + challengeSubdomain := fmt.Sprintf("%s.%s", challengePrefix, ident.Value) + // Look for the required record in the DNS - challengeSubdomain := fmt.Sprintf("%s.%s", core.DNSPrefix, ident.Value) txts, resolvers, err := va.dnsClient.LookupTXT(ctx, challengeSubdomain) if err != nil { return nil, berrors.DNSError("%s", err) diff --git a/va/dns_account_test.go b/va/dns_account_test.go new file mode 100644 index 00000000000..9e652659ffc --- /dev/null +++ b/va/dns_account_test.go @@ -0,0 +1,155 @@ +// dns_account_test.go +package va + +import ( + "errors" + "net/netip" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/bdns" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +// Use a consistent test account URI, matching the example in the draft +const testAccountURI = "https://example.com/acme/acct/ExampleAccount" + +func TestDNSAccount01Validation(t *testing.T) { + testCases := []struct { + name string + ident identifier.ACMEIdentifier + wantErrType berrors.ErrorType + wantErrMsg string + }{ + { + name: "wrong TXT record", + ident: identifier.NewDNS("wrong-dns01.com"), + wantErrType: berrors.Unauthorized, + wantErrMsg: "Incorrect TXT record", + }, + { + name: "wrong TXT record with multiple values", + ident: identifier.NewDNS("wrong-many-dns01.com"), + wantErrType: berrors.Unauthorized, + wantErrMsg: "Incorrect TXT record", + }, + { + name: "wrong long TXT record", + ident: identifier.NewDNS("long-dns01.com"), + wantErrType: berrors.Unauthorized, + wantErrMsg: "Incorrect TXT record", + }, + { + name: "DNS failure on localhost", + ident: identifier.NewDNS("localhost"), + wantErrType: berrors.Unauthorized, + wantErrMsg: "Incorrect TXT record", + }, + { + name: "IP identifier not supported", + ident: identifier.NewIP(netip.MustParseAddr("127.0.0.1")), + wantErrType: berrors.Malformed, + wantErrMsg: "Identifier type for DNS-ACCOUNT-01 challenge was not DNS", + }, + { + name: "invalid identifier type", + ident: identifier.ACMEIdentifier{ + Type: identifier.IdentifierType("iris"), + Value: "790DB180-A274-47A4-855F-31C428CB1072", + }, + wantErrType: berrors.Malformed, + wantErrMsg: "Identifier type for DNS-ACCOUNT-01 challenge was not DNS", + }, + { + name: "DNS server failure", + ident: identifier.NewDNS("servfail.com"), + wantErrType: berrors.DNS, + wantErrMsg: "SERVFAIL", + }, + { + name: "valid DNS record", + ident: identifier.NewDNS("good-dns01.com"), + }, + { + name: "valid DNS record with no authority", + ident: identifier.NewDNS("no-authority-dns01.com"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + _, err := va.validateDNSAccount01(ctx, tc.ident, expectedKeyAuthorization, testAccountURI) + + if tc.wantErrMsg != "" { + if err == nil { + t.Fatalf("validateDNSAccount01(%q) = success, but want error %q", tc.ident.Value, tc.wantErrMsg) + } + if !errors.Is(err, tc.wantErrType) { + t.Errorf("validateDNSAccount01(%q) = error type %T, but want error type %T", tc.ident.Value, err, tc.wantErrType) + } + prob := detailedError(err) + if !strings.Contains(prob.String(), tc.wantErrMsg) { + t.Errorf("validateDNSAccount01(%q) = %q, but want error containing %q", tc.ident.Value, prob.String(), tc.wantErrMsg) + } + } else { + if err != nil { + t.Errorf("validateDNSAccount01(%q) = %v, but want success", tc.ident.Value, err) + } + } + }) + } +} + +func TestDNSAccount01ValidationNoServer(t *testing.T) { + va, log := setup(nil, "", nil, nil) + staticProvider, err := bdns.NewStaticProvider([]string{}) + test.AssertNotError(t, err, "Couldn't make new static provider") + + va.dnsClient = bdns.NewTest( + time.Second*5, + staticProvider, + metrics.NoopRegisterer, + clock.New(), + 1, + "", + log, + nil) + + _, err = va.validateDNSAccount01(ctx, identifier.NewDNS("localhost"), expectedKeyAuthorization, testAccountURI) + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) +} + +func TestDNSAccount01ValidationEmptyAccountURI(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + // The specific domain doesn't matter, as the function should + // reject the empty accountURI before DNS lookup. + ident := identifier.NewDNS("empty-uri-test.com") + + // Call the validation function with an empty accountURI + _, err := va.validateDNSAccount01(ctx, ident, expectedKeyAuthorization, "") + + // Assert that an error was returned + if err == nil { + t.Errorf("validateDNSAccount01(%q) = success, but want error", ident.Value) + return + } + + // Assert the specific error type + test.AssertErrorIs(t, err, berrors.InternalServer) + + // Assert the specific error message using strings.Contains + wantErrMsg := "accountURI must be provided for dns-account-01" + if !strings.Contains(err.Error(), wantErrMsg) { + t.Errorf("validateDNSAccount01(%q) = %q, but want error containing %q", ident.Value, err.Error(), wantErrMsg) + } +} diff --git a/va/dns_test.go b/va/dns_test.go index ebaa8107176..1f0a22af81f 100644 --- a/va/dns_test.go +++ b/va/dns_test.go @@ -16,7 +16,7 @@ import ( "github.com/letsencrypt/boulder/test" ) -func TestDNSValidationWrong(t *testing.T) { +func TestDNS01ValidationWrong(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(context.Background(), identifier.NewDNS("wrong-dns01.com"), expectedKeyAuthorization) if err == nil { @@ -26,7 +26,7 @@ func TestDNSValidationWrong(t *testing.T) { test.AssertEquals(t, prob.String(), "unauthorized :: Incorrect TXT record \"a\" found at _acme-challenge.wrong-dns01.com") } -func TestDNSValidationWrongMany(t *testing.T) { +func TestDNS01ValidationWrongMany(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(context.Background(), identifier.NewDNS("wrong-many-dns01.com"), expectedKeyAuthorization) @@ -37,7 +37,7 @@ func TestDNSValidationWrongMany(t *testing.T) { test.AssertEquals(t, prob.String(), "unauthorized :: Incorrect TXT record \"a\" (and 4 more) found at _acme-challenge.wrong-many-dns01.com") } -func TestDNSValidationWrongLong(t *testing.T) { +func TestDNS01ValidationWrongLong(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(context.Background(), identifier.NewDNS("long-dns01.com"), expectedKeyAuthorization) @@ -48,7 +48,7 @@ func TestDNSValidationWrongLong(t *testing.T) { test.AssertEquals(t, prob.String(), "unauthorized :: Incorrect TXT record \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\" found at _acme-challenge.long-dns01.com") } -func TestDNSValidationFailure(t *testing.T) { +func TestDNS01ValidationFailure(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(ctx, identifier.NewDNS("localhost"), expectedKeyAuthorization) @@ -57,7 +57,7 @@ func TestDNSValidationFailure(t *testing.T) { test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) } -func TestDNSValidationIP(t *testing.T) { +func TestDNS01ValidationIP(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(ctx, identifier.NewIP(netip.MustParseAddr("127.0.0.1")), expectedKeyAuthorization) @@ -66,7 +66,7 @@ func TestDNSValidationIP(t *testing.T) { test.AssertEquals(t, prob.Type, probs.MalformedProblem) } -func TestDNSValidationInvalid(t *testing.T) { +func TestDNS01ValidationInvalid(t *testing.T) { var notDNS = identifier.ACMEIdentifier{ Type: identifier.IdentifierType("iris"), Value: "790DB180-A274-47A4-855F-31C428CB1072", @@ -80,7 +80,7 @@ func TestDNSValidationInvalid(t *testing.T) { test.AssertEquals(t, prob.Type, probs.MalformedProblem) } -func TestDNSValidationServFail(t *testing.T) { +func TestDNS01ValidationServFail(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, err := va.validateDNS01(ctx, identifier.NewDNS("servfail.com"), expectedKeyAuthorization) @@ -89,7 +89,7 @@ func TestDNSValidationServFail(t *testing.T) { test.AssertEquals(t, prob.Type, probs.DNSProblem) } -func TestDNSValidationNoServer(t *testing.T) { +func TestDNS01ValidationNoServer(t *testing.T) { va, log := setup(nil, "", nil, nil) staticProvider, err := bdns.NewStaticProvider([]string{}) test.AssertNotError(t, err, "Couldn't make new static provider") @@ -109,7 +109,7 @@ func TestDNSValidationNoServer(t *testing.T) { test.AssertEquals(t, prob.Type, probs.DNSProblem) } -func TestDNSValidationOK(t *testing.T) { +func TestDNS01ValidationOK(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, prob := va.validateDNS01(ctx, identifier.NewDNS("good-dns01.com"), expectedKeyAuthorization) @@ -117,7 +117,7 @@ func TestDNSValidationOK(t *testing.T) { test.Assert(t, prob == nil, "Should be valid.") } -func TestDNSValidationNoAuthorityOK(t *testing.T) { +func TestDNS01ValidationNoAuthorityOK(t *testing.T) { va, _ := setup(nil, "", nil, nil) _, prob := va.validateDNS01(ctx, identifier.NewDNS("no-authority-dns01.com"), expectedKeyAuthorization) diff --git a/va/va.go b/va/va.go index 4307e57b4ca..4993aec3602 100644 --- a/va/va.go +++ b/va/va.go @@ -26,6 +26,7 @@ import ( "github.com/letsencrypt/boulder/core" corepb "github.com/letsencrypt/boulder/core/proto" berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" bgrpc "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/identifier" blog "github.com/letsencrypt/boulder/log" @@ -407,12 +408,15 @@ func (va *ValidationAuthorityImpl) isPrimaryVA() bool { // validateChallenge simply passes through to the appropriate validation method // depending on the challenge type. +// The accountURI parameter is required for dns-account-01 challenges to +// calculate the account-specific label. func (va *ValidationAuthorityImpl) validateChallenge( ctx context.Context, ident identifier.ACMEIdentifier, kind core.AcmeChallenge, token string, keyAuthorization string, + accountURI string, ) ([]core.ValidationRecord, error) { switch kind { case core.ChallengeTypeHTTP01: @@ -423,6 +427,12 @@ func (va *ValidationAuthorityImpl) validateChallenge( return va.validateDNS01(ctx, ident, keyAuthorization) case core.ChallengeTypeTLSALPN01: return va.validateTLSALPN01(ctx, ident, keyAuthorization) + case core.ChallengeTypeDNSAccount01: + if features.Get().DNSAccount01Enabled { + // Strip a (potential) leading wildcard token from the identifier. + ident.Value = strings.TrimPrefix(ident.Value, "*.") + return va.validateDNSAccount01(ctx, ident, keyAuthorization, accountURI) + } } return nil, berrors.MalformedError("invalid challenge type %s", kind) } @@ -652,7 +662,7 @@ type validationLogEvent struct { // implements the DCV portion of Multi-Perspective Issuance Corroboration as // defined in BRs Sections 3.2.2.9 and 5.4.1. func (va *ValidationAuthorityImpl) DoDCV(ctx context.Context, req *vapb.PerformValidationRequest) (*vapb.ValidationResult, error) { - if core.IsAnyNilOrZero(req, req.Identifier, req.Challenge, req.Authz, req.ExpectedKeyAuthorization) { + if core.IsAnyNilOrZero(req, req.Identifier, req.Challenge, req.Authz, req.Authz.RegID, req.ExpectedKeyAuthorization) { return nil, berrors.InternalServerError("Incomplete validation request") } @@ -706,6 +716,12 @@ func (va *ValidationAuthorityImpl) DoDCV(ctx context.Context, req *vapb.PerformV va.log.AuditObject("Validation result", logEvent) }() + // For dns-account-01 challenges, construct the account URI from the configured prefix + var accountURI string + if chall.Type == core.ChallengeTypeDNSAccount01 && features.Get().DNSAccount01Enabled { + accountURI = fmt.Sprintf("%s%d", va.accountURIPrefixes[0], req.Authz.RegID) + } + // Do local validation. Note that we process the result in a couple ways // *before* checking whether it returned an error. These few checks are // carefully written to ensure that they work whether the local validation @@ -716,6 +732,7 @@ func (va *ValidationAuthorityImpl) DoDCV(ctx context.Context, req *vapb.PerformV chall.Type, chall.Token, req.ExpectedKeyAuthorization, + accountURI, ) // Stop the clock for local validation latency. diff --git a/va/va_test.go b/va/va_test.go index df0526e50bd..965b91d574e 100644 --- a/va/va_test.go +++ b/va/va_test.go @@ -377,7 +377,7 @@ func TestPerformValidationWithMismatchedRemoteVARIRs(t *testing.T) { func TestValidateMalformedChallenge(t *testing.T) { va, _ := setup(nil, "", nil, nil) - _, err := va.validateChallenge(ctx, identifier.NewDNS("example.com"), "fake-type-01", expectedToken, expectedKeyAuthorization) + _, err := va.validateChallenge(ctx, identifier.NewDNS("example.com"), "fake-type-01", expectedToken, expectedKeyAuthorization, testAccountURI) prob := detailedError(err) test.AssertEquals(t, prob.Type, probs.MalformedProblem)