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/boulder-va/main.go b/cmd/boulder-va/main.go index e8bf768bfa8..d9ff8f032f5 100644 --- a/cmd/boulder-va/main.go +++ b/cmd/boulder-va/main.go @@ -147,6 +147,7 @@ func main() { clk, logger, c.VA.AccountURIPrefixes, + c.VA.DNSAccountChallengeURIPrefix, va.PrimaryPerspective, "", policy.IsReservedIP) diff --git a/cmd/config.go b/cmd/config.go index 13842fdf9b2..ffff0e846ff 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -103,7 +103,7 @@ type SMTPConfig struct { // 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/cmd/remoteva/main.go b/cmd/remoteva/main.go index 8c142bd688f..5cfbed8e2f3 100644 --- a/cmd/remoteva/main.go +++ b/cmd/remoteva/main.go @@ -136,6 +136,7 @@ func main() { clk, logger, c.RVA.AccountURIPrefixes, + c.RVA.DNSAccountChallengeURIPrefix, c.RVA.Perspective, c.RVA.RIR, policy.IsReservedIP) diff --git a/features/features.go b/features/features.go index 84e8df50dd1..fc96007d544 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 fe80555f905..1d5bf1e4b72 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" @@ -577,20 +578,28 @@ func (pa *AuthorityImpl) checkHostLists(domain string) 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 eaa1154ae84..663d308d244 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{ @@ -445,56 +446,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 7229bae422f..0ec2108f921 100644 --- a/test/config-next/ra.json +++ b/test/config-next/ra.json @@ -159,6 +159,7 @@ "features": { "AsyncFinalize": true, "AutomaticallyPauseZombieClients": true, + "DNSAccount01Enabled": true, "NoPendingAuthzReuse": true }, "ctLogs": { @@ -190,7 +191,8 @@ "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..caf87148399 100644 --- a/test/config-next/remoteva-a.json +++ b/test/config-next/remoteva-a.json @@ -34,10 +34,14 @@ } } }, + "features": { + "DNSAccount01Enabled": true + }, "accountURIPrefixes": [ "http://boulder.service.consul:4000/acme/reg/", "http://boulder.service.consul:4001/acme/acct/" ], + "dnsAccountChallengeURIPrefix": "http://boulder.service.consul:4001/acme/acct/", "perspective": "dadaist", "rir": "ARIN" }, diff --git a/test/config-next/remoteva-b.json b/test/config-next/remoteva-b.json index 7595a8b4e58..00efac5e984 100644 --- a/test/config-next/remoteva-b.json +++ b/test/config-next/remoteva-b.json @@ -34,10 +34,14 @@ } } }, + "features": { + "DNSAccount01Enabled": true + }, "accountURIPrefixes": [ "http://boulder.service.consul:4000/acme/reg/", "http://boulder.service.consul:4001/acme/acct/" ], + "dnsAccountChallengeURIPrefix": "http://boulder.service.consul:4001/acme/acct/", "perspective": "surrealist", "rir": "RIPE" }, diff --git a/test/config-next/remoteva-c.json b/test/config-next/remoteva-c.json index a5ca7ffa5c7..5651bc7eeff 100644 --- a/test/config-next/remoteva-c.json +++ b/test/config-next/remoteva-c.json @@ -34,10 +34,14 @@ } } }, + "features": { + "DNSAccount01Enabled": true + }, "accountURIPrefixes": [ "http://boulder.service.consul:4000/acme/reg/", "http://boulder.service.consul:4001/acme/acct/" ], + "dnsAccountChallengeURIPrefix": "http://boulder.service.consul:4001/acme/acct/", "perspective": "cubist", "rir": "ARIN" }, diff --git a/test/config-next/va.json b/test/config-next/va.json index a0bef772ec8..dd38096cc7d 100644 --- a/test/config-next/va.json +++ b/test/config-next/va.json @@ -36,6 +36,9 @@ } } }, + "features": { + "DNSAccount01Enabled": true + }, "remoteVAs": [ { "serverAddress": "rva1.service.consul:9397", @@ -62,7 +65,8 @@ "accountURIPrefixes": [ "http://boulder.service.consul:4000/acme/reg/", "http://boulder.service.consul:4001/acme/acct/" - ] + ], + "dnsAccountChallengeURIPrefix": "http://boulder.service.consul:4001/acme/acct/" }, "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..83e59b884a3 --- /dev/null +++ b/test/integration/dns_account_01_test.go @@ -0,0 +1,464 @@ +//go:build integration + +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "strings" + "testing" + + "github.com/eggsampler/acme/v3" +) + +func TestDNSAccount01HappyPath(t *testing.T) { + t.Parallel() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + _, 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) + } + + csrKey, err := makeCSR(nil, idents, true) + if err != nil { + t.Fatalf("making CSR: %s", err) + } + + order, err = c.Client.FinalizeOrder(c.Account, order, csrKey) + if err != nil { + t.Fatalf("finalizing order: %s", err) + } + + certs, err := c.Client.FetchCertificates(c.Account, order.Certificate) + if err != nil { + t.Fatalf("fetching certificates: %s", err) + } + + if len(certs) == 0 { + t.Fatal("no certificates returned") + } + + found := false + for _, name := range certs[0].DNSNames { + if name == domain { + found = true + break + } + } + if !found { + t.Errorf("certificate doesn't contain domain %s", domain) + } +} + +func TestDNSAccount01WrongTXTRecord(t *testing.T) { + t.Parallel() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + // 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() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + // 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() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + // 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() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + // 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) + } +} + +func TestDNSAccount01WildcardDomain(t *testing.T) { + t.Parallel() + + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + _, 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) + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating cert key: %s", err) + } + + csr, err := makeCSR(key, idents, false) + if err != nil { + t.Fatalf("making CSR: %s", err) + } + + order, err = c.Client.FinalizeOrder(c.Account, order, csr) + if err != nil { + t.Fatalf("finalizing order: %s", err) + } + + certs, err := c.Client.FetchCertificates(c.Account, order.Certificate) + if err != nil { + t.Fatalf("fetching certificates: %s", err) + } + + if len(certs) == 0 { + t.Fatal("no certificates returned") + } + + foundHost := false + foundWildcard := false + for _, name := range certs[0].DNSNames { + if name == hostDomain { + foundHost = true + } + if name == wildcardDomain { + foundWildcard = true + } + } + + if !foundHost { + t.Errorf("certificate doesn't contain host domain %s", hostDomain) + } + if !foundWildcard { + t.Errorf("certificate doesn't contain wildcard domain %s", wildcardDomain) + } +} + +func TestDNSAccount01Metrics(t *testing.T) { + t.Parallel() + + domain := random_domain() + + c, err := makeClient() + if err != nil { + t.Fatalf("creating client: %s", err) + } + + order, err := c.Client.NewOrder(c.Account, []acme.Identifier{{Type: "dns", Value: domain}}) + 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.Skip("DNS-Account-01 is not offered, skipping test") + } + + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, "incorrect-value") + 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.Fatal("expected validation to fail, but it succeeded") + } + + 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) + } + + newOrder, err := c.Client.NewOrder(c.Account, []acme.Identifier{{Type: "dns", Value: domain}}) + if err != nil { + t.Fatalf("creating new order for successful test: %s", err) + } + + newAuthzURL := newOrder.Authorizations[0] + newAuth, err := c.Client.FetchAuthorization(c.Account, newAuthzURL) + if err != nil { + t.Fatalf("fetching new authorization: %s", err) + } + + newChal, ok := newAuth.ChallengeMap[acme.ChallengeTypeDNSAccount01] + if !ok { + t.Fatal("DNS-Account-01 challenge not found in new authorization") + } + + _, err = testSrvClient.AddDNSAccount01Response(c.Account.URL, domain, newChal.KeyAuthorization) + if err != nil { + t.Fatalf("adding DNS response for new challenge: %s", err) + } + + newChal, err = c.Client.UpdateChallenge(c.Account, newChal) + if err != nil { + t.Fatalf("updating new challenge: %s", err) + } + + if newChal.Status != "valid" { + t.Fatalf("expected new challenge status to be 'valid', got: %s", newChal.Status) + } +} diff --git a/test/v2_integration.py b/test/v2_integration.py index 39eebb6419b..965f02cb851 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..d481ffcff4f 100644 --- a/va/config/config.go +++ b/va/config/config.go @@ -28,7 +28,8 @@ type Common struct { DNSTimeout config.Duration `validate:"required"` DNSAllowLoopbackAddresses bool - AccountURIPrefixes []string `validate:"min=1,dive,required,url"` + AccountURIPrefixes []string `validate:"min=1,dive,required,url"` + DNSAccountChallengeURIPrefix string `validate:"omitempty,url"` } // SetDefaultsAndValidate performs some basic sanity checks on fields stored in diff --git a/va/dns.go b/va/dns.go index d1639d2a5f7..0c108e68327 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,65 @@ 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. +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 == "" { + va.log.Infof("DNS-ACCOUNT-01 validation for %q failed: missing accountURI", ident.Value) + 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 := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(prefixBytes) + prefixLabel = strings.ToLower(prefixLabel) + + // Construct the full query domain specific to DNS-ACCOUNT-01 + challengeSubdomain := fmt.Sprintf("_%s.%s.%s", prefixLabel, core.DNSPrefix, ident.Value) + va.log.Debugf("DNS-ACCOUNT-01: Querying TXT for %q (derived from account URI %q)", challengeSubdomain, accountURI) + + // Call the common validation logic + records, err := va.validateDNS(ctx, ident, challengeSubdomain, 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: %s)", 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") + va.log.Infof("Identifier type for DNS-01 challenge was not DNS: %s", ident) + return nil, berrors.MalformedError("Identifier type for DNS-01 challenge was not DNS") } + // Construct the query domain specific to DNS-01 + challengeSubdomain := fmt.Sprintf("%s.%s", core.DNSPrefix, ident.Value) + + // Call the common validation logic + return va.validateDNS(ctx, ident, challengeSubdomain, keyAuthorization) +} + +// validateDNS performs the DNS TXT lookup and validation logic. +func (va *ValidationAuthorityImpl) validateDNS(ctx context.Context, ident identifier.ACMEIdentifier, challengeSubdomain 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)) // 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..3f304468a53 --- /dev/null +++ b/va/dns_account_test.go @@ -0,0 +1,164 @@ +// dns_account_test.go +package va + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/bdns" + "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" + +// Expected label prefix derived from testAccountURI (as per draft example) +const expectedLabelPrefix = "_ujmmovf2vn55tgye._acme-challenge" + +func TestDNSAccount01ValidationWrong(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + _, err := va.validateDNSAccount01(context.Background(), identifier.NewDNS("wrong-dns01.com"), expectedKeyAuthorization, testAccountURI) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + expectedErr := "unauthorized :: Incorrect TXT record \"a\" found at " + expectedLabelPrefix + ".wrong-dns01.com" + + " (account: " + testAccountURI + ")" + test.AssertEquals(t, prob.String(), expectedErr) +} + +func TestDNSAccount01ValidationWrongMany(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(context.Background(), identifier.NewDNS("wrong-many-dns01.com"), expectedKeyAuthorization, testAccountURI) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + expectedErr := "unauthorized :: Incorrect TXT record \"a\" (and 4 more) found at " + expectedLabelPrefix + ".wrong-many-dns01.com" + + " (account: " + testAccountURI + ")" + test.AssertEquals(t, prob.String(), expectedErr) +} + +func TestDNSAccount01ValidationWrongLong(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(context.Background(), identifier.NewDNS("long-dns01.com"), expectedKeyAuthorization, testAccountURI) + if err == nil { + t.Fatalf("Successful DNS validation with wrong TXT record") + } + prob := detailedError(err) + expectedErr := "unauthorized :: Incorrect TXT record \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\" found at " + expectedLabelPrefix + ".long-dns01.com" + + " (account: " + testAccountURI + ")" + test.AssertEquals(t, prob.String(), expectedErr) +} + +func TestDNSAccount01ValidationFailure(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(ctx, identifier.NewDNS("localhost"), expectedKeyAuthorization, testAccountURI) + prob := detailedError(err) + + test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem) + + expectedErr := "unauthorized :: Incorrect TXT record \"hostname\" found at " + expectedLabelPrefix + ".localhost" + + " (account: " + testAccountURI + ")" + test.AssertEquals(t, prob.String(), expectedErr) +} + +func TestDNSAccount01ValidationIP(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(ctx, identifier.NewIP(netip.MustParseAddr("127.0.0.1")), expectedKeyAuthorization, testAccountURI) + prob := detailedError(err) + + test.AssertEquals(t, prob.Type, probs.MalformedProblem) +} + +func TestDNSAccount01ValidationInvalid(t *testing.T) { + var notDNS = identifier.ACMEIdentifier{ + Type: identifier.IdentifierType("iris"), + Value: "790DB180-A274-47A4-855F-31C428CB1072", + } + + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(ctx, notDNS, expectedKeyAuthorization, testAccountURI) + prob := detailedError(err) + + test.AssertEquals(t, prob.Type, probs.MalformedProblem) +} + +func TestDNSAccount01ValidationServFail(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, err := va.validateDNSAccount01(ctx, identifier.NewDNS("servfail.com"), expectedKeyAuthorization, testAccountURI) + + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.DNSProblem) +} + +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 TestDNSAccount01ValidationOK(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, prob := va.validateDNSAccount01(ctx, identifier.NewDNS("good-dns01.com"), expectedKeyAuthorization, testAccountURI) + + test.Assert(t, prob == nil, "Should be valid.") +} + +func TestDNSAccount01ValidationNoAuthorityOK(t *testing.T) { + va, _ := setup(nil, "", nil, nil) + + _, prob := va.validateDNSAccount01(ctx, identifier.NewDNS("no-authority-dns01.com"), expectedKeyAuthorization, testAccountURI) + + test.Assert(t, prob == nil, "Should be valid.") +} + +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 + test.Assert(t, err != nil, "validateDNSAccount01 succeeded unexpectedly with an empty account URI") + + // Assert the specific error type + prob := detailedError(err) + test.AssertEquals(t, prob.Type, probs.ConnectionProblem) + + // Assert the specific error message + expectedErrMsg := "connection :: Error getting validation data" + test.AssertEquals(t, prob.String(), expectedErrMsg) +} 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..aa5ec7764de 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" @@ -203,21 +204,22 @@ func newDefaultPortConfig() *portConfig { type ValidationAuthorityImpl struct { vapb.UnsafeVAServer vapb.UnsafeCAAServer - log blog.Logger - dnsClient bdns.Client - issuerDomain string - httpPort int - httpsPort int - tlsPort int - userAgent string - clk clock.Clock - remoteVAs []RemoteVA - maxRemoteFailures int - accountURIPrefixes []string - singleDialTimeout time.Duration - perspective string - rir string - isReservedIPFunc func(netip.Addr) error + log blog.Logger + dnsClient bdns.Client + issuerDomain string + httpPort int + httpsPort int + tlsPort int + userAgent string + clk clock.Clock + remoteVAs []RemoteVA + maxRemoteFailures int + accountURIPrefixes []string + dnsAccountChallengeURIPrefix string + singleDialTimeout time.Duration + perspective string + rir string + isReservedIPFunc func(netip.Addr) error metrics *vaMetrics } @@ -235,6 +237,7 @@ func NewValidationAuthorityImpl( clk clock.Clock, logger blog.Logger, accountURIPrefixes []string, + dnsAccountChallengeURIPrefix string, perspective string, rir string, reservedIPChecker func(netip.Addr) error, @@ -255,18 +258,19 @@ func NewValidationAuthorityImpl( pc := newDefaultPortConfig() va := &ValidationAuthorityImpl{ - log: logger, - dnsClient: resolver, - issuerDomain: issuerDomain, - httpPort: pc.HTTPPort, - httpsPort: pc.HTTPSPort, - tlsPort: pc.TLSPort, - userAgent: userAgent, - clk: clk, - metrics: initMetrics(stats), - remoteVAs: remoteVAs, - maxRemoteFailures: maxAllowedFailures(len(remoteVAs)), - accountURIPrefixes: accountURIPrefixes, + log: logger, + dnsClient: resolver, + issuerDomain: issuerDomain, + httpPort: pc.HTTPPort, + httpsPort: pc.HTTPSPort, + tlsPort: pc.TLSPort, + userAgent: userAgent, + clk: clk, + metrics: initMetrics(stats), + remoteVAs: remoteVAs, + maxRemoteFailures: maxAllowedFailures(len(remoteVAs)), + accountURIPrefixes: accountURIPrefixes, + dnsAccountChallengeURIPrefix: dnsAccountChallengeURIPrefix, // singleDialTimeout specifies how long an individual `DialContext` operation may take // before timing out. This timeout ignores the base RPC timeout and is strictly // used for the DialContext operations that take place during an @@ -407,12 +411,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 +430,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) } @@ -707,6 +720,14 @@ func (va *ValidationAuthorityImpl) DoDCV(ctx context.Context, req *vapb.PerformV }() // Do local validation. Note that we process the result in a couple ways + // For dns-account-01 challenges, construct the account URI from the configured prefix + var accountURI string + if chall.Type == core.ChallengeTypeDNSAccount01 && features.Get().DNSAccount01Enabled { + if va.dnsAccountChallengeURIPrefix != "" && req.Authz.RegID != 0 { + accountURI = fmt.Sprintf("%s%d", va.dnsAccountChallengeURIPrefix, req.Authz.RegID) + } + } + // *before* checking whether it returned an error. These few checks are // carefully written to ensure that they work whether the local validation // was successful or not, and cannot themselves fail. @@ -716,6 +737,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 56a58dee229..42bd1b4c45e 100644 --- a/va/va_test.go +++ b/va/va_test.go @@ -142,6 +142,7 @@ func setup(srv *httptest.Server, userAgent string, remoteVAs []RemoteVA, mockDNS fc, logger, accountURIPrefixes, + "https://acme-v01.api.letsencrypt.org/acme/acct/", perspective, "", isNonLoopbackReservedIP, @@ -320,6 +321,7 @@ func TestNewValidationAuthorityImplWithDuplicateRemotes(t *testing.T) { clock.NewFake(), blog.NewMock(), accountURIPrefixes, + "https://acme-v01.api.letsencrypt.org/acme/acct/", "example perspective", "", isNonLoopbackReservedIP, @@ -377,7 +379,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)