Skip to content

Commit 6adb2e9

Browse files
wfe/ra/va/pa: Add support for draft-ietf-acme-dns-persist-00
1 parent 89e2dfe commit 6adb2e9

28 files changed

Lines changed: 1528 additions & 197 deletions

File tree

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().DNSPersist01Enabled && !pa.ChallengeTypeEnabled(core.ChallengeTypeDNSPersist01) {
176+
cmd.Fail("Feature flag DNSPersist01Enabled requires dns-persist-01 to be enabled in the PA")
177+
}
178+
if pa.ChallengeTypeEnabled(core.ChallengeTypeDNSPersist01) && !features.Get().DNSAccount01Enabled {
179+
cmd.Fail("Feature flag DNSAccount01Enabled must be enabled to use dns-persist-01 challenge type")
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
@@ -98,10 +98,14 @@ type Config struct {
9898

9999
Features features.Config
100100

101-
// DirectoryCAAIdentity is used for the /directory response's "meta"
102-
// element's "caaIdentities" field. It should match the VA's "issuerDomain"
103-
// configuration value (this value is the one used to enforce CAA)
101+
// DirectoryCAAIdentity is the CA's issuer domain name, used for:
102+
// 1. /directory response: included in the "meta" caaIdentities field.
103+
// 2. dns-persist-01 challenges: included in the issuer-domain-names field.
104+
//
105+
// Must match the VA's IssuerDomain. A mismatch will cause CAA and
106+
// dns-persist-01 validation failures.
104107
DirectoryCAAIdentity string `validate:"required,fqdn"`
108+
105109
// DirectoryWebsite is used for the /directory response's "meta" element's
106110
// "website" field.
107111
DirectoryWebsite string `validate:"required,url"`
@@ -399,12 +403,12 @@ func main() {
399403
c.WFE.Unpause.JWTLifetime.Duration,
400404
c.WFE.Unpause.URL,
401405
c.WFE.BlockedOnDemandLabels,
406+
c.WFE.DirectoryCAAIdentity,
402407
)
403408
cmd.FailOnError(err, "Unable to create WFE")
404409

405410
wfe.SubscriberAgreementURL = c.WFE.SubscriberAgreementURL
406411
wfe.AllowOrigins = c.WFE.AllowOrigins
407-
wfe.DirectoryCAAIdentity = c.WFE.DirectoryCAAIdentity
408412
wfe.DirectoryWebsite = c.WFE.DirectoryWebsite
409413
wfe.LegacyKeyIDPrefix = c.WFE.LegacyKeyIDPrefix
410414

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"`
@@ -209,7 +217,7 @@ func (ch Challenge) RecordsSane() bool {
209217
(ch.ValidationRecord[0].AddressUsed == netip.Addr{}) || len(ch.ValidationRecord[0].AddressesResolved) == 0 {
210218
return false
211219
}
212-
case ChallengeTypeDNS01, ChallengeTypeDNSAccount01:
220+
case ChallengeTypeDNS01, ChallengeTypeDNSAccount01, ChallengeTypeDNSPersist01:
213221
if len(ch.ValidationRecord) > 1 {
214222
return false
215223
}
@@ -226,14 +234,20 @@ func (ch Challenge) RecordsSane() bool {
226234
return true
227235
}
228236

229-
// CheckPending ensures that a challenge object is pending and has a token.
230-
// This is used before offering the challenge to the client, and before actually
231-
// validating a challenge.
237+
// CheckPending ensures that a challenge object is pending and, for challenge
238+
// types that require one, has a token. This is used before offering the
239+
// challenge to the client, and before actually validating a challenge.
232240
func (ch Challenge) CheckPending() error {
233241
if ch.Status != StatusPending {
234242
return fmt.Errorf("challenge is not pending")
235243
}
236244

245+
// dns-persist-01 does not use a token; validation relies on persistent
246+
// DNS TXT records containing the issuer-domain-name and accounturi.
247+
if ch.Type == ChallengeTypeDNSPersist01 {
248+
return nil
249+
}
250+
237251
if !looksLikeAToken(ch.Token) {
238252
return fmt.Errorf("token is missing or malformed")
239253
}

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) {

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, VA, and WFE2 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)

policy/pa.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -607,15 +607,18 @@ func (pa *AuthorityImpl) checkBlocklists(ident identifier.ACMEIdentifier) error
607607
func (pa *AuthorityImpl) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) {
608608
switch ident.Type {
609609
case identifier.TypeDNS:
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.
610+
// If the identifier is for a DNS wildcard name we only provide DNS-01,
611+
// DNS-ACCOUNT-01, or DNS-PERSIST-01 challenges, to comply with the BRs
612+
// Sections 3.2.2.4.19 and 3.2.2.4.20 stating that ACME HTTP-01 and
613+
// TLS-ALPN-01 are not suitable for validating Wildcard Domains.
614614
if strings.HasPrefix(ident.Value, "*.") {
615615
challenges := []core.AcmeChallenge{core.ChallengeTypeDNS01}
616616
if features.Get().DNSAccount01Enabled {
617617
challenges = append(challenges, core.ChallengeTypeDNSAccount01)
618618
}
619+
if features.Get().DNSPersist01Enabled {
620+
challenges = append(challenges, core.ChallengeTypeDNSPersist01)
621+
}
619622
return challenges, nil
620623
}
621624

@@ -628,6 +631,9 @@ func (pa *AuthorityImpl) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]c
628631
if features.Get().DNSAccount01Enabled {
629632
challenges = append(challenges, core.ChallengeTypeDNSAccount01)
630633
}
634+
if features.Get().DNSPersist01Enabled {
635+
challenges = append(challenges, core.ChallengeTypeDNSPersist01)
636+
}
631637
return challenges, nil
632638
case identifier.TypeIP:
633639
// Only HTTP-01 and TLS-ALPN-01 are suitable for IP address identifiers

policy/pa_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func paImpl(t *testing.T) *AuthorityImpl {
2323
core.ChallengeTypeDNS01: true,
2424
core.ChallengeTypeTLSALPN01: true,
2525
core.ChallengeTypeDNSAccount01: true,
26+
core.ChallengeTypeDNSPersist01: true,
2627
}
2728

2829
enabledIdentifiers := map[identifier.IdentifierType]bool{
@@ -463,8 +464,8 @@ func TestChallengeTypesFor(t *testing.T) {
463464
t.Parallel()
464465
pa := paImpl(t)
465466

466-
t.Run("DNSAccount01Enabled=true", func(t *testing.T) {
467-
features.Set(features.Config{DNSAccount01Enabled: true})
467+
t.Run("DNSAccount01Enabled=true,DNSPersist01Enabled=true", func(t *testing.T) {
468+
features.Set(features.Config{DNSAccount01Enabled: true, DNSPersist01Enabled: true})
468469
t.Cleanup(features.Reset)
469470

470471
testCases := []struct {
@@ -481,6 +482,7 @@ func TestChallengeTypesFor(t *testing.T) {
481482
core.ChallengeTypeDNS01,
482483
core.ChallengeTypeTLSALPN01,
483484
core.ChallengeTypeDNSAccount01,
485+
core.ChallengeTypeDNSPersist01,
484486
},
485487
},
486488
{
@@ -489,6 +491,7 @@ func TestChallengeTypesFor(t *testing.T) {
489491
wantChalls: []core.AcmeChallenge{
490492
core.ChallengeTypeDNS01,
491493
core.ChallengeTypeDNSAccount01,
494+
core.ChallengeTypeDNSPersist01,
492495
},
493496
},
494497
{
@@ -523,8 +526,8 @@ func TestChallengeTypesFor(t *testing.T) {
523526
}
524527
})
525528

526-
t.Run("DNSAccount01Enabled=false", func(t *testing.T) {
527-
features.Set(features.Config{DNSAccount01Enabled: false})
529+
t.Run("DNSAccount01Enabled=false,DNSPersist01Enabled=false", func(t *testing.T) {
530+
features.Set(features.Config{DNSAccount01Enabled: false, DNSPersist01Enabled: false})
528531
t.Cleanup(features.Reset)
529532

530533
testCases := []struct {
@@ -541,6 +544,7 @@ func TestChallengeTypesFor(t *testing.T) {
541544
core.ChallengeTypeDNS01,
542545
core.ChallengeTypeTLSALPN01,
543546
// DNSAccount01 excluded
547+
// DNSPersist01 excluded
544548
},
545549
},
546550
{
@@ -549,6 +553,7 @@ func TestChallengeTypesFor(t *testing.T) {
549553
wantChalls: []core.AcmeChallenge{
550554
core.ChallengeTypeDNS01,
551555
// DNSAccount01 excluded
556+
// DNSPersist01 excluded
552557
},
553558
},
554559
{

0 commit comments

Comments
 (0)