Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a8f788c
feat: Add core definitions for dns-account-01 challenge type
sheurich May 2, 2025
3f0ddb9
chore: Update vendor dependency `github.com/eggsampler/acme/v3` v3.6.2
sheurich May 2, 2025
60e6787
feat: Add dns-account-01 challenge type to SA model
sheurich May 6, 2025
6726be6
test: Add dns-account-01 challenge test case to TestGetValidAuthoriza…
sheurich May 28, 2025
0210f6b
Merge branch 'main' into feat-dns-account-01-core
sheurich Jul 23, 2025
0332291
feat: Add `DNSAccount01Enabled` feature flag in `features/features.go`
sheurich May 2, 2025
4d71b1b
feat: Update Policy Authority to offer dns-account-01 challenge
sheurich Apr 23, 2025
a848397
refactor(va): Extract common DNS validation logic
sheurich Apr 24, 2025
90fca9f
feat(va): Implement dns-account-01 challenge validation
sheurich Apr 25, 2025
3f737cd
test: Ignore dns-account-01 challenges in Python-based tests
sheurich Apr 28, 2025
db7d2f1
feat(va): Add routing for dns-account-01 challenge validation
sheurich Apr 28, 2025
605f8b5
refactor(va): rename testAccountURL to testAccountURI for naming cons…
sheurich Apr 28, 2025
a8e38a4
Enable dns-account-01 in config-next
sheurich May 2, 2025
dd38376
test: Add and update tests for dns-account-01
sheurich May 6, 2025
71d9f54
Complete dns-account-01 integration: remove gRPC protocol churn and u…
sheurich Jun 19, 2025
bc00ba8
Merge branch 'main' into feat-dns-account-01-core
sheurich Jul 28, 2025
0c90ee9
Merge branch 'feat-dns-account-01-core' into feat-dns-account-01-mod-val
sheurich Jul 28, 2025
ba43af2
Update sa/sa_test.go
sheurich Jul 28, 2025
bdc9a31
Merge branch 'feat-dns-account-01-core' into feat-dns-account-01-mod-val
sheurich Jul 28, 2025
72db95e
Remove TestDNSAccount01Metrics due to improve test clarity
sheurich Jul 29, 2025
a381b6a
Remove dnsAccountChallengeURIPrefix, use AccountURIPrefixes[0] instead
sheurich Jul 29, 2025
bdb1922
Merge branch 'main' into feat-dns-account-01-mod-val
aarongable Jul 31, 2025
8237818
Moved "features" stanza containing "DNSAccount01Enabled": true to the…
sheurich Aug 1, 2025
fde121f
Rejoined split comment that was accidentally broken by newly-inserted…
sheurich Aug 1, 2025
59a7990
va: clean up dns-account-01 implementation
sheurich Aug 1, 2025
e782fd2
va: refactor dns-account-01 tests to follow Go best practices
sheurich Aug 1, 2025
27fee28
integration: improve dns-account-01 test reliability and simplicity
sheurich Aug 1, 2025
9345254
feat(va): Reference CAB/F ballot in dns-account-01 comment
sheurich Aug 1, 2025
8cde75e
Update va/dns.go
sheurich Aug 4, 2025
8bb4e78
refactor(va): update TestDNSAccount01Validation to use error types fo…
sheurich Aug 4, 2025
a9fde95
Update va/dns_account_test.go
sheurich Aug 5, 2025
7bdf48d
Merge branch 'main' into feat-dns-account-01-mod-val
sheurich Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions bdns/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
6 changes: 6 additions & 0 deletions features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 16 additions & 7 deletions policy/pa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
161 changes: 114 additions & 47 deletions policy/pa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions test/chall-test-srv-client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package challtestsrvclient
import (
"bytes"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading