Skip to content

Commit d180fbf

Browse files
Add support for the draft dns-persist-01 challenge (#536)
Adds support for draft specification of the dns-perist-01 ACME challenge type. Draft specification can be found [here](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist) and the BRs text covering this challenge can be found [here](https://github.com/cabforum/servercert/blob/main/docs/BR.md#322422-dns-txt-record-with-persistent-value). Part of letsencrypt/boulder#8527
1 parent 122659c commit d180fbf

10 files changed

Lines changed: 572 additions & 18 deletions

File tree

.golangci.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ linters:
3434
- nolintlint
3535
- nonamedreturns
3636
- nosprintfhostport
37-
- perfsprint
3837
- predeclared
3938
- reassign
4039
- revive

acme/common.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
ChallengeTLSALPN01 = "tls-alpn-01"
2020
ChallengeDNS01 = "dns-01"
2121
ChallengeDNSAccount01 = "dns-account-01"
22+
ChallengeDNSPersist01 = "dns-persist-01"
2223

2324
HTTP01BaseURL = ".well-known/acme-challenge/"
2425

@@ -81,10 +82,11 @@ type Authorization struct {
8182

8283
// A Challenge is used to validate an Authorization
8384
type Challenge struct {
84-
Type string `json:"type"`
85-
URL string `json:"url"`
86-
Token string `json:"token"`
87-
Status string `json:"status"`
88-
Validated string `json:"validated,omitempty"`
89-
Error *ProblemDetails `json:"error,omitempty"`
85+
Type string `json:"type"`
86+
URL string `json:"url"`
87+
Token string `json:"token,omitempty"`
88+
Status string `json:"status"`
89+
IssuerDomainNames []string `json:"issuer-domain-names,omitempty"`
90+
Validated string `json:"validated,omitempty"`
91+
Error *ProblemDetails `json:"error,omitempty"`
9092
}

cmd/pebble/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type config struct {
3131
// Require External Account Binding for "newAccount" requests
3232
ExternalAccountBindingRequired bool
3333
ExternalAccountMACKeys map[string]string
34+
CAAIdentities []string
3435
// Configure policies to deny certain domains
3536
DomainBlocklist []string
3637
KeyAlgorithm string
@@ -138,7 +139,12 @@ func main() {
138139
cmd.FailOnError(err, "Failed to add domain to block list")
139140
}
140141

141-
wfeImpl := wfe.New(logger, db, va, ca, *strictMode, c.Pebble.ExternalAccountBindingRequired, c.Pebble.RetryAfter.Authz, c.Pebble.RetryAfter.Order)
142+
if len(c.Pebble.CAAIdentities) < 1 {
143+
logger.Println("No CAA identities configured, using default [pebble.letsencrypt.org]")
144+
c.Pebble.CAAIdentities = []string{"pebble.letsencrypt.org"}
145+
}
146+
147+
wfeImpl := wfe.New(logger, db, va, ca, c.Pebble.CAAIdentities, *strictMode, c.Pebble.ExternalAccountBindingRequired, c.Pebble.RetryAfter.Authz, c.Pebble.RetryAfter.Order)
142148
muxHandler := wfeImpl.Handler()
143149

144150
if c.Pebble.ManagementListenAddress != "" {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ toolchain go1.24.2
66

77
require (
88
github.com/go-jose/go-jose/v4 v4.1.3
9-
github.com/letsencrypt/challtestsrv v1.4.1
9+
github.com/letsencrypt/challtestsrv v1.4.2
1010
github.com/miekg/dns v1.1.62
1111
)
1212

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
22
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
33
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
44
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5-
github.com/letsencrypt/challtestsrv v1.4.1 h1:T01fsGKc0HIZbo3G496Z7uU2yNTua4wYie14SNr3zww=
6-
github.com/letsencrypt/challtestsrv v1.4.1/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
5+
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
6+
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
77
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
88
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
99
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=

va/va.go

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
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

Comments
 (0)