99 "encoding/asn1"
1010 "encoding/base32"
1111 "encoding/base64"
12+ "errors"
1213 "fmt"
1314 "io"
1415 "log"
@@ -18,6 +19,7 @@ import (
1819 "net/url"
1920 "os"
2021 "runtime"
22+ "slices"
2123 "strconv"
2224 "strings"
2325 "time"
@@ -323,6 +325,8 @@ func (va VAImpl) performValidation(task *vaTask, results chan<- *core.Validation
323325 results <- va .validateDNS01 (task )
324326 case acme .ChallengeDNSAccount01 :
325327 results <- va .validateDNSAccount01 (task )
328+ case acme .ChallengeDNSPersist01 :
329+ results <- va .validateDNSPersist01 (task )
326330 default :
327331 va .log .Printf ("Error: performValidation(): Invalid challenge type: %q" , task .Challenge .Type )
328332 }
@@ -554,6 +558,185 @@ func (va VAImpl) validateHTTP01(task *vaTask) *core.ValidationRecord {
554558 return result
555559}
556560
561+ type dnsPersistIssueValue struct {
562+ issuerDomain string
563+ accountURI string
564+ policy string
565+ persistUntil * time.Time
566+ }
567+
568+ // trimWSP trims RFC 8659 whitespace characters (space and tab) from the
569+ // beginning and end of a string.
570+ func trimWSP (s string ) string {
571+ return strings .TrimFunc (s , func (r rune ) bool {
572+ return r == ' ' || r == '\t'
573+ })
574+ }
575+
576+ // splitIssuerDomainName splits an RFC 8659 issue-value into issuer-domain-name
577+ // and raw parameter segments. It returns zero values when issuer-domain-name is
578+ // missing.
579+ func splitIssuerDomainName (raw string ) (string , []string ) {
580+ // Split into issuer-domain-name and parameters.
581+ parts := strings .Split (raw , ";" )
582+ if len (parts ) == 0 {
583+ return "" , nil
584+ }
585+ // Parse issuer-domain-name.
586+ issuerDomainName := trimWSP (parts [0 ])
587+ if issuerDomainName == "" {
588+ return "" , nil
589+ }
590+ return issuerDomainName , parts [1 :]
591+ }
592+
593+ // parseDNSPersistIssueValues parses RFC 8659 issue-value parameters for a
594+ // dns-persist-01 TXT record and returns the extracted fields. It returns an
595+ // error if any parameter is malformed.
596+ func parseDNSPersistIssueValues (issuerDomainName string , paramsRaw []string ) (* dnsPersistIssueValue , error ) {
597+ result := & dnsPersistIssueValue {issuerDomain : issuerDomainName }
598+
599+ // Parse parameters (with optional surrounding WSP).
600+ seenTags := make (map [string ]bool )
601+ for _ , param := range paramsRaw {
602+ param = trimWSP (param )
603+ if param == "" {
604+ return nil , errors .New ("empty parameter or trailing semicolon provided" )
605+ }
606+ // Capture each tag=value pair.
607+ tagValue := strings .SplitN (param , "=" , 2 )
608+ if len (tagValue ) != 2 {
609+ return nil , fmt .Errorf ("malformed parameter %q should be tag=value pair" , param )
610+ }
611+ tag := trimWSP (tagValue [0 ])
612+ value := trimWSP (tagValue [1 ])
613+ if tag == "" {
614+ return nil , fmt .Errorf ("malformed parameter %q, empty tag" , param )
615+ }
616+ canonicalTag := strings .ToLower (tag )
617+ if seenTags [canonicalTag ] {
618+ return nil , fmt .Errorf ("duplicate parameter %q" , tag )
619+ }
620+ seenTags [canonicalTag ] = true
621+ // Ensure values contain no whitespace/control/non-ASCII characters.
622+ for _ , r := range value {
623+ if (r >= 0x21 && r <= 0x3A ) || (r >= 0x3C && r <= 0x7E ) {
624+ continue
625+ }
626+ return nil , fmt .Errorf ("malformed value %q for tag %q" , value , tag )
627+ }
628+ // Finally, capture expected tag values.
629+ //
630+ // Note: according to RFC 8659 matching of tags is case insensitive.
631+ switch canonicalTag {
632+ case "accounturi" :
633+ if value == "" {
634+ return nil , fmt .Errorf ("empty value provided for mandatory accounturi" )
635+ }
636+ result .accountURI = value
637+ case "policy" :
638+ // Per the dns-persist-01 specification, if the policy tag is
639+ // present parameter's tag and defined values MUST be treated as
640+ // case-insensitive.
641+ if value != "" && strings .ToLower (value ) != "wildcard" {
642+ // If the policy parameter's value is anything other than
643+ // "wildcard", the CA MUST proceed as if the policy parameter
644+ // were not present.
645+ value = ""
646+ }
647+ result .policy = value
648+ case "persistuntil" :
649+ persistUntilVal , err := strconv .ParseInt (value , 10 , 64 )
650+ if err != nil {
651+ return nil , fmt .Errorf ("malformed persistUntil timestamp %q" , value )
652+ }
653+ persistUntil := time .Unix (persistUntilVal , 0 ).UTC ()
654+ result .persistUntil = & persistUntil
655+ }
656+ }
657+ return result , nil
658+ }
659+
660+ func (va VAImpl ) validateDNSPersist01 (task * vaTask ) * core.ValidationRecord {
661+ challengeSubdomain := fmt .Sprintf ("%s.%s" , "_validation-persist" , task .Identifier .Value )
662+ result := & core.ValidationRecord {
663+ URL : challengeSubdomain ,
664+ ValidatedAt : time .Now (),
665+ }
666+
667+ txtRecords , err := va .getTXTEntry (challengeSubdomain )
668+ if err != nil {
669+ result .Error = acme .UnauthorizedProblem (
670+ fmt .Sprintf ("Error retrieving TXT records for DNS-PERSIST-01 challenge: %s" , err ))
671+ return result
672+ }
673+
674+ if len (txtRecords ) == 0 {
675+ result .Error = acme .UnauthorizedProblem ("No TXT records found for DNS-PERSIST-01 challenge" )
676+ return result
677+ }
678+
679+ task .Challenge .RLock ()
680+ issuerNames := append ([]string (nil ), task .Challenge .IssuerDomainNames ... )
681+ task .Challenge .RUnlock ()
682+
683+ var syntaxErrs []string
684+ var authorizationErrs []string
685+ for _ , record := range txtRecords {
686+ issuerDomainName , paramsRaw := splitIssuerDomainName (record )
687+ if ! slices .Contains (issuerNames , issuerDomainName ) {
688+ continue
689+ }
690+ issueValue , err := parseDNSPersistIssueValues (issuerDomainName , paramsRaw )
691+ if err != nil {
692+ // We know if this record was intended for us but it is malformed,
693+ // we can continue checking other records but we should report the
694+ // syntax error if no other record authorizes the challenge.
695+ syntaxErrs = append (syntaxErrs , fmt .Sprintf (
696+ "Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: %s" , issuerDomainName , err ))
697+ continue
698+ }
699+ if issueValue .accountURI == "" {
700+ syntaxErrs = append (syntaxErrs , fmt .Sprintf (
701+ "Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: missing mandatory accountURI parameter" , issuerDomainName ))
702+ continue
703+ }
704+ if issueValue .accountURI != task .AccountURL {
705+ authorizationErrs = append (authorizationErrs , fmt .Sprintf (
706+ "Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: accounturi mismatch: expected %q, got %q" ,
707+ issuerDomainName , task .AccountURL , issueValue .accountURI ))
708+ continue
709+ }
710+ // Per the dns-persist-01 specification, if the policy tag is present
711+ // parameter's defined values MUST be treated as case-insensitive.
712+ if task .Wildcard && strings .ToLower (issueValue .policy ) != "wildcard" {
713+ authorizationErrs = append (authorizationErrs , fmt .Sprintf (
714+ "Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: policy mismatch: expected \" wildcard\" , got %q" ,
715+ issuerDomainName , issueValue .policy ))
716+ continue
717+ }
718+ if issueValue .persistUntil != nil && result .ValidatedAt .After (* issueValue .persistUntil ) {
719+ authorizationErrs = append (authorizationErrs , fmt .Sprintf (
720+ "Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q, validation time %s is after persistUntil %s" ,
721+ issuerDomainName , result .ValidatedAt .Format (time .RFC3339 ), issueValue .persistUntil .Format (time .RFC3339 )))
722+ continue
723+ }
724+ return result
725+ }
726+
727+ if len (syntaxErrs ) > 0 {
728+ result .Error = acme .MalformedProblem (strings .Join (syntaxErrs , "; " ))
729+ return result
730+ }
731+ if len (authorizationErrs ) > 0 {
732+ result .Error = acme .UnauthorizedProblem (strings .Join (authorizationErrs , "; " ))
733+ return result
734+ }
735+
736+ result .Error = acme .UnauthorizedProblem ("No valid TXT record found for DNS-PERSIST-01 challenge" )
737+ return result
738+ }
739+
557740// NOTE(@cpu): fetchHTTP only fetches the ACME HTTP-01 challenge path for
558741// a given challenge & identifier domain. It is not a challenge agnostic general
559742// purpose HTTP function
@@ -662,8 +845,12 @@ func (va VAImpl) getTXTEntry(name string) ([]string, error) {
662845 }
663846
664847 for _ , record := range in .Answer {
665- if t , ok := record .(* dns.TXT ); ok {
666- txts = append (txts , t .Txt ... )
848+ t , ok := record .(* dns.TXT )
849+ if ok {
850+ // One TXT RR may contain multiple RFC 1035 <character-string>
851+ // elements (each up to 255 data octets). Concatenate them to
852+ // recover the full value.
853+ txts = append (txts , strings .Join (t .Txt , "" ))
667854 }
668855 }
669856
0 commit comments