Skip to content

Commit efb6758

Browse files
wfe/ra/va/pa: Add support for draft-ietf-acme-dns-persist-00 (#8660)
Implement the dns-persist-01 ACME challenge type as specified in draft-ietf-acme-dns-persist-00. This challenge proves domain control via a persistent DNS TXT record at `_validation-persist.<domain>` containing the CA's issuer domain name and the subscriber's account URI. The following optional features are deliberately not implemented: - Just-in-Time Validation (section 4.2): Would require the RA to perform validation at order creation time, adding latency and complexity to NewOrder with no current operational need. - Subdomain validation via policy=wildcard (sections 5 and 6): as implemented, the policy tag gates wildcard certificate issuance but does not enable TXT records further up the domain hierarchy to satisfy subdomain authorizations. The draft has no mechanism for the subscriber to indicate which Authorization Domain Name (ADN) they want to validate at, so the server would have to walk up the domain tree. We've proposed that clients include an ADN field in their challenge POST payload to solve this. We'll wait to see if the draft adopts some form of ADN negotiation before implementing this functionality. - Authorization reuse (section 7.8): The spec caps reuse to the TXT record's TTL, and the BRs (section 3.2.2.4.22) caps it at 10 days. Since typical TTLs are seconds to minutes, re-validating on every order is simpler and avoids the need to plumb through and enforce TTL values. Fixes #8527
1 parent 3f18560 commit efb6758

File tree

35 files changed

+1747
-207
lines changed

35 files changed

+1747
-207
lines changed

cmd/boulder-ra/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
capb "github.com/letsencrypt/boulder/ca/proto"
1212
"github.com/letsencrypt/boulder/cmd"
1313
"github.com/letsencrypt/boulder/config"
14+
"github.com/letsencrypt/boulder/core"
1415
"github.com/letsencrypt/boulder/ctpolicy"
1516
"github.com/letsencrypt/boulder/ctpolicy/ctconfig"
1617
"github.com/letsencrypt/boulder/ctpolicy/loglist"
@@ -171,6 +172,13 @@ func main() {
171172
pa, err := policy.New(c.PA.Identifiers, c.PA.Challenges, logger)
172173
cmd.FailOnError(err, "Couldn't create PA")
173174

175+
if features.Get().DNSAccount01Enabled != pa.ChallengeTypeEnabled(core.ChallengeTypeDNSAccount01) {
176+
cmd.Fail("Feature flag DNSAccount01Enabled and PA dns-account-01 challenge must both be enabled or disabled")
177+
}
178+
if features.Get().DNSPersist01Enabled != pa.ChallengeTypeEnabled(core.ChallengeTypeDNSPersist01) {
179+
cmd.Fail("Feature flag DNSPersist01Enabled and PA dns-persist-01 challenge must both be enabled or disabled")
180+
}
181+
174182
if c.RA.HostnamePolicyFile == "" {
175183
cmd.Fail("HostnamePolicyFile must be provided.")
176184
}

cmd/boulder-wfe2/main.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,14 @@ type Config struct {
9999

100100
Features features.Config
101101

102-
// DirectoryCAAIdentity is used for the /directory response's "meta"
103-
// element's "caaIdentities" field. It should match the VA's "issuerDomain"
104-
// configuration value (this value is the one used to enforce CAA)
102+
// DirectoryCAAIdentity is the CA's issuer domain name, used for:
103+
// 1. /directory response: included in the "meta" caaIdentities field.
104+
// 2. dns-persist-01 challenges: included in the issuer-domain-names field.
105+
//
106+
// Must match the VA's IssuerDomain. A mismatch will cause CAA and
107+
// dns-persist-01 validation failures.
105108
DirectoryCAAIdentity string `validate:"required,fqdn"`
109+
106110
// DirectoryWebsite is used for the /directory response's "meta" element's
107111
// "website" field.
108112
DirectoryWebsite string `validate:"required,url"`
@@ -402,12 +406,12 @@ func main() {
402406
c.WFE.Unpause.JWTLifetime.Duration,
403407
c.WFE.Unpause.URL,
404408
c.WFE.BlockedOnDemandLabels,
409+
c.WFE.DirectoryCAAIdentity,
405410
)
406411
cmd.FailOnError(err, "Unable to create WFE")
407412

408413
wfe.SubscriberAgreementURL = c.WFE.SubscriberAgreementURL
409414
wfe.AllowOrigins = c.WFE.AllowOrigins
410-
wfe.DirectoryCAAIdentity = c.WFE.DirectoryCAAIdentity
411415
wfe.DirectoryWebsite = c.WFE.DirectoryWebsite
412416
wfe.LegacyKeyIDPrefix = c.WFE.LegacyKeyIDPrefix
413417

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 dns-account-01,endkeys"`
97+
Challenges map[core.AcmeChallenge]bool `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01 dns-account-01 dns-persist-01,endkeys"`
9898
Identifiers map[identifier.IdentifierType]bool `validate:"omitempty,dive,keys,oneof=dns ip,endkeys"`
9999
}
100100

core/challenges.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ func TLSALPNChallenge01(token string) Challenge {
2727
func DNSAccountChallenge01(token string) Challenge {
2828
return newChallenge(ChallengeTypeDNSAccount01, token)
2929
}
30+
31+
// DNSPersistChallenge01 constructs a dns-persist-01 challenge.
32+
func DNSPersistChallenge01() Challenge {
33+
return newChallenge(ChallengeTypeDNSPersist01, "")
34+
}

core/core_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,25 @@ func TestChallenges(t *testing.T) {
2727

2828
token := NewToken()
2929
http01 := HTTPChallenge01(token)
30-
test.AssertNotError(t, http01.CheckPending(), "CheckConsistencyForClientOffer returned an error")
30+
test.AssertNotError(t, http01.CheckPending(), "CheckPending returned an error")
3131

3232
dns01 := DNSChallenge01(token)
33-
test.AssertNotError(t, dns01.CheckPending(), "CheckConsistencyForClientOffer returned an error")
33+
test.AssertNotError(t, dns01.CheckPending(), "CheckPending returned an error")
3434

3535
dnsAccount01 := DNSAccountChallenge01(token)
36-
test.AssertNotError(t, dnsAccount01.CheckPending(), "CheckConsistencyForClientOffer returned an error")
36+
test.AssertNotError(t, dnsAccount01.CheckPending(), "CheckPending returned an error")
37+
38+
dnsPersist01 := DNSPersistChallenge01()
39+
test.AssertNotError(t, dnsPersist01.CheckPending(), "CheckPending returned an error")
3740

3841
tlsalpn01 := TLSALPNChallenge01(token)
39-
test.AssertNotError(t, tlsalpn01.CheckPending(), "CheckConsistencyForClientOffer returned an error")
42+
test.AssertNotError(t, tlsalpn01.CheckPending(), "CheckPending returned an error")
4043

4144
test.Assert(t, ChallengeTypeHTTP01.IsValid(), "Refused valid challenge")
4245
test.Assert(t, ChallengeTypeDNS01.IsValid(), "Refused valid challenge")
4346
test.Assert(t, ChallengeTypeTLSALPN01.IsValid(), "Refused valid challenge")
4447
test.Assert(t, ChallengeTypeDNSAccount01.IsValid(), "Refused valid challenge")
48+
test.Assert(t, ChallengeTypeDNSPersist01.IsValid(), "Refused valid challenge")
4549
test.Assert(t, !AcmeChallenge("nonsense-71").IsValid(), "Accepted invalid challenge")
4650
}
4751

core/objects.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ const (
4242
ChallengeTypeDNS01 = AcmeChallenge("dns-01")
4343
ChallengeTypeTLSALPN01 = AcmeChallenge("tls-alpn-01")
4444
ChallengeTypeDNSAccount01 = AcmeChallenge("dns-account-01")
45+
ChallengeTypeDNSPersist01 = AcmeChallenge("dns-persist-01")
4546
)
4647

4748
// IsValid tests whether the challenge is a known challenge
4849
func (c AcmeChallenge) IsValid() bool {
4950
switch c {
50-
case ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01:
51+
case ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01, ChallengeTypeDNSPersist01:
5152
return true
5253
default:
5354
return false
@@ -68,9 +69,12 @@ var OCSPStatusToInt = map[OCSPStatus]int{
6869
OCSPStatusRevoked: ocsp.Revoked,
6970
}
7071

71-
// DNSPrefix is attached to DNS names in DNS challenges
72+
// DNSPrefix is attached to DNS names in dns-01 and dns-account-01 challenges
7273
const DNSPrefix = "_acme-challenge"
7374

75+
// DNSPersistPrefix is attached to DNS names in dns-persist-01 challenges.
76+
const DNSPersistPrefix = "_validation-persist"
77+
7478
type RawCertificateRequest struct {
7579
CSR JSONBuffer `json:"csr"` // The encoded CSR
7680
}
@@ -156,9 +160,13 @@ type Challenge struct {
156160
Error *probs.ProblemDetails `json:"error,omitempty"`
157161

158162
// Token is a random value that uniquely identifies the challenge. It is used
159-
// by all current challenges (http-01, tls-alpn-01, and dns-01).
163+
// by all challenges except dns-persist-01.
160164
Token string `json:"token,omitempty"`
161165

166+
// IssuerDomainNames contains the list of issuer domain name values accepted
167+
// during dns-persist-01 challenge validation.
168+
IssuerDomainNames []string `json:"issuer-domain-names,omitempty"`
169+
162170
// Contains information about URLs used or redirected to and IPs resolved and
163171
// used
164172
ValidationRecord []ValidationRecord `json:"validationRecord,omitempty"`
@@ -203,7 +211,7 @@ func (ch Challenge) RecordsSane() bool {
203211
if ch.ValidationRecord[0].Hostname == "" || ch.ValidationRecord[0].Port == "" || (ch.ValidationRecord[0].AddressUsed == netip.Addr{}) || len(ch.ValidationRecord[0].AddressesResolved) == 0 {
204212
return false
205213
}
206-
case ChallengeTypeDNS01, ChallengeTypeDNSAccount01:
214+
case ChallengeTypeDNS01, ChallengeTypeDNSAccount01, ChallengeTypeDNSPersist01:
207215
if len(ch.ValidationRecord) > 1 {
208216
return false
209217
}
@@ -218,14 +226,20 @@ func (ch Challenge) RecordsSane() bool {
218226
return true
219227
}
220228

221-
// CheckPending ensures that a challenge object is pending and has a token.
222-
// This is used before offering the challenge to the client, and before actually
223-
// validating a challenge.
229+
// CheckPending ensures that a challenge object is pending and, for challenge
230+
// types that require one, has a token. This is used before offering the
231+
// challenge to the client, and before actually validating a challenge.
224232
func (ch Challenge) CheckPending() error {
225233
if ch.Status != StatusPending {
226234
return fmt.Errorf("challenge is not pending")
227235
}
228236

237+
// dns-persist-01 does not use a token; validation relies on persistent
238+
// DNS TXT records containing the issuer-domain-name and accounturi.
239+
if ch.Type == ChallengeTypeDNSPersist01 {
240+
return nil
241+
}
242+
229243
if !looksLikeAToken(ch.Token) {
230244
return fmt.Errorf("token is missing or malformed")
231245
}

core/objects_test.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,31 @@ func TestChallengeSanityCheck(t *testing.T) {
5959
}`), &accountKey)
6060
test.AssertNotError(t, err, "Error unmarshaling JWK")
6161

62-
types := []AcmeChallenge{ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01}
63-
for _, challengeType := range types {
62+
// Challenge types that require a token.
63+
tokenTypes := []AcmeChallenge{ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01}
64+
for _, challengeType := range tokenTypes {
6465
chall := Challenge{
6566
Type: challengeType,
6667
Status: StatusInvalid,
6768
}
68-
test.AssertError(t, chall.CheckPending(), "CheckConsistencyForClientOffer didn't return an error")
69+
test.AssertError(t, chall.CheckPending(), "CheckPending didn't return an error")
6970

7071
chall.Status = StatusPending
71-
test.AssertError(t, chall.CheckPending(), "CheckConsistencyForClientOffer didn't return an error")
72+
test.AssertError(t, chall.CheckPending(), "CheckPending didn't return an error")
7273

7374
chall.Token = "KQqLsiS5j0CONR_eUXTUSUDNVaHODtc-0pD6ACif7U4"
74-
test.AssertNotError(t, chall.CheckPending(), "CheckConsistencyForClientOffer returned an error")
75+
test.AssertNotError(t, chall.CheckPending(), "CheckPending returned an error")
7576
}
77+
78+
// dns-persist-01 does not use a token.
79+
chall := Challenge{
80+
Type: ChallengeTypeDNSPersist01,
81+
Status: StatusInvalid,
82+
}
83+
test.AssertError(t, chall.CheckPending(), "CheckPending didn't return an error for invalid status")
84+
85+
chall.Status = StatusPending
86+
test.AssertNotError(t, chall.CheckPending(), "CheckPending returned an error for dns-persist-01 without token")
7687
}
7788

7889
func TestJSONBufferUnmarshal(t *testing.T) {

core/util.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"unicode"
2828

2929
"github.com/go-jose/go-jose/v4"
30+
"golang.org/x/net/idna"
31+
"golang.org/x/text/unicode/norm"
3032
"google.golang.org/grpc/codes"
3133
"google.golang.org/grpc/status"
3234
"google.golang.org/protobuf/types/known/durationpb"
@@ -398,3 +400,22 @@ func IsCanceled(err error) bool {
398400
func Command() string {
399401
return path.Base(os.Args[0])
400402
}
403+
404+
// NormalizeIssuerDomainName normalizes an RFC 8659 issuer-domain-name per the
405+
// recommended algorithm in draft-ietf-acme-dns-persist-00, Section 9.1.1:
406+
// case-fold to lowercase, apply Unicode NFC normalization, convert to A-label
407+
// (Punycode), remove any trailing dot, and ensure the result is no more than
408+
// 253 octets in length. If normalization fails, an error is returned.
409+
func NormalizeIssuerDomainName(name string) (string, error) {
410+
name = strings.ToLower(name)
411+
name = norm.NFC.String(name)
412+
name, err := idna.Lookup.ToASCII(name)
413+
if err != nil {
414+
return "", fmt.Errorf("converting issuer domain name %q to ASCII: %w", name, err)
415+
}
416+
name = strings.TrimSuffix(name, ".")
417+
if len(name) > 253 {
418+
return "", fmt.Errorf("issuer domain name %q exceeds 253 octets (%d)", name, len(name))
419+
}
420+
return name, nil
421+
}

core/util_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,54 @@ func TestIsCanceled(t *testing.T) {
426426
t.Errorf("Expected random error to not be canceled, but was.")
427427
}
428428
}
429+
430+
func TestNormalizeIssuerDomainName(t *testing.T) {
431+
t.Parallel()
432+
433+
testCases := []struct {
434+
name string
435+
input string
436+
want string
437+
wantError bool
438+
}{
439+
{
440+
name: "Already normalized",
441+
input: "ca.example",
442+
want: "ca.example",
443+
},
444+
{
445+
name: "Normalizes uppercase and trailing dot",
446+
input: "CA.EXAMPLE.",
447+
want: "ca.example",
448+
},
449+
{
450+
name: "Normalizes IDNA issuer",
451+
input: "BÜCHER.example",
452+
want: "xn--bcher-kva.example",
453+
},
454+
{
455+
name: "Rejects invalid issuer with underscore",
456+
input: "ca_.example",
457+
wantError: true,
458+
},
459+
{
460+
name: "Rejects too-long issuer",
461+
input: strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 63),
462+
wantError: true,
463+
},
464+
}
465+
466+
for _, tc := range testCases {
467+
t.Run(tc.name, func(t *testing.T) {
468+
t.Parallel()
469+
470+
got, err := NormalizeIssuerDomainName(tc.input)
471+
if tc.wantError {
472+
test.AssertError(t, err, "expected normalization error")
473+
return
474+
}
475+
test.AssertNotError(t, err, "unexpected normalization error")
476+
test.AssertEquals(t, got, tc.want)
477+
})
478+
}
479+
}

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
// during certificate issuance. This flag must be set to true in the
8181
// RA and VA services for full functionality.
8282
DNSAccount01Enabled bool
83+
84+
// DNSPersist01Enabled controls support for the dns-persist-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 and VA services for full functionality.
88+
DNSPersist01Enabled bool
8389
}
8490

8591
var fMu = new(sync.RWMutex)

0 commit comments

Comments
 (0)