Skip to content

Commit 326a778

Browse files
committed
Merge branch 'main' of github.com:letsencrypt/boulder into ghcr
2 parents aa75667 + 84207fe commit 326a778

File tree

23 files changed

+935
-107
lines changed

23 files changed

+935
-107
lines changed

bdns/mocks.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,43 @@ type MockClient struct {
2020

2121
// LookupTXT is a mock
2222
func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string, ResolverAddrs, error) {
23+
// Use the example account-specific label prefix derived from
24+
// "https://example.com/acme/acct/ExampleAccount"
25+
const accountLabelPrefix = "_ujmmovf2vn55tgye._acme-challenge"
26+
27+
if hostname == accountLabelPrefix+".servfail.com" {
28+
// Mirror dns-01 servfail behaviour
29+
return nil, ResolverAddrs{"MockClient"}, fmt.Errorf("SERVFAIL")
30+
}
31+
if hostname == accountLabelPrefix+".good-dns01.com" {
32+
// Mirror dns-01 good record
33+
// base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"
34+
// + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI"))
35+
return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil
36+
}
37+
if hostname == accountLabelPrefix+".wrong-dns01.com" {
38+
// Mirror dns-01 wrong record
39+
return []string{"a"}, ResolverAddrs{"MockClient"}, nil
40+
}
41+
if hostname == accountLabelPrefix+".wrong-many-dns01.com" {
42+
// Mirror dns-01 wrong-many record
43+
return []string{"a", "b", "c", "d", "e"}, ResolverAddrs{"MockClient"}, nil
44+
}
45+
if hostname == accountLabelPrefix+".long-dns01.com" {
46+
// Mirror dns-01 long record
47+
return []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, ResolverAddrs{"MockClient"}, nil
48+
}
49+
if hostname == accountLabelPrefix+".no-authority-dns01.com" {
50+
// Mirror dns-01 no-authority good record
51+
// base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"
52+
// + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI"))
53+
return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil
54+
}
55+
if hostname == accountLabelPrefix+".empty-txts.com" {
56+
// Mirror dns-01 zero TXT records
57+
return []string{}, ResolverAddrs{"MockClient"}, nil
58+
}
59+
2360
if hostname == "_acme-challenge.servfail.com" {
2461
return nil, ResolverAddrs{"MockClient"}, fmt.Errorf("SERVFAIL")
2562
}
@@ -48,6 +85,8 @@ func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string,
4885
if hostname == "_acme-challenge.empty-txts.com" {
4986
return []string{}, ResolverAddrs{"MockClient"}, nil
5087
}
88+
89+
// Default fallback
5190
return []string{"hostname"}, ResolverAddrs{"MockClient"}, nil
5291
}
5392

cmd/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (d *DBConfig) URL() (string, error) {
9494
// it should offer.
9595
type PAConfig struct {
9696
DBConfig `validate:"-"`
97-
Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01,endkeys"`
97+
Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01 dns-account-01,endkeys"`
9898
Identifiers map[identifier.IdentifierType]bool `validate:"omitempty,dive,keys,oneof=dns ip,endkeys"`
9999
}
100100

features/features.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ type Config struct {
8080
// StoreARIReplacesInOrders causes the SA to store and retrieve the optional
8181
// ARI replaces field in the orders table.
8282
StoreARIReplacesInOrders bool
83+
84+
// DNSAccount01Enabled controls support for the dns-account-01 challenge
85+
// type. When enabled, the server can offer and validate this challenge
86+
// during certificate issuance. This flag must be set to true in the
87+
// RA, VA, and WFE2 services for full functionality.
88+
DNSAccount01Enabled bool
8389
}
8490

8591
var fMu = new(sync.RWMutex)

policy/pa.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/letsencrypt/boulder/core"
2020
berrors "github.com/letsencrypt/boulder/errors"
21+
"github.com/letsencrypt/boulder/features"
2122
"github.com/letsencrypt/boulder/iana"
2223
"github.com/letsencrypt/boulder/identifier"
2324
blog "github.com/letsencrypt/boulder/log"
@@ -606,20 +607,28 @@ func (pa *AuthorityImpl) checkBlocklists(ident identifier.ACMEIdentifier) error
606607
func (pa *AuthorityImpl) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) {
607608
switch ident.Type {
608609
case identifier.TypeDNS:
609-
// If the identifier is for a DNS wildcard name we only provide a DNS-01
610-
// challenge, to comply with the BRs Sections 3.2.2.4.19 and 3.2.2.4.20
611-
// stating that ACME HTTP-01 and TLS-ALPN-01 are not suitable for validating
612-
// Wildcard Domains.
610+
// If the identifier is for a DNS wildcard name we only provide DNS-01
611+
// or DNS-ACCOUNT-01 challenges, to comply with the BRs Sections 3.2.2.4.19
612+
// and 3.2.2.4.20 stating that ACME HTTP-01 and TLS-ALPN-01 are not
613+
// suitable for validating Wildcard Domains.
613614
if strings.HasPrefix(ident.Value, "*.") {
614-
return []core.AcmeChallenge{core.ChallengeTypeDNS01}, nil
615+
challenges := []core.AcmeChallenge{core.ChallengeTypeDNS01}
616+
if features.Get().DNSAccount01Enabled {
617+
challenges = append(challenges, core.ChallengeTypeDNSAccount01)
618+
}
619+
return challenges, nil
615620
}
616621

617622
// Return all challenge types we support for non-wildcard DNS identifiers.
618-
return []core.AcmeChallenge{
623+
challenges := []core.AcmeChallenge{
619624
core.ChallengeTypeHTTP01,
620625
core.ChallengeTypeDNS01,
621626
core.ChallengeTypeTLSALPN01,
622-
}, nil
627+
}
628+
if features.Get().DNSAccount01Enabled {
629+
challenges = append(challenges, core.ChallengeTypeDNSAccount01)
630+
}
631+
return challenges, nil
623632
case identifier.TypeIP:
624633
// Only HTTP-01 and TLS-ALPN-01 are suitable for IP address identifiers
625634
// per RFC 8738, Sec. 4.

policy/pa_test.go

Lines changed: 114 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import (
1919

2020
func paImpl(t *testing.T) *AuthorityImpl {
2121
enabledChallenges := map[core.AcmeChallenge]bool{
22-
core.ChallengeTypeHTTP01: true,
23-
core.ChallengeTypeDNS01: true,
24-
core.ChallengeTypeTLSALPN01: true,
22+
core.ChallengeTypeHTTP01: true,
23+
core.ChallengeTypeDNS01: true,
24+
core.ChallengeTypeTLSALPN01: true,
25+
core.ChallengeTypeDNSAccount01: true,
2526
}
2627

2728
enabledIdentifiers := map[identifier.IdentifierType]bool{
@@ -457,56 +458,122 @@ func TestChallengeTypesFor(t *testing.T) {
457458
t.Parallel()
458459
pa := paImpl(t)
459460

460-
testCases := []struct {
461-
name string
462-
ident identifier.ACMEIdentifier
463-
wantChalls []core.AcmeChallenge
464-
wantErr string
465-
}{
466-
{
467-
name: "dns",
468-
ident: identifier.NewDNS("example.com"),
469-
wantChalls: []core.AcmeChallenge{
470-
core.ChallengeTypeHTTP01, core.ChallengeTypeDNS01, core.ChallengeTypeTLSALPN01,
461+
t.Run("DNSAccount01Enabled=true", func(t *testing.T) {
462+
features.Set(features.Config{DNSAccount01Enabled: true})
463+
t.Cleanup(features.Reset)
464+
465+
testCases := []struct {
466+
name string
467+
ident identifier.ACMEIdentifier
468+
wantChalls []core.AcmeChallenge
469+
wantErr string
470+
}{
471+
{
472+
name: "dns",
473+
ident: identifier.NewDNS("example.com"),
474+
wantChalls: []core.AcmeChallenge{
475+
core.ChallengeTypeHTTP01,
476+
core.ChallengeTypeDNS01,
477+
core.ChallengeTypeTLSALPN01,
478+
core.ChallengeTypeDNSAccount01,
479+
},
471480
},
472-
},
473-
{
474-
name: "dns wildcard",
475-
ident: identifier.NewDNS("*.example.com"),
476-
wantChalls: []core.AcmeChallenge{
477-
core.ChallengeTypeDNS01,
481+
{
482+
name: "dns wildcard",
483+
ident: identifier.NewDNS("*.example.com"),
484+
wantChalls: []core.AcmeChallenge{
485+
core.ChallengeTypeDNS01,
486+
core.ChallengeTypeDNSAccount01,
487+
},
478488
},
479-
},
480-
{
481-
name: "ip",
482-
ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")),
483-
wantChalls: []core.AcmeChallenge{
484-
core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01,
489+
{
490+
name: "ip",
491+
ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")),
492+
wantChalls: []core.AcmeChallenge{
493+
core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01,
494+
},
485495
},
486-
},
487-
{
488-
name: "invalid",
489-
ident: identifier.ACMEIdentifier{Type: "fnord", Value: "uh-oh, Spaghetti-Os[tm]"},
490-
wantErr: "unrecognized identifier type",
491-
},
492-
}
496+
{
497+
name: "invalid",
498+
ident: identifier.ACMEIdentifier{Type: "fnord", Value: "uh-oh, Spaghetti-Os[tm]"},
499+
wantErr: "unrecognized identifier type",
500+
},
501+
}
493502

494-
for _, tc := range testCases {
495-
t.Run(tc.name, func(t *testing.T) {
496-
t.Parallel()
497-
challs, err := pa.ChallengeTypesFor(tc.ident)
503+
for _, tc := range testCases {
504+
tc := tc // Capture range variable
505+
t.Run(tc.name, func(t *testing.T) {
506+
t.Parallel()
507+
challs, err := pa.ChallengeTypesFor(tc.ident)
498508

499-
if len(tc.wantChalls) != 0 {
500-
test.AssertNotError(t, err, "should have succeeded")
501-
test.AssertDeepEquals(t, challs, tc.wantChalls)
502-
}
509+
if len(tc.wantChalls) != 0 {
510+
test.AssertNotError(t, err, "should have succeeded")
511+
test.AssertDeepEquals(t, challs, tc.wantChalls)
512+
}
503513

504-
if tc.wantErr != "" {
505-
test.AssertError(t, err, "should have errored")
506-
test.AssertContains(t, err.Error(), tc.wantErr)
507-
}
508-
})
509-
}
514+
if tc.wantErr != "" {
515+
test.AssertError(t, err, "should have errored")
516+
test.AssertContains(t, err.Error(), tc.wantErr)
517+
}
518+
})
519+
}
520+
})
521+
522+
t.Run("DNSAccount01Enabled=false", func(t *testing.T) {
523+
features.Set(features.Config{DNSAccount01Enabled: false})
524+
t.Cleanup(features.Reset)
525+
526+
testCases := []struct {
527+
name string
528+
ident identifier.ACMEIdentifier
529+
wantChalls []core.AcmeChallenge
530+
wantErr string
531+
}{
532+
{
533+
name: "dns",
534+
ident: identifier.NewDNS("example.com"),
535+
wantChalls: []core.AcmeChallenge{
536+
core.ChallengeTypeHTTP01,
537+
core.ChallengeTypeDNS01,
538+
core.ChallengeTypeTLSALPN01,
539+
// DNSAccount01 excluded
540+
},
541+
},
542+
{
543+
name: "wildcard",
544+
ident: identifier.NewDNS("*.example.com"),
545+
wantChalls: []core.AcmeChallenge{
546+
core.ChallengeTypeDNS01,
547+
// DNSAccount01 excluded
548+
},
549+
},
550+
{
551+
name: "ip",
552+
ident: identifier.NewIP(netip.MustParseAddr("1.2.3.4")),
553+
wantChalls: []core.AcmeChallenge{
554+
core.ChallengeTypeHTTP01, core.ChallengeTypeTLSALPN01,
555+
},
556+
},
557+
}
558+
559+
for _, tc := range testCases {
560+
tc := tc // Capture range variable
561+
t.Run(tc.name, func(t *testing.T) {
562+
t.Parallel()
563+
challs, err := pa.ChallengeTypesFor(tc.ident)
564+
565+
if len(tc.wantChalls) != 0 {
566+
test.AssertNotError(t, err, "should have succeeded")
567+
test.AssertDeepEquals(t, challs, tc.wantChalls)
568+
}
569+
570+
if tc.wantErr != "" {
571+
test.AssertError(t, err, "should have errored")
572+
test.AssertContains(t, err.Error(), tc.wantErr)
573+
}
574+
})
575+
}
576+
})
510577
}
511578

512579
// TestMalformedExactBlocklist tests that loading a YAML policy file with an
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
acme>=2.0
2-
cryptography>=0.7
1+
acme >= 2.0, < 5.0.0
2+
cryptography >= 0.7
33
PyOpenSSL
44
requests

test/chall-test-srv-client/client.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package challtestsrvclient
33
import (
44
"bytes"
55
"crypto/sha256"
6+
"encoding/base32"
67
"encoding/base64"
78
"encoding/json"
89
"fmt"
@@ -400,6 +401,80 @@ func (c *Client) RemoveDNS01Response(host string) ([]byte, error) {
400401
return resp, nil
401402
}
402403

404+
// AddDNSAccount01Response adds an ACME DNS-ACCOUNT-01 challenge response for the
405+
// provided host to the challenge test server's DNS interfaces. The TXT record
406+
// name is constructed using the accountURL, and the TXT record value is the
407+
// base64url encoded SHA-256 hash of the provided value. Any failure returns an
408+
// error that includes the relevant operation and the payload.
409+
func (c *Client) AddDNSAccount01Response(accountURL, host, value string) ([]byte, error) {
410+
if accountURL == "" {
411+
return nil, fmt.Errorf("accountURL cannot be empty")
412+
}
413+
if host == "" {
414+
return nil, fmt.Errorf("host cannot be empty")
415+
}
416+
label, err := calculateDNSAccount01Label(accountURL)
417+
if err != nil {
418+
return nil, fmt.Errorf("error calculating DNS label: %v", err)
419+
}
420+
host = fmt.Sprintf("%s._acme-challenge.%s", label, host)
421+
if !strings.HasSuffix(host, ".") {
422+
host += "."
423+
}
424+
h := sha256.Sum256([]byte(value))
425+
value = base64.RawURLEncoding.EncodeToString(h[:])
426+
payload := map[string]string{"host": host, "value": value}
427+
resp, err := c.postURL(addTXT, payload)
428+
if err != nil {
429+
return nil, fmt.Errorf(
430+
"while adding DNS-ACCOUNT-01 response for host %q, val %q (payload: %v): %w",
431+
host, value, payload, err,
432+
)
433+
}
434+
return resp, nil
435+
}
436+
437+
// RemoveDNSAccount01Response removes an ACME DNS-ACCOUNT-01 challenge
438+
// response for the provided host and accountURL combination from the
439+
// challenge test server's DNS interfaces. The TXT record name is
440+
// constructed using the accountURL. Any failure returns an error
441+
// that includes both the relevant operation and the payload.
442+
func (c *Client) RemoveDNSAccount01Response(accountURL, host string) ([]byte, error) {
443+
if accountURL == "" {
444+
return nil, fmt.Errorf("accountURL cannot be empty")
445+
}
446+
if host == "" {
447+
return nil, fmt.Errorf("host cannot be empty")
448+
}
449+
label, err := calculateDNSAccount01Label(accountURL)
450+
if err != nil {
451+
return nil, fmt.Errorf("error calculating DNS label: %v", err)
452+
}
453+
host = fmt.Sprintf("%s._acme-challenge.%s", label, host)
454+
if !strings.HasSuffix(host, ".") {
455+
host += "."
456+
}
457+
payload := map[string]string{"host": host}
458+
resp, err := c.postURL(delTXT, payload)
459+
if err != nil {
460+
return nil, fmt.Errorf(
461+
"while removing DNS-ACCOUNT-01 response for host %q (payload: %v): %w",
462+
host, payload, err,
463+
)
464+
}
465+
return resp, nil
466+
}
467+
468+
func calculateDNSAccount01Label(accountURL string) (string, error) {
469+
if accountURL == "" {
470+
return "", fmt.Errorf("account URL cannot be empty")
471+
}
472+
473+
h := sha256.Sum256([]byte(accountURL))
474+
label := fmt.Sprintf("_%s", strings.ToLower(base32.StdEncoding.EncodeToString(h[:10])))
475+
return label, nil
476+
}
477+
403478
// DNSRequest is a single DNS request in the request history.
404479
type DNSRequest struct {
405480
Question struct {

0 commit comments

Comments
 (0)