Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ linters:
- nolintlint
- nonamedreturns
- nosprintfhostport
- perfsprint
- predeclared
- reassign
- revive
Expand Down
14 changes: 8 additions & 6 deletions acme/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
ChallengeTLSALPN01 = "tls-alpn-01"
ChallengeDNS01 = "dns-01"
ChallengeDNSAccount01 = "dns-account-01"
ChallengeDNSPersist01 = "dns-persist-01"

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

Expand Down Expand Up @@ -81,10 +82,11 @@ type Authorization struct {

// A Challenge is used to validate an Authorization
type Challenge struct {
Type string `json:"type"`
URL string `json:"url"`
Token string `json:"token"`
Status string `json:"status"`
Validated string `json:"validated,omitempty"`
Error *ProblemDetails `json:"error,omitempty"`
Type string `json:"type"`
URL string `json:"url"`
Token string `json:"token,omitempty"`
Status string `json:"status"`
IssuerDomainNames []string `json:"issuer-domain-names,omitempty"`
Validated string `json:"validated,omitempty"`
Error *ProblemDetails `json:"error,omitempty"`
}
8 changes: 7 additions & 1 deletion cmd/pebble/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type config struct {
// Require External Account Binding for "newAccount" requests
ExternalAccountBindingRequired bool
ExternalAccountMACKeys map[string]string
CAAIdentities []string
// Configure policies to deny certain domains
DomainBlocklist []string
KeyAlgorithm string
Expand Down Expand Up @@ -138,7 +139,12 @@ func main() {
cmd.FailOnError(err, "Failed to add domain to block list")
}

wfeImpl := wfe.New(logger, db, va, ca, *strictMode, c.Pebble.ExternalAccountBindingRequired, c.Pebble.RetryAfter.Authz, c.Pebble.RetryAfter.Order)
if len(c.Pebble.CAAIdentities) < 1 {
logger.Println("No CAA identities configured, using default [pebble.letsencrypt.org]")
c.Pebble.CAAIdentities = []string{"pebble.letsencrypt.org"}
}

wfeImpl := wfe.New(logger, db, va, ca, c.Pebble.CAAIdentities, *strictMode, c.Pebble.ExternalAccountBindingRequired, c.Pebble.RetryAfter.Authz, c.Pebble.RetryAfter.Order)
muxHandler := wfeImpl.Handler()

if c.Pebble.ManagementListenAddress != "" {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ toolchain go1.24.2

require (
github.com/go-jose/go-jose/v4 v4.1.3
github.com/letsencrypt/challtestsrv v1.4.1
github.com/letsencrypt/challtestsrv v1.4.2
github.com/miekg/dns v1.1.62
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/letsencrypt/challtestsrv v1.4.1 h1:T01fsGKc0HIZbo3G496Z7uU2yNTua4wYie14SNr3zww=
github.com/letsencrypt/challtestsrv v1.4.1/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
Expand Down
191 changes: 189 additions & 2 deletions va/va.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/asn1"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
Expand All @@ -18,6 +19,7 @@ import (
"net/url"
"os"
"runtime"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -323,6 +325,8 @@ func (va VAImpl) performValidation(task *vaTask, results chan<- *core.Validation
results <- va.validateDNS01(task)
case acme.ChallengeDNSAccount01:
results <- va.validateDNSAccount01(task)
case acme.ChallengeDNSPersist01:
results <- va.validateDNSPersist01(task)
default:
va.log.Printf("Error: performValidation(): Invalid challenge type: %q", task.Challenge.Type)
}
Expand Down Expand Up @@ -554,6 +558,185 @@ func (va VAImpl) validateHTTP01(task *vaTask) *core.ValidationRecord {
return result
}

type dnsPersistIssueValue struct {
issuerDomain string
accountURI string
policy string
persistUntil *time.Time
}

// trimWSP trims RFC 8659 whitespace characters (space and tab) from the
// beginning and end of a string.
func trimWSP(s string) string {
return strings.TrimFunc(s, func(r rune) bool {
return r == ' ' || r == '\t'
})
}

// splitIssuerDomainName splits an RFC 8659 issue-value into issuer-domain-name
// and raw parameter segments. It returns zero values when issuer-domain-name is
// missing.
func splitIssuerDomainName(raw string) (string, []string) {
// Split into issuer-domain-name and parameters.
parts := strings.Split(raw, ";")
if len(parts) == 0 {
return "", nil
}
// Parse issuer-domain-name.
issuerDomainName := trimWSP(parts[0])
if issuerDomainName == "" {
return "", nil
}
return issuerDomainName, parts[1:]
}
Comment on lines +568 to +591
Copy link
Copy Markdown
Member Author

@beautifulentropy beautifulentropy Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love splitting these two functions like this, but I'm not seeing a better way to peek at the issuer-domain-name, without performing a second split. So, it feels like if we're going to split we might as well return the issuer-domain-name string and the remainder []string. That way we don't have to do that work again. Let me know if you have other alternatives.


// parseDNSPersistIssueValues parses RFC 8659 issue-value parameters for a
// dns-persist-01 TXT record and returns the extracted fields. It returns an
// error if any parameter is malformed.
func parseDNSPersistIssueValues(issuerDomainName string, paramsRaw []string) (*dnsPersistIssueValue, error) {
result := &dnsPersistIssueValue{issuerDomain: issuerDomainName}

// Parse parameters (with optional surrounding WSP).
seenTags := make(map[string]bool)
for _, param := range paramsRaw {
param = trimWSP(param)
if param == "" {
return nil, errors.New("empty parameter or trailing semicolon provided")
}
// Capture each tag=value pair.
tagValue := strings.SplitN(param, "=", 2)
if len(tagValue) != 2 {
return nil, fmt.Errorf("malformed parameter %q should be tag=value pair", param)
}
tag := trimWSP(tagValue[0])
value := trimWSP(tagValue[1])
if tag == "" {
return nil, fmt.Errorf("malformed parameter %q, empty tag", param)
}
canonicalTag := strings.ToLower(tag)
if seenTags[canonicalTag] {
return nil, fmt.Errorf("duplicate parameter %q", tag)
}
seenTags[canonicalTag] = true
// Ensure values contain no whitespace/control/non-ASCII characters.
for _, r := range value {
if (r >= 0x21 && r <= 0x3A) || (r >= 0x3C && r <= 0x7E) {
continue
}
return nil, fmt.Errorf("malformed value %q for tag %q", value, tag)
}
// Finally, capture expected tag values.
//
// Note: according to RFC 8659 matching of tags is case insensitive.
switch canonicalTag {
case "accounturi":
if value == "" {
return nil, fmt.Errorf("empty value provided for mandatory accounturi")
}
result.accountURI = value
case "policy":
// Per the dns-persist-01 specification, if the policy tag is
// present parameter's tag and defined values MUST be treated as
// case-insensitive.
if value != "" && strings.ToLower(value) != "wildcard" {
// If the policy parameter's value is anything other than
// "wildcard", the CA MUST proceed as if the policy parameter
// were not present.
value = ""
}
result.policy = value
case "persistuntil":
persistUntilVal, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("malformed persistUntil timestamp %q", value)
}
persistUntil := time.Unix(persistUntilVal, 0).UTC()
result.persistUntil = &persistUntil
}
}
return result, nil
}

func (va VAImpl) validateDNSPersist01(task *vaTask) *core.ValidationRecord {
challengeSubdomain := fmt.Sprintf("%s.%s", "_validation-persist", task.Identifier.Value)
result := &core.ValidationRecord{
URL: challengeSubdomain,
ValidatedAt: time.Now(),
}

txtRecords, err := va.getTXTEntry(challengeSubdomain)
if err != nil {
result.Error = acme.UnauthorizedProblem(
fmt.Sprintf("Error retrieving TXT records for DNS-PERSIST-01 challenge: %s", err))
return result
}

if len(txtRecords) == 0 {
result.Error = acme.UnauthorizedProblem("No TXT records found for DNS-PERSIST-01 challenge")
return result
}

task.Challenge.RLock()
issuerNames := append([]string(nil), task.Challenge.IssuerDomainNames...)
Comment thread
beautifulentropy marked this conversation as resolved.
task.Challenge.RUnlock()

var syntaxErrs []string
var authorizationErrs []string
for _, record := range txtRecords {
issuerDomainName, paramsRaw := splitIssuerDomainName(record)
if !slices.Contains(issuerNames, issuerDomainName) {
Comment thread
beautifulentropy marked this conversation as resolved.
continue
}
issueValue, err := parseDNSPersistIssueValues(issuerDomainName, paramsRaw)
Comment thread
beautifulentropy marked this conversation as resolved.
if err != nil {
// We know if this record was intended for us but it is malformed,
// we can continue checking other records but we should report the
// syntax error if no other record authorizes the challenge.
Comment on lines +692 to +694
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the spec, it seems not fully specified whether a CA should ignore malformed records that are intended for it.

Closest seems to be:

4.1.1. Coexistence of Records
When multiple TXT records are present at the same DNS label (e.g., _validation-persist.example.com), each record functions as an independent authorization for the specified issuer. This follows a similar pattern to CAA records [RFC8659], where multiple records at the same label are permissible.

But honestly I think it would be better to just error on any malformed record that has your issuer; or maybe any malformed record at all. I'll file an issue on the spec. Let's keep the behavior here as-is, pending any spec changes.

Copy link
Copy Markdown
Contributor

@kanashimia kanashimia Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering those statements:

Validate Record: If a matching record is found, the CA proceeds to validate it according to the requirements in this specification, including verifying the accounturi and persistUntil parameters.

CAs SHOULD return a malformed error (as defined in [RFC8555]) when the TXT record has invalid syntax, such as duplicate parameters, invalid timestamp format in the persistUntil parameter, missing mandatory accounturi parameter, or other syntactic violations of the record format specified in this document.

I would read that as "all intended records are validated", but you are right that it isn't clear what to do then.

It is a bigger question with regards to how multiple records that are intended for the same CA are handled,
like if there are two records that are applicable, one is expired and other is not, then expired one should be ignored logically,
if there is a wildcard record and a record that isn't a wildcard then for wildcard validation only one of them is applicable,
or there could be two records with different account urls,
and probably doing the same with malformed records just for consistency makes sense.

or there could be two records with different account urls

In particular this is an important use case to allow in my opinion.

syntaxErrs = append(syntaxErrs, fmt.Sprintf(
"Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: %s", issuerDomainName, err))
continue
}
if issueValue.accountURI == "" {
syntaxErrs = append(syntaxErrs, fmt.Sprintf(
"Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: missing mandatory accountURI parameter", issuerDomainName))
continue
}
if issueValue.accountURI != task.AccountURL {
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
"Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: accounturi mismatch: expected %q, got %q",
issuerDomainName, task.AccountURL, issueValue.accountURI))
continue
}
// Per the dns-persist-01 specification, if the policy tag is present
// parameter's defined values MUST be treated as case-insensitive.
if task.Wildcard && strings.ToLower(issueValue.policy) != "wildcard" {
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
"Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q: policy mismatch: expected \"wildcard\", got %q",
issuerDomainName, issueValue.policy))
continue
}
if issueValue.persistUntil != nil && result.ValidatedAt.After(*issueValue.persistUntil) {
authorizationErrs = append(authorizationErrs, fmt.Sprintf(
"Error parsing DNS-PERSIST-01 challenge TXT record with issuer-domain-name %q, validation time %s is after persistUntil %s",
issuerDomainName, result.ValidatedAt.Format(time.RFC3339), issueValue.persistUntil.Format(time.RFC3339)))
continue
}
return result
}

if len(syntaxErrs) > 0 {
result.Error = acme.MalformedProblem(strings.Join(syntaxErrs, "; "))
return result
}
if len(authorizationErrs) > 0 {
result.Error = acme.UnauthorizedProblem(strings.Join(authorizationErrs, "; "))
return result
}

result.Error = acme.UnauthorizedProblem("No valid TXT record found for DNS-PERSIST-01 challenge")
return result
}

// NOTE(@cpu): fetchHTTP only fetches the ACME HTTP-01 challenge path for
// a given challenge & identifier domain. It is not a challenge agnostic general
// purpose HTTP function
Expand Down Expand Up @@ -662,8 +845,12 @@ func (va VAImpl) getTXTEntry(name string) ([]string, error) {
}

for _, record := range in.Answer {
if t, ok := record.(*dns.TXT); ok {
txts = append(txts, t.Txt...)
t, ok := record.(*dns.TXT)
if ok {
// One TXT RR may contain multiple RFC 1035 <character-string>
// elements (each up to 255 data octets). Concatenate them to
// recover the full value.
txts = append(txts, strings.Join(t.Txt, ""))
}
}

Expand Down
Loading
Loading