Skip to content

Commit 59f468c

Browse files
Addressing comments
1 parent 6adb2e9 commit 59f468c

9 files changed

Lines changed: 461 additions & 291 deletions

File tree

cmd/boulder-ra/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,11 @@ func main() {
172172
pa, err := policy.New(c.PA.Identifiers, c.PA.Challenges, logger)
173173
cmd.FailOnError(err, "Couldn't create PA")
174174

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")
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")
177177
}
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")
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")
180180
}
181181

182182
if c.RA.HostnamePolicyFile == "" {

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+
}

ra/ra.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,8 +2264,8 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
22642264
}
22652265

22662266
// If the identifier is a wildcard DNS name, all challenges must be
2267-
// DNS-01 or DNS-Account-01. The PA guarantees this at order creation
2268-
// time, but we verify again to be safe.
2267+
// DNS-based. The PA guarantees this at order creation time, but we
2268+
// verify again to be safe.
22692269
if ident.Type == identifier.TypeDNS && strings.HasPrefix(ident.Value, "*.") {
22702270
for _, chall := range authz.Challenges {
22712271
if chall.Type != core.ChallengeTypeDNS01 &&
@@ -2284,9 +2284,15 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
22842284
// the TXT record's TTL and BRs section 3.2.2.4.22 caps it at 10 days.
22852285
// Since TTLs are typically seconds to minutes, the TTL cap is likely to
22862286
// be the binding constraint; re-validating every order is simpler.
2287-
if slices.ContainsFunc(authz.Challenges, func(c core.Challenge) bool {
2288-
return c.Type == core.ChallengeTypeDNSPersist01 && c.Status == core.StatusValid
2289-
}) {
2287+
solvedBy, err := authz.SolvedBy()
2288+
if err != nil {
2289+
// This should never happen.
2290+
return nil, berrors.InternalServerError(
2291+
"SA.GetAuthorizations returned a DNS wildcard authz (%s) with invalid challenge(s)",
2292+
authz.ID,
2293+
)
2294+
}
2295+
if solvedBy == core.ChallengeTypeDNSPersist01 {
22902296
missingAuthzIdents = append(missingAuthzIdents, ident)
22912297
// Delete the authz from the identToExistingAuthz map since we are not reusing it.
22922298
delete(identToExistingAuthz, ident)

va/dns_persist.go

Lines changed: 78 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import (
88
"strings"
99
"time"
1010

11-
"golang.org/x/net/idna"
12-
"golang.org/x/text/unicode/norm"
13-
1411
"github.com/letsencrypt/boulder/core"
1512
berrors "github.com/letsencrypt/boulder/errors"
1613
"github.com/letsencrypt/boulder/identifier"
@@ -20,51 +17,13 @@ type dnsPersistIssueValue struct {
2017
issuerDomain string
2118
accountURI string
2219
policy string
23-
persistUntil *time.Time
24-
}
25-
26-
// NormalizeIssuerDomainName normalizes an RFC 8659 issuer-domain-name per
27-
// draft-ietf-acme-dns-persist-00, Section 9.1.1: case-fold to lowercase, apply
28-
// Unicode NFC normalization, convert to A-label (Punycode), remove any trailing
29-
// dot, and ensure the result is no more than 253 octets in length. If
30-
// normalization fails, an error is returned.
31-
func NormalizeIssuerDomainName(name string) (string, error) {
32-
name = strings.ToLower(name)
33-
name = norm.NFC.String(name)
34-
name, err := idna.Lookup.ToASCII(name)
35-
if err != nil {
36-
return "", fmt.Errorf("converting issuer domain name %q to ASCII: %w", name, err)
37-
}
38-
name = strings.TrimSuffix(name, ".")
39-
if len(name) > 253 {
40-
return "", fmt.Errorf("issuer domain name %q exceeds 253 octets (%d)", name, len(name))
41-
}
42-
return name, nil
20+
persistUntil time.Time
4321
}
4422

45-
// trimWSP trims RFC 5234 WSP (SP / HTAB) characters, as referenced by RFC 8659,
46-
// from both ends of the input string.
47-
func trimWSP(input string) string {
48-
return strings.TrimFunc(input, func(r rune) bool {
49-
return r == ' ' || r == '\t'
50-
})
51-
}
52-
53-
// splitIssueValue splits and returns an RFC 8659 issue-value into
54-
// issuer-domain-name and raw parameter segments. If parsing fails, zero values
55-
// are returned.
56-
func splitIssueValue(raw string) (string, []string) {
57-
// Split into issuer-domain-name and parameters.
58-
parts := strings.Split(raw, ";")
59-
if len(parts) == 0 {
60-
return "", nil
61-
}
62-
// Parse issuer-domain-name.
63-
issuerDomainName := trimWSP(parts[0])
64-
if issuerDomainName == "" {
65-
return "", nil
66-
}
67-
return issuerDomainName, parts[1:]
23+
// isWSP checks if a rune is an RFC 5234 WSP (SP / HTAB) character, as
24+
// referenced by RFC 8659.
25+
func isWSP(r rune) bool {
26+
return r == '\t' || r == ' '
6827
}
6928

7029
// parseDNSPersistIssueValue parses the raw parameter segments of an RFC 8659
@@ -77,7 +36,7 @@ func parseDNSPersistIssueValue(issuerDomainName string, paramsRaw []string) (*dn
7736

7837
for _, param := range paramsRaw {
7938
// Clean optional WSP from the parameter.
80-
param = trimWSP(param)
39+
param = strings.TrimFunc(param, isWSP)
8140
if param == "" {
8241
return nil, errors.New("empty parameter or trailing semicolon provided")
8342
}
@@ -87,8 +46,8 @@ func parseDNSPersistIssueValue(issuerDomainName string, paramsRaw []string) (*dn
8746
if len(tagValue) != 2 {
8847
return nil, fmt.Errorf("malformed parameter %q should be tag=value pair", param)
8948
}
90-
tag := trimWSP(tagValue[0])
91-
value := trimWSP(tagValue[1])
49+
tag := strings.TrimFunc(tagValue[0], isWSP)
50+
value := strings.TrimFunc(tagValue[1], isWSP)
9251
if tag == "" {
9352
return nil, fmt.Errorf("malformed parameter %q, empty tag", param)
9453
}
@@ -134,18 +93,73 @@ func parseDNSPersistIssueValue(issuerDomainName string, paramsRaw []string) (*dn
13493
if err != nil {
13594
return nil, fmt.Errorf("malformed persistUntil timestamp %q", value)
13695
}
137-
persistUntil := time.Unix(persistUntilVal, 0).UTC()
138-
result.persistUntil = &persistUntil
96+
result.persistUntil = time.Unix(persistUntilVal, 0).UTC()
13997
}
14098
}
14199
return result, nil
142100
}
143101

102+
// parseDNSPersistRecord parses a raw TXT record string into a
103+
// dnsPersistIssueValue. It returns ("", nil, nil) if the record's
104+
// issuer-domain-name is empty or does not match allowedIssuer.
105+
func parseDNSPersistRecord(record string, allowedIssuer string) (string, *dnsPersistIssueValue, error) {
106+
// Split into issuer-domain-name and parameters per RFC 8659.
107+
parts := strings.Split(record, ";")
108+
receivedIssuer := strings.TrimFunc(parts[0], isWSP)
109+
if receivedIssuer == "" {
110+
return "", nil, nil
111+
}
112+
113+
normalizedIssuer, err := core.NormalizeIssuerDomainName(receivedIssuer)
114+
if err != nil || normalizedIssuer != allowedIssuer {
115+
return "", nil, nil
116+
}
117+
118+
params, err := parseDNSPersistIssueValue(receivedIssuer, parts[1:])
119+
if err != nil {
120+
return receivedIssuer, nil, err
121+
}
122+
return receivedIssuer, params, nil
123+
}
124+
125+
// checkDNSPersistRecord checks whether a parsed dns-persist-01 record
126+
// authorizes issuance for the given account URI and wildcard status at the
127+
// given time. It returns nil if the record authorizes issuance, a
128+
// berrors.Malformed error for syntax problems, or a berrors.Unauthorized error
129+
// for authorization failures.
130+
func checkDNSPersistRecord(params *dnsPersistIssueValue, validAccountURI string, wildcardName bool, validatedAt time.Time) error {
131+
if params.accountURI == "" {
132+
return berrors.MalformedError("missing mandatory accountURI parameter")
133+
}
134+
if params.accountURI != validAccountURI {
135+
return berrors.UnauthorizedError("accounturi mismatch: expected %q, got %q", validAccountURI, params.accountURI)
136+
}
137+
// Per draft-ietf-acme-dns-persist-00, the policy parameter's tag and
138+
// defined values MUST be treated as case-insensitive. If the policy
139+
// parameter's value is anything other than "wildcard", the CA MUST proceed
140+
// as if the policy parameter were not present.
141+
if wildcardName && strings.ToLower(params.policy) != "wildcard" {
142+
return berrors.UnauthorizedError("policy mismatch: expected \"wildcard\", got %q", params.policy)
143+
}
144+
if !params.persistUntil.IsZero() && validatedAt.After(params.persistUntil) {
145+
return berrors.UnauthorizedError("validation time %s is after persistUntil %s",
146+
validatedAt.Format(time.RFC3339), params.persistUntil.Format(time.RFC3339))
147+
}
148+
return nil
149+
}
150+
144151
func (va *ValidationAuthorityImpl) validateDNSPersist01(ctx context.Context, ident identifier.ACMEIdentifier, validAccountURI string, wildcardName bool) ([]core.ValidationRecord, error) {
145152
if ident.Type != identifier.TypeDNS {
146153
return nil, berrors.MalformedError("Identifier type for DNS-PERSIST-01 challenge was not DNS")
147154
}
148155

156+
if va.issuerDomain == "" {
157+
// Belt and suspenders check: the VA should not have been configured to
158+
// perform DNS-PERSIST-01 validation if it does not have an issuer
159+
// domain name configured for comparison.
160+
return nil, berrors.InternalServerError("no issuer domain name configured for DNS-PERSIST-01 challenge validation")
161+
}
162+
149163
challengeSubdomain := fmt.Sprintf("%s.%s", core.DNSPersistPrefix, ident.Value)
150164
txts, resolver, err := va.dnsClient.LookupTXT(ctx, challengeSubdomain)
151165
if err != nil {
@@ -156,59 +170,31 @@ func (va *ValidationAuthorityImpl) validateDNSPersist01(ctx context.Context, ide
156170
}
157171
validatedAt := va.clk.Now().UTC()
158172

159-
allowedIssuer := va.issuerDomain
160-
if allowedIssuer == "" {
161-
// Belt and suspenders check: the VA should not have been configured to
162-
// perform DNS-PERSIST-01 validation if it does not have an issuer
163-
// domain name configured for comparison.
164-
return nil, berrors.InternalServerError("no issuer domain name configured for DNS-PERSIST-01 challenge validation")
165-
}
166-
167173
var syntaxErrs []string
168174
var authorizationErrs []string
169175
for _, rr := range txts.Final {
170176
record := strings.Join(rr.Txt, "")
171-
receivedIssuer, paramsRaw := splitIssueValue(record)
172-
normalizedIssuer, err := NormalizeIssuerDomainName(receivedIssuer)
173-
if err != nil || normalizedIssuer != allowedIssuer {
174-
continue
175-
}
176177

177-
params, err := parseDNSPersistIssueValue(receivedIssuer, paramsRaw)
178+
receivedIssuer, params, err := parseDNSPersistRecord(record, va.issuerDomain)
178179
if err != nil {
179-
// We know if this record was intended for us but it is malformed,
180-
// we can continue checking other records but we should report the
181-
// syntax error if no other record authorizes the challenge.
182180
syntaxErrs = append(syntaxErrs, fmt.Sprintf(
183181
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: %s", receivedIssuer, err))
184182
continue
185183
}
186-
if params.accountURI == "" {
187-
syntaxErrs = append(syntaxErrs, fmt.Sprintf(
188-
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: missing mandatory accountURI parameter", receivedIssuer))
189-
continue
190-
}
191-
if params.accountURI != validAccountURI {
192-
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
193-
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: accounturi mismatch: expected %q, got %q",
194-
receivedIssuer, validAccountURI, params.accountURI))
184+
if params == nil {
185+
// Record didn't match our issuer domain, skip.
195186
continue
196187
}
197-
// Per draft-ietf-acme-dns-persist-00, the policy parameter's tag
198-
// and defined values MUST be treated as case-insensitive. If the
199-
// policy parameter's value is anything other than "wildcard", the
200-
// CA MUST proceed as if the policy parameter were not present.
201-
policyLower := strings.ToLower(params.policy)
202-
if wildcardName && policyLower != "wildcard" {
203-
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
204-
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: policy mismatch: expected \"wildcard\", got %q",
205-
receivedIssuer, params.policy))
206-
continue
207-
}
208-
if params.persistUntil != nil && validatedAt.After(*params.persistUntil) {
209-
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
210-
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q, validation time %s is after persistUntil %s",
211-
receivedIssuer, validatedAt.Format(time.RFC3339), params.persistUntil.Format(time.RFC3339)))
188+
189+
err = checkDNSPersistRecord(params, validAccountURI, wildcardName, validatedAt)
190+
if err != nil {
191+
msg := fmt.Sprintf(
192+
"Parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: %s", receivedIssuer, err)
193+
if errors.Is(err, berrors.Malformed) {
194+
syntaxErrs = append(syntaxErrs, msg)
195+
} else {
196+
authorizationErrs = append(authorizationErrs, msg)
197+
}
212198
continue
213199
}
214200

@@ -224,6 +210,5 @@ func (va *ValidationAuthorityImpl) validateDNSPersist01(ctx context.Context, ide
224210
if len(authorizationErrs) > 0 {
225211
return nil, berrors.UnauthorizedError("%s", strings.Join(authorizationErrs, "; "))
226212
}
227-
228213
return nil, berrors.UnauthorizedError("No valid TXT record found for DNS-PERSIST-01 challenge")
229214
}

0 commit comments

Comments
 (0)