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+
144151func (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