From d7982ddfe72642402b68ad4dfc3daf9036b1a00f Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 12:47:12 -0500 Subject: [PATCH 1/8] Add dns-persist-01 options to the ACME provisioner Add the dns-persist-01 challenge type, disabled by default, and the issuerDomainNames and accountHashPrefix properties required by draft-ietf-acme-dns-persist-02. Both properties are validated when the challenge is enabled. --- authority/provisioner/acme.go | 119 +++++++++++++++++++++- authority/provisioner/acme_test.go | 156 +++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 3 deletions(-) diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index 7c4f0acba..33ddece45 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -8,6 +8,8 @@ import ( "fmt" "net" "net/http" + "net/url" + "slices" "strings" "sync/atomic" "time" @@ -16,6 +18,7 @@ import ( "golang.org/x/sync/singleflight" "github.com/smallstep/linkedca" + "go.step.sm/crypto/x509util" "github.com/smallstep/certificates/acme/wire" ) @@ -37,6 +40,8 @@ const ( WIREOIDC_01 ACMEChallenge = "wire-oidc-01" // WIREDPOP_01 is the Wire DPoP challenge. WIREDPOP_01 ACMEChallenge = "wire-dpop-01" + // DNS_PERSIST_01 is the dns-persist-01 ACME challenge. + DNS_PERSIST_01 ACMEChallenge = "dns-persist-01" ) // String returns a normalized version of the challenge. @@ -47,7 +52,7 @@ func (c ACMEChallenge) String() string { // Validate returns an error if the acme challenge is not a valid one. func (c ACMEChallenge) Validate() error { switch ACMEChallenge(c.String()) { - case HTTP_01, DNS_01, TLS_ALPN_01, DEVICE_ATTEST_01, WIREOIDC_01, WIREDPOP_01: + case HTTP_01, DNS_01, TLS_ALPN_01, DEVICE_ATTEST_01, WIREOIDC_01, WIREDPOP_01, DNS_PERSIST_01: return nil default: return fmt.Errorf("acme challenge %q is not supported", c) @@ -116,9 +121,21 @@ type ACME struct { RequireEAB bool `json:"requireEAB,omitempty"` // Challenges contains the enabled challenges for this provisioner. If this // value is not set the default http-01, dns-01 and tls-alpn-01 challenges - // will be enabled, device-attest-01, wire-oidc-01 and wire-dpop-01 will be - // disabled. + // will be enabled, device-attest-01, wire-oidc-01, wire-dpop-01 and + // dns-persist-01 will be disabled. Challenges []ACMEChallenge `json:"challenges,omitempty"` + // IssuerDomainNames is an array of domain names that the ACME server + // identifies itself with in dns-persist-01 validation records. They must + // be lowercase A-labels without a trailing dot and, if CaaIdentities is + // set, they must be included in it. Between 1 and 10 names are required + // if dns-persist-01 is enabled. + IssuerDomainNames []string `json:"issuerDomainNames,omitempty"` + // AccountHashPrefix is the URL prefix used to build the hashed account + // URIs in dns-persist-01 validation records, e.g. + // https://ca.example.com/acme/account-hash/. Validation records are + // bound to this value, so it must not change once records have been + // provisioned. It is required if dns-persist-01 is enabled. + AccountHashPrefix string `json:"accountHashPrefix,omitempty"` // AttestationFormats contains the enabled attestation formats for this // provisioner. If this value is not set the default apple, step and tpm // will be used. @@ -250,6 +267,10 @@ func (p *ACME) Init(config Config) (err error) { return fmt.Errorf("failed initializing Wire options: %w", err) } + if err := p.validateDNSPersistOptions(); err != nil { + return fmt.Errorf("failed validating dns-persist-01 options: %w", err) + } + p.androidCRL = &androidCRLCache{} p.ctl, err = NewController(p, p.Claims, config, p.Options) @@ -369,6 +390,86 @@ func (p *ACME) initializeWireOptions() error { return nil } +// maxIssuerDomainNames is the maximum number of issuer domain names that can be +// sent in a dns-persist-01 challenge. +const maxIssuerDomainNames = 10 + +// validateDNSPersistOptions validates the properties required by the +// dns-persist-01 challenge. It'll return early if the challenge is not +// enabled. +func (p *ACME) validateDNSPersistOptions() error { + hasDNSPersistChallenge := false + for _, c := range p.Challenges { + if ACMEChallenge(c.String()) == DNS_PERSIST_01 { + hasDNSPersistChallenge = true + break + } + } + if !hasDNSPersistChallenge { + return nil + } + + switch n := len(p.IssuerDomainNames); { + case n == 0: + return errors.New("issuerDomainNames cannot be empty") + case n > maxIssuerDomainNames: + return fmt.Errorf("issuerDomainNames cannot have more than %d names", maxIssuerDomainNames) + } + + // If the provisioner advertises CAA identities, the issuer domain names + // must be a subset of them. CAA identities are compared after applying + // the same normalization. + var caaIdentities []string + for _, id := range p.CaaIdentities { + if name, err := normalizeIssuerDomainName(id); err == nil { + caaIdentities = append(caaIdentities, name) + } + } + + for _, name := range p.IssuerDomainNames { + normalized, err := normalizeIssuerDomainName(name) + if err != nil { + return fmt.Errorf("issuerDomainNames %q is not valid: %w", name, err) + } + if name != normalized { + return fmt.Errorf("issuerDomainNames %q must be a lowercase A-label without a trailing dot; use %q instead", name, normalized) + } + if len(p.CaaIdentities) > 0 && !slices.Contains(caaIdentities, name) { + return fmt.Errorf("issuerDomainNames %q must be included in caaIdentities", name) + } + } + + if p.AccountHashPrefix == "" { + return errors.New("accountHashPrefix cannot be empty") + } + // The hashed account URI is a parameter value in the validation record, + // so the prefix cannot contain whitespace, semicolons or non-ASCII + // characters, see RFC 8659, section 4.2. + for _, c := range []byte(p.AccountHashPrefix) { + if c < 0x21 || c > 0x7e || c == ';' { + return fmt.Errorf("accountHashPrefix %q contains invalid characters", p.AccountHashPrefix) + } + } + if u, err := url.Parse(p.AccountHashPrefix + "sha-256/"); err != nil || !u.IsAbs() { + return fmt.Errorf("accountHashPrefix %q is not a valid absolute URL prefix", p.AccountHashPrefix) + } + + return nil +} + +// normalizeIssuerDomainName returns the given domain name as a lowercase +// A-label without a trailing dot. +func normalizeIssuerDomainName(name string) (string, error) { + normalized, err := x509util.SanitizeName(strings.TrimSuffix(name, ".")) + if err != nil { + return "", err + } + if len(normalized) > 253 { + return "", errors.New("domain name cannot be longer than 253 octets") + } + return normalized, nil +} + // ACMEIdentifierType encodes ACME Identifier types type ACMEIdentifierType string @@ -479,6 +580,18 @@ func (p *ACME) IsChallengeEnabled(_ context.Context, challenge ACMEChallenge) bo return false } +// GetIssuerDomainNames returns the issuer domain names used in dns-persist-01 +// challenges. +func (p *ACME) GetIssuerDomainNames() []string { + return p.IssuerDomainNames +} + +// GetAccountHashPrefix returns the URL prefix used in the hashed account URIs +// of dns-persist-01 validation records. +func (p *ACME) GetAccountHashPrefix() string { + return p.AccountHashPrefix +} + // IsAttestationFormatEnabled checks if the given attestation format is enabled. // By default apple, step and tpm are enabled, to disable any of them the // AttestationFormat provisioner property should have at least one element. diff --git a/authority/provisioner/acme_test.go b/authority/provisioner/acme_test.go index acac63c41..67a54ba30 100644 --- a/authority/provisioner/acme_test.go +++ b/authority/provisioner/acme_test.go @@ -36,6 +36,7 @@ func TestACMEChallenge_Validate(t *testing.T) { {"device-attest-01", DEVICE_ATTEST_01, false}, {"wire-oidc-01", DEVICE_ATTEST_01, false}, {"wire-dpop-01", DEVICE_ATTEST_01, false}, + {"dns-persist-01", DNS_PERSIST_01, false}, {"uppercase", "HTTP-01", false}, {"fail", "http-02", true}, } @@ -93,6 +94,13 @@ func TestACME_Getters(t *testing.T) { tokenID, err := p.GetTokenID("token") assert.Empty(t, tokenID) assert.Equal(t, ErrTokenFlowNotSupported, err) + assert.Empty(t, p.GetIssuerDomainNames()) + assert.Empty(t, p.GetAccountHashPrefix()) + + p.IssuerDomainNames = []string{"ca.example.com"} + p.AccountHashPrefix = "https://ca.example.com/acme/account-hash/" + assert.Equal(t, []string{"ca.example.com"}, p.GetIssuerDomainNames()) + assert.Equal(t, "https://ca.example.com/acme/account-hash/", p.GetAccountHashPrefix()) } func TestACME_Init(t *testing.T) { @@ -198,6 +206,130 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= err: errors.New("failed initializing Wire options: failed validating Wire options: failed initializing OIDC options: provider not set"), } }, + "fail/dns-persist-missing-issuer-domain-names": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New("failed validating dns-persist-01 options: issuerDomainNames cannot be empty"), + } + }, + "fail/dns-persist-too-many-issuer-domain-names": func(t *testing.T) ProvisionerValidateTest { + names := make([]string, 11) + for i := range names { + names[i] = fmt.Sprintf("ca%d.example.com", i) + } + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: names, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New("failed validating dns-persist-01 options: issuerDomainNames cannot have more than 10 names"), + } + }, + "fail/dns-persist-invalid-issuer-domain-name": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca_example.com"}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: issuerDomainNames "ca_example.com" is not valid: server name contains invalid character`), + } + }, + "fail/dns-persist-issuer-domain-name-too-long": func(t *testing.T) ProvisionerValidateTest { + name := strings.Repeat(strings.Repeat("a", 63)+".", 4) + "com" + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{name}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: fmt.Errorf("failed validating dns-persist-01 options: issuerDomainNames %q is not valid: domain name cannot be longer than 253 octets", name), + } + }, + "fail/dns-persist-issuer-domain-name-not-normalized": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"CA.Example.com."}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: issuerDomainNames "CA.Example.com." must be a lowercase A-label without a trailing dot; use "ca.example.com" instead`), + } + }, + "fail/dns-persist-issuer-domain-name-not-a-label": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"bücher.example"}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: issuerDomainNames "bücher.example" must be a lowercase A-label without a trailing dot; use "xn--bcher-kva.example" instead`), + } + }, + "fail/dns-persist-issuer-domain-name-not-in-caa-identities": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + CaaIdentities: []string{"ca.example.com"}, + IssuerDomainNames: []string{"ca.example.com", "ca.example.net"}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: issuerDomainNames "ca.example.net" must be included in caaIdentities`), + } + }, + "fail/dns-persist-missing-account-hash-prefix": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca.example.com"}, + }, + err: errors.New("failed validating dns-persist-01 options: accountHashPrefix cannot be empty"), + } + }, + "fail/dns-persist-account-hash-prefix-invalid-characters": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca.example.com"}, + AccountHashPrefix: "https://ca.example.com/acme;account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: accountHashPrefix "https://ca.example.com/acme;account-hash/" contains invalid characters`), + } + }, + "fail/dns-persist-account-hash-prefix-not-absolute": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca.example.com"}, + AccountHashPrefix: "/acme/account-hash/", + }, + err: errors.New(`failed validating dns-persist-01 options: accountHashPrefix "/acme/account-hash/" is not a valid absolute URL prefix`), + } + }, "ok": func(t *testing.T) ProvisionerValidateTest { return ProvisionerValidateTest{ p: &ACME{Name: "foo", Type: "ACME"}, @@ -214,6 +346,28 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= }, } }, + "ok/dns-persist": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_01, "DNS-PERSIST-01"}, + CaaIdentities: []string{"CA.example.com.", "ca.example.net"}, + IssuerDomainNames: []string{"ca.example.com"}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + }, + } + }, + "ok/dns-persist-disabled": func(t *testing.T) ProvisionerValidateTest { + return ProvisionerValidateTest{ + p: &ACME{ + Name: "foo", + Type: "ACME", + Challenges: []ACMEChallenge{DNS_01}, + IssuerDomainNames: []string{"CA.example.com."}, + }, + } + }, "ok/wire": func(t *testing.T) ProvisionerValidateTest { return ProvisionerValidateTest{ p: &ACME{ @@ -395,12 +549,14 @@ func TestACME_IsChallengeEnabled(t *testing.T) { {"ok dns-01", fields{nil}, args{ctx, DNS_01}, true}, {"ok tls-alpn-01", fields{[]ACMEChallenge{}}, args{ctx, TLS_ALPN_01}, true}, {"fail device-attest-01", fields{[]ACMEChallenge{}}, args{ctx, "device-attest-01"}, false}, + {"fail dns-persist-01", fields{nil}, args{ctx, DNS_PERSIST_01}, false}, {"ok http-01 enabled", fields{[]ACMEChallenge{"http-01"}}, args{ctx, "HTTP-01"}, true}, {"ok dns-01 enabled", fields{[]ACMEChallenge{"http-01", "dns-01"}}, args{ctx, DNS_01}, true}, {"ok tls-alpn-01 enabled", fields{[]ACMEChallenge{"http-01", "dns-01", "tls-alpn-01"}}, args{ctx, TLS_ALPN_01}, true}, {"ok device-attest-01 enabled", fields{[]ACMEChallenge{"device-attest-01", "dns-01"}}, args{ctx, DEVICE_ATTEST_01}, true}, {"ok wire-oidc-01 enabled", fields{[]ACMEChallenge{"wire-oidc-01"}}, args{ctx, WIREOIDC_01}, true}, {"ok wire-dpop-01 enabled", fields{[]ACMEChallenge{"wire-dpop-01"}}, args{ctx, WIREDPOP_01}, true}, + {"ok dns-persist-01 enabled", fields{[]ACMEChallenge{"dns-01", "dns-persist-01"}}, args{ctx, DNS_PERSIST_01}, true}, {"fail http-01", fields{[]ACMEChallenge{"dns-01"}}, args{ctx, "http-01"}, false}, {"fail dns-01", fields{[]ACMEChallenge{"http-01", "tls-alpn-01"}}, args{ctx, "dns-01"}, false}, {"fail tls-alpn-01", fields{[]ACMEChallenge{"http-01", "dns-01", "device-attest-01"}}, args{ctx, "tls-alpn-01"}, false}, From 15d7244fdd6749356c5893143c9be621dae369fe Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 12:57:11 -0500 Subject: [PATCH 2/8] Validate dns-persist-01 ACME challenges Implement the dns-persist-01 validation method defined in draft-ietf-acme-dns-persist-02. The validation looks up the TXT records at _validation-persist., ignores the ones for other issuers, and succeeds if a record binds the domain to the account using the hashed account URI. Wildcard identifiers require policy=wildcard. The persistUntil parameter limits the expiration of the authorization, and orders cannot be finalized once an authorization validated with dns-persist-01 has expired. --- acme/api/account_test.go | 2 + acme/challenge.go | 300 ++++++++++++++++++++++-- acme/challenge_test.go | 486 +++++++++++++++++++++++++++++++++++++++ acme/common.go | 22 ++ acme/order.go | 25 ++ acme/order_test.go | 61 +++++ 6 files changed, 883 insertions(+), 13 deletions(-) diff --git a/acme/api/account_test.go b/acme/api/account_test.go index 140a15e72..770436673 100644 --- a/acme/api/account_test.go +++ b/acme/api/account_test.go @@ -51,6 +51,8 @@ func (*fakeProvisioner) IsAttestationFormatEnabled(context.Context, provisioner. return true } func (*fakeProvisioner) GetAttestationRoots() (*x509.CertPool, bool) { return nil, false } +func (*fakeProvisioner) GetIssuerDomainNames() []string { return nil } +func (*fakeProvisioner) GetAccountHashPrefix() string { return "" } func (*fakeProvisioner) AuthorizeRevoke(context.Context, string) error { return nil } func (*fakeProvisioner) GetID() string { return "" } func (*fakeProvisioner) GetName() string { return "" } diff --git a/acme/challenge.go b/acme/challenge.go index 4e1e3f53c..7b4af8a3a 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -60,6 +60,8 @@ const ( WIREOIDC01 ChallengeType = "wire-oidc-01" // WIREDPOP01 is the Wire DPoP challenge type WIREDPOP01 ChallengeType = "wire-dpop-01" + // DNSPERSIST01 is the dns-persist-01 ACME challenge type + DNSPERSIST01 ChallengeType = "dns-persist-01" ) var ( @@ -81,19 +83,20 @@ var ( // Challenge represents an ACME response Challenge type. type Challenge struct { - ID string `json:"-"` - AccountID string `json:"-"` - AuthorizationID string `json:"-"` - Value string `json:"-"` - Type ChallengeType `json:"type"` - Status Status `json:"status"` - Token string `json:"token"` - ValidatedAt string `json:"validated,omitempty"` - URL string `json:"url"` - Target string `json:"target,omitempty"` - Error *Error `json:"error,omitempty"` - Payload []byte `json:"-"` - PayloadFormat string `json:"-"` + ID string `json:"-"` + AccountID string `json:"-"` + AuthorizationID string `json:"-"` + Value string `json:"-"` + Type ChallengeType `json:"type"` + Status Status `json:"status"` + Token string `json:"token,omitempty"` + ValidatedAt string `json:"validated,omitempty"` + URL string `json:"url"` + Target string `json:"target,omitempty"` + IssuerDomainNames []string `json:"issuerDomainNames,omitempty"` + Error *Error `json:"error,omitempty"` + Payload []byte `json:"-"` + PayloadFormat string `json:"-"` } // ToLog enables response logging. @@ -118,6 +121,8 @@ func (ch *Challenge) Validate(ctx context.Context, db DB, jwk *jose.JSONWebKey, return http01Validate(ctx, ch, db, jwk) case DNS01: return dns01Validate(ctx, ch, db, jwk) + case DNSPERSIST01: + return dnsPersist01Validate(ctx, ch, db, jwk) case TLSALPN01: return tlsalpn01Validate(ctx, ch, db, jwk) case DEVICEATTEST01: @@ -228,6 +233,12 @@ func dns01ChallengeHost(domain string) string { return "_acme-challenge." + rootedName(domain) } +// dnsPersist01ChallengeHost returns the TXT record used in DNS-PERSIST-01 +// validations. +func dnsPersist01ChallengeHost(domain string) string { + return "_validation-persist." + rootedName(domain) +} + func tlsAlert(err error) uint8 { if opErr, ok := errors.AsType[*net.OpError](err); ok { v := reflect.ValueOf(opErr.Err) @@ -397,6 +408,269 @@ func dns01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSONWebK return nil } +// dnsPersist01HashAlgorithm is the hash algorithm used in the hashed account +// URIs of dns-persist-01 validation records. It is the only one supported. +const dnsPersist01HashAlgorithm = "sha-256" + +// dnsPersist01Record contains the parameters of a dns-persist-01 validation +// record. +type dnsPersist01Record struct { + accountURI string + wildcard bool + persistUntil time.Time +} + +// dnsPersist01Validate validates a dns-persist-01 challenge as defined in +// draft-ietf-acme-dns-persist-02. Validation succeeds if one of the TXT +// records at _validation-persist. contains one of the challenge issuer +// domain names and a hashed account URI that binds the record to the account. +func dnsPersist01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSONWebKey) error { + // Load the authorization to check if it's for a wildcard identifier, and + // to limit its expiration to the persistUntil parameter of the record. + az, err := db.GetAuthorization(ctx, ch.AuthorizationID) + if err != nil { + return WrapErrorISE(err, "error loading authorization") + } + + // The account status is checked on every request, but the draft requires + // to check it on every validation attempt too. + acc, err := db.GetAccount(ctx, ch.AccountID) + if err != nil { + return WrapErrorISE(err, "error loading account") + } + if !acc.IsValid() { + return storeError(ctx, db, ch, true, NewError(ErrorUnauthorizedType, + "account %s is not valid", acc.ID)) + } + + // The hashed account URI is computed over the account URL returned to the + // client, so use the stored location instead of one derived from the + // current request. + accountURL := acc.GetLocation() + if accountURL == "" { + return storeError(ctx, db, ch, true, NewError(ErrorUnauthorizedType, + "account %s does not have the stored location required by %s challenges", acc.ID, ch.Type)) + } + + // Normalize domain for wildcard DNS names, the validation record is always + // at the base domain. + domain, err := x509util.SanitizeName(strings.TrimSuffix(strings.TrimPrefix(ch.Value, "*."), ".")) + if err != nil || len(domain) > 253 { + return storeError(ctx, db, ch, true, NewError(ErrorMalformedType, + "invalid DNS name %s", ch.Value)) + } + + // Key rollover is not supported, so the current account key is the only + // one that has been used by the account. + thumbprint, err := KeyToID(jwk) + if err != nil { + return err + } + prefix := MustProvisionerFromContext(ctx).GetAccountHashPrefix() + expected := []string{ + dnsPersist01AccountURI(prefix, domain, thumbprint, accountURL), + dnsPersist01AccountURI(prefix, "*", thumbprint, accountURL), + } + + host := dnsPersist01ChallengeHost(domain) + vc := MustClientFromContext(ctx) + txtRecords, err := vc.LookupTxt(host) + if err != nil { + return storeError(ctx, db, ch, false, WrapError(ErrorDNSType, err, + "error looking up TXT records for domain %s", domain)) + } + + // Validation succeeds if any of the records for the issuer domain names in + // the challenge is valid. Records for other CAs are ignored. If no record + // is valid, the error from the first one is reported. + var ( + record *dnsPersist01Record + recordErr *Error + ) + now := clock.Now() + for _, txt := range txtRecords { + rec, ok, acmeErr := parseDNSPersist01Record(txt, ch.IssuerDomainNames, prefix) + if !ok { + continue + } + if acmeErr == nil { + acmeErr = verifyDNSPersist01Record(rec, now, expected, az.Wildcard, domain) + } + if acmeErr != nil { + if recordErr == nil { + recordErr = acmeErr + } + continue + } + record = rec + break + } + if record == nil { + if recordErr == nil { + recordErr = NewError(ErrorUnauthorizedType, + "no TXT record found at %s for issuer domain names %s", host, strings.Join(ch.IssuerDomainNames, ", ")) + } + return storeError(ctx, db, ch, false, recordErr) + } + + // The authorization cannot expire after the persistUntil timestamp. + // + // TODO: add method to update authorization and challenge atomically. + if !record.persistUntil.IsZero() && record.persistUntil.Before(az.ExpiresAt) { + az.ExpiresAt = record.persistUntil + if err := db.UpdateAuthorization(ctx, az); err != nil { + return WrapErrorISE(err, "error updating authorization") + } + } + + // Update and store the challenge. + ch.Status = StatusValid + ch.Error = nil + ch.ValidatedAt = clock.Now().Format(time.RFC3339) + + if err := db.UpdateChallenge(ctx, ch); err != nil { + return WrapErrorISE(err, "error updating challenge") + } + return nil +} + +// dnsPersist01AccountURI returns the hashed account URI that binds a +// dns-persist-01 validation record for the given domain to an account. The +// domain can be "*" for records that are not bound to a domain. The domain must +// not be longer than 255 octets. +func dnsPersist01AccountURI(prefix, domain, thumbprint, accountURL string) string { + b := make([]byte, 0, 1+len(domain)+len(thumbprint)+len(accountURL)) + b = append(b, cast.Uint8(len(domain))) + b = append(b, domain...) + b = append(b, thumbprint...) + b = append(b, accountURL...) + sum := sha256.Sum256(b) + return prefix + dnsPersist01HashAlgorithm + "/" + base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// parseDNSPersist01Record parses a dns-persist-01 validation record, using the +// issue-value syntax defined in RFC 8659, section 4.2. It returns false if the +// issuer domain name of the record is not one of the given ones. Unknown +// parameters are ignored. +func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHashPrefix string) (*dnsPersist01Record, bool, *Error) { + issuer, params, _ := strings.Cut(txt, ";") + issuer = strings.ToLower(strings.TrimSuffix(strings.Trim(issuer, " \t"), ".")) + if !slices.Contains(issuerDomainNames, issuer) { + return nil, false, nil + } + + rec := new(dnsPersist01Record) + if strings.Trim(params, " \t") != "" { + seen := make(map[string]bool) + for param := range strings.SplitSeq(params, ";") { + tag, value, ok := strings.Cut(param, "=") + tag, value = strings.Trim(tag, " \t"), strings.Trim(value, " \t") + if !ok || !isDNSPersist01Tag(tag) || !isDNSPersist01Value(value) { + return nil, true, NewError(ErrorMalformedType, + "TXT record %q has an invalid parameter %q", txt, param) + } + + // Tags are compared case-insensitively, so a constraint like + // persistUntil is never ignored because of its case. + tag = strings.ToLower(tag) + if seen[tag] { + return nil, true, NewError(ErrorMalformedType, + "TXT record %q has a duplicate parameter %q", txt, tag) + } + seen[tag] = true + + switch tag { + case "accounturi": + if err := validateDNSPersist01AccountURI(value, accountHashPrefix); err != nil { + return nil, true, err + } + rec.accountURI = value + case "policy": + rec.wildcard = strings.EqualFold(value, "wildcard") + case "persistuntil": + ts, err := strconv.ParseInt(value, 10, 64) + if err != nil || strings.Trim(value, "0123456789") != "" { + return nil, true, NewError(ErrorMalformedType, + "TXT record %q has an invalid persistUntil value %q", txt, value) + } + rec.persistUntil = time.Unix(ts, 0).UTC() + } + } + } + + if rec.accountURI == "" { + return nil, true, NewError(ErrorMalformedType, + "TXT record %q is missing the accounturi parameter", txt) + } + return rec, true, nil +} + +// validateDNSPersist01AccountURI checks that the given value is a hashed +// account URI with the format sha-256/. +func validateDNSPersist01AccountURI(value, accountHashPrefix string) *Error { + rest, ok := strings.CutPrefix(value, accountHashPrefix) + if !ok { + return NewError(ErrorMalformedType, + "accounturi %q does not start with %q", value, accountHashPrefix) + } + alg, hash, _ := strings.Cut(rest, "/") + if alg != dnsPersist01HashAlgorithm { + return NewError(ErrorMalformedType, + "accounturi %q does not use the %s hash algorithm", value, dnsPersist01HashAlgorithm) + } + if b, err := base64.RawURLEncoding.Strict().DecodeString(hash); err != nil || len(b) != sha256.Size { + return NewError(ErrorMalformedType, + "accounturi %q does not contain a valid %s hash value", value, dnsPersist01HashAlgorithm) + } + return nil +} + +// verifyDNSPersist01Record checks that a dns-persist-01 validation record for +// the given domain authorizes the account and has not expired. +func verifyDNSPersist01Record(rec *dnsPersist01Record, now time.Time, expected []string, wildcard bool, domain string) *Error { + switch { + case !slices.Contains(expected, rec.accountURI): + return NewError(ErrorUnauthorizedType, + "accounturi does not match; expected %s, but got %s", expected[0], rec.accountURI) + case !rec.persistUntil.IsZero() && now.After(rec.persistUntil): + return NewError(ErrorUnauthorizedType, + "TXT record expired at %s", rec.persistUntil.Format(time.RFC3339)) + case wildcard && !rec.wildcard: + return NewError(ErrorUnauthorizedType, + "TXT record does not have the policy=wildcard parameter required for *.%s", domain) + default: + return nil + } +} + +// isDNSPersist01Tag returns true if the given string matches the tag syntax: +// +// tag = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT)) +func isDNSPersist01Tag(s string) bool { + if s == "" || s[0] == '-' || s[len(s)-1] == '-' { + return false + } + for _, c := range []byte(s) { + if c != '-' && (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') { + return false + } + } + return true +} + +// isDNSPersist01Value returns true if the given string matches the value +// syntax: +// +// value = *(%x21-3A / %x3C-7E) +func isDNSPersist01Value(s string) bool { + for _, c := range []byte(s) { + if c < 0x21 || c > 0x7e || c == ';' { + return false + } + } + return true +} + type wireOidcPayload struct { // IDToken contains the OIDC identity token IDToken string `json:"id_token"` diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 6177eb926..51d4edf3c 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -788,6 +788,57 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= }, } }, + "ok/dns-persist-01": func(t *testing.T) test { + ch := &Challenge{ + ID: "chID", + AccountID: "accID", + AuthorizationID: "azID", + Type: "dns-persist-01", + Status: StatusPending, + Value: "zap.internal", + IssuerDomainNames: []string{"ca.example.com"}, + } + + return test{ + ch: ch, + jwk: dnsPersist01TestJWK(t), + ctx: NewProvisionerContext(context.Background(), &MockProvisioner{ + MgetAccountHashPrefix: func() string { return "https://ca.example.com/account-hash/" }, + }), + vc: &mockClient{ + lookupTxt: func(name string) ([]string, error) { + assert.Equal(t, "_validation-persist.zap.internal", name) + return nil, errors.New("force") + }, + }, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + assert.Equal(t, "azID", id) + return &Authorization{ID: id, Status: StatusPending}, nil + }, + MockGetAccount: func(ctx context.Context, id string) (*Account, error) { + assert.Equal(t, "accID", id) + return &Account{ID: id, Status: StatusValid, LocationPrefix: "https://ca.example.com/acme/acme/account/"}, nil + }, + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, ChallengeType("dns-persist-01"), updch.Type) + assert.Equal(t, "zap.internal", updch.Value) + assert.Equal(t, StatusPending, updch.Status) + + err := NewError(ErrorDNSType, "error looking up TXT records for domain %s: force", ch.Value) + + assert.EqualError(t, updch.Error.Err, err.Err.Error()) + assert.Equal(t, err.Type, updch.Error.Type) + assert.Equal(t, err.Detail, updch.Error.Detail) + assert.Equal(t, err.Status, updch.Error.Status) + assert.Equal(t, err.Subproblems, updch.Error.Subproblems) + + return nil + }, + }, + } + }, "fail/tls-alpn-01": func(t *testing.T) test { ch := &Challenge{ ID: "chID", @@ -5452,3 +5503,438 @@ func Test_dns01ChallengeHost(t *testing.T) { }) } } + +func Test_dnsPersist01ChallengeHost(t *testing.T) { + tests := []struct { + name string + strictFQDN bool + domain string + want string + }{ + {"dns", false, "smallstep.com", "_validation-persist.smallstep.com"}, + {"dns strict", true, "smallstep.com", "_validation-persist.smallstep.com."}, + {"rooted dns", false, "smallstep.com.", "_validation-persist.smallstep.com."}, + {"rooted dns strict", true, "smallstep.com.", "_validation-persist.smallstep.com."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := StrictFQDN + t.Cleanup(func() { + StrictFQDN = tmp + }) + StrictFQDN = tt.strictFQDN + assert.Equal(t, tt.want, dnsPersist01ChallengeHost(tt.domain)) + }) + } +} + +// dnsPersist01TestJWK returns the example key from RFC 7638, section 3.1. It +// is the key used in the examples of draft-ietf-acme-dns-persist-02. +func dnsPersist01TestJWK(t *testing.T) *jose.JSONWebKey { + t.Helper() + jwk := new(jose.JSONWebKey) + require.NoError(t, json.Unmarshal([]byte(`{ + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "alg": "RS256", + "kid": "2011-04-29" + }`), jwk)) + return jwk +} + +func Test_dnsPersist01AccountURI(t *testing.T) { + // Test vectors from draft-ietf-acme-dns-persist-02, section 10.2. + thumbprint, err := KeyToID(dnsPersist01TestJWK(t)) + require.NoError(t, err) + require.Equal(t, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", thumbprint) + + const ( + prefix = "https://ca.example/account-hash/" + accountURL = "https://ca.example/acct/123" + ) + tests := []struct { + name string + domain string + want string + }{ + {"domain", "example.com", "https://ca.example/account-hash/sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw"}, + {"any domain", "*", "https://ca.example/account-hash/sha-256/NpDnSwUthQK8zCgFdefYxAdAVPnygMLbs9US6oO-5ug"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, dnsPersist01AccountURI(prefix, tt.domain, thumbprint, accountURL)) + }) + } +} + +func TestDNSPersist01Validate(t *testing.T) { + // The key, account URL and hashed account URIs are the ones in + // draft-ietf-acme-dns-persist-02, section 10.2. + const ( + prefix = "https://ca.example/account-hash/" + accountURI = prefix + "sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw" + anyDomainURI = prefix + "sha-256/NpDnSwUthQK8zCgFdefYxAdAVPnygMLbs9US6oO-5ug" + otherAccountURI = prefix + "sha-256/Mpngr2LZCN9gPArggXqEu4pKtYZvLL5IxMStzyWrrXc" + validationDomain = "_validation-persist.example.com" + ) + jwk := dnsPersist01TestJWK(t) + issuerDomainNames := []string{"authority.example", "ca.example.net"} + now := clock.Now() + expiresAt := now.Add(24 * time.Hour) + persistUntil := now.Add(time.Hour) + validAccount := &Account{ID: "123", Status: StatusValid, LocationPrefix: "https://ca.example/acct/"} + wwwAccountURI := dnsPersist01AccountURI(prefix, "www.example.com", "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", "https://ca.example/acct/123") + + tests := []struct { + name string + value string + wildcard bool + account *Account + accountErr error + authzErr error + txtRecords []string + lookupErr error + updChErr error + updAzErr error + wantStatus Status + wantChErr *Error + wantExpires time.Time + err *Error + }{ + { + name: "ok", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI}, + wantStatus: StatusValid, + }, + { + name: "ok/any-domain", + value: "example.com", + txtRecords: []string{"ca.example.net; accounturi=" + anyDomainURI}, + wantStatus: StatusValid, + }, + { + name: "ok/normalized-identifier", + value: "Example.COM", + txtRecords: []string{"authority.example; accounturi=" + accountURI}, + wantStatus: StatusValid, + }, + { + name: "ok/case-insensitive", + value: "example.com", + wildcard: true, + txtRecords: []string{"Authority.Example.;AccountURI=" + accountURI + " ;\tPolicy=WILDCARD"}, + wantStatus: StatusValid, + }, + { + name: "ok/wildcard", + value: "example.com", + wildcard: true, + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy=wildcard"}, + wantStatus: StatusValid, + }, + { + name: "ok/unknown-parameters", + value: "example.com", + txtRecords: []string{"authority.example; foo=bar; accounturi=" + accountURI + "; policy=other; empty="}, + wantStatus: StatusValid, + }, + { + name: "ok/multiple-records", + value: "example.com", + txtRecords: []string{ + "ca1.example; accounturi=https://ca1.example/account-hash/sha-256/wjZNvLpIgf7zrr9KQo7zCLaG2Ea67KS4o4cAwSepsls; policy=wildcard", + "ca2.example; invalid record", + "authority.example; accounturi=" + otherAccountURI, + "ca.example.net; accounturi=" + accountURI, + }, + wantStatus: StatusValid, + }, + { + name: "ok/persist-until", + value: "example.com", + txtRecords: []string{fmt.Sprintf("authority.example; accounturi=%s; persistUntil=%d", accountURI, persistUntil.Unix())}, + wantStatus: StatusValid, + wantExpires: persistUntil, + }, + { + name: "ok/persist-until-after-expiration", + value: "example.com", + txtRecords: []string{fmt.Sprintf("authority.example; accounturi=%s; persistUntil=%d", accountURI, expiresAt.Add(time.Hour).Unix())}, + wantStatus: StatusValid, + }, + { + name: "fail/get-authorization-error", + value: "example.com", + authzErr: errors.New("force"), + wantStatus: StatusPending, + err: NewErrorISE("error loading authorization: force"), + }, + { + name: "fail/get-account-error", + value: "example.com", + accountErr: errors.New("force"), + wantStatus: StatusPending, + err: NewErrorISE("error loading account: force"), + }, + { + name: "fail/account-deactivated", + value: "example.com", + account: &Account{ID: "123", Status: StatusDeactivated, LocationPrefix: "https://ca.example/acct/"}, + wantStatus: StatusInvalid, + wantChErr: NewError(ErrorUnauthorizedType, "account 123 is not valid"), + }, + { + name: "fail/account-without-location", + value: "example.com", + account: &Account{ID: "123", Status: StatusValid}, + wantStatus: StatusInvalid, + wantChErr: NewError(ErrorUnauthorizedType, "account 123 does not have the stored location required by dns-persist-01 challenges"), + }, + { + name: "fail/invalid-domain", + value: "exa mple.com", + wantStatus: StatusInvalid, + wantChErr: NewError(ErrorMalformedType, "invalid DNS name exa mple.com"), + }, + { + name: "fail/lookup-error", + value: "example.com", + lookupErr: errors.New("force"), + wantStatus: StatusPending, + wantChErr: NewError(ErrorDNSType, "error looking up TXT records for domain example.com: force"), + }, + { + name: "fail/no-records", + value: "example.com", + txtRecords: []string{"ca1.example; accounturi=" + accountURI}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "no TXT record found at _validation-persist.example.com for issuer domain names authority.example, ca.example.net"), + }, + { + name: "fail/missing-account-uri", + value: "example.com", + txtRecords: []string{"authority.example; policy=wildcard"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; policy=wildcard" is missing the accounturi parameter`), + }, + { + name: "fail/missing-parameters", + value: "example.com", + txtRecords: []string{"authority.example;"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example;" is missing the accounturi parameter`), + }, + { + name: "fail/duplicate-parameter", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=1; persistuntil=2"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=1; persistuntil=2" has a duplicate parameter "persistuntil"`, accountURI), + }, + { + name: "fail/parameter-without-value", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy" has an invalid parameter " policy"`, accountURI), + }, + { + name: "fail/empty-parameter", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + ";"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s;" has an invalid parameter ""`, accountURI), + }, + { + name: "fail/invalid-tag", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; -policy=wildcard"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; -policy=wildcard" has an invalid parameter " -policy=wildcard"`, accountURI), + }, + { + name: "fail/invalid-value", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy=wild card"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy=wild card" has an invalid parameter " policy=wild card"`, accountURI), + }, + { + name: "fail/account-uri-prefix", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=https://ca.example/acct/123"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/acct/123" does not start with "https://ca.example/account-hash/"`), + }, + { + name: "fail/account-uri-algorithm", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + prefix + "sha-512/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-512/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw" does not use the sha-256 hash algorithm`), + }, + { + name: "fail/account-uri-hash", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + prefix + "sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk" does not contain a valid sha-256 hash value`), + }, + { + name: "fail/account-uri-mismatch", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + otherAccountURI}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), + }, + { + name: "fail/account-uri-other-domain", + value: "www.example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", wwwAccountURI, accountURI), + }, + { + name: "fail/persist-until-invalid", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=+1767225600"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=+1767225600" has an invalid persistUntil value "+1767225600"`, accountURI), + }, + { + name: "fail/persist-until-expired", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=1721952000"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "TXT record expired at 2024-07-26T00:00:00Z"), + }, + { + name: "fail/wildcard-without-policy", + value: "example.com", + wildcard: true, + txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy=other"}, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "TXT record does not have the policy=wildcard parameter required for *.example.com"), + }, + { + name: "fail/first-error", + value: "example.com", + txtRecords: []string{ + "authority.example; accounturi=" + otherAccountURI, + "ca.example.net; policy=wildcard", + }, + wantStatus: StatusPending, + wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), + }, + { + name: "fail/update-authorization-error", + value: "example.com", + txtRecords: []string{fmt.Sprintf("authority.example; accounturi=%s; persistUntil=%d", accountURI, persistUntil.Unix())}, + updAzErr: errors.New("force"), + wantStatus: StatusPending, + wantExpires: persistUntil, + err: NewErrorISE("error updating authorization: force"), + }, + { + name: "fail/update-challenge-error", + value: "example.com", + txtRecords: []string{"authority.example; accounturi=" + accountURI}, + updChErr: errors.New("force"), + wantStatus: StatusValid, + err: NewErrorISE("error updating challenge: force"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := &Challenge{ + ID: "chID", + AccountID: "123", + AuthorizationID: "azID", + Type: DNSPERSIST01, + Value: tt.value, + Status: StatusPending, + IssuerDomainNames: issuerDomainNames, + } + account := validAccount + if tt.account != nil { + account = tt.account + } + + var ( + updatedCh *Challenge + updatedAz *Authorization + ) + db := &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + assert.Equal(t, "azID", id) + if tt.authzErr != nil { + return nil, tt.authzErr + } + return &Authorization{ID: id, Wildcard: tt.wildcard, ExpiresAt: expiresAt, Status: StatusPending}, nil + }, + MockGetAccount: func(ctx context.Context, id string) (*Account, error) { + assert.Equal(t, "123", id) + if tt.accountErr != nil { + return nil, tt.accountErr + } + return account, nil + }, + MockUpdateAuthorization: func(ctx context.Context, az *Authorization) error { + updatedAz = az + return tt.updAzErr + }, + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + updatedCh = updch + return tt.updChErr + }, + } + vc := &mockClient{ + lookupTxt: func(name string) ([]string, error) { + assert.Equal(t, strings.Replace(validationDomain, "example.com", strings.ToLower(tt.value), 1), name) + return tt.txtRecords, tt.lookupErr + }, + } + ctx := NewClientContext(context.Background(), vc) + ctx = NewProvisionerContext(ctx, &MockProvisioner{ + MgetAccountHashPrefix: func() string { return prefix }, + }) + + err := dnsPersist01Validate(ctx, ch, db, jwk) + if tt.err != nil { + var k *Error + if assert.ErrorAs(t, err, &k) { + assert.Equal(t, tt.err.Type, k.Type) + assert.Equal(t, tt.err.Status, k.Status) + assert.Equal(t, tt.err.Err.Error(), k.Err.Error()) + } + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantStatus, ch.Status) + if tt.err == nil { + require.NotNil(t, updatedCh) + } + if tt.wantChErr != nil { + if assert.NotNil(t, ch.Error) { + assert.Equal(t, tt.wantChErr.Type, ch.Error.Type) + assert.Equal(t, tt.wantChErr.Status, ch.Error.Status) + assert.Equal(t, tt.wantChErr.Err.Error(), ch.Error.Err.Error()) + } + } else { + assert.Nil(t, ch.Error) + } + if tt.wantStatus == StatusValid { + assert.NotEmpty(t, ch.ValidatedAt) + } + + if tt.wantExpires.IsZero() { + assert.Nil(t, updatedAz) + } else if assert.NotNil(t, updatedAz) { + assert.True(t, tt.wantExpires.Equal(updatedAz.ExpiresAt)) + } + }) + } +} diff --git a/acme/common.go b/acme/common.go index 400d4db04..da24ed70b 100644 --- a/acme/common.go +++ b/acme/common.go @@ -75,6 +75,8 @@ type Provisioner interface { IsChallengeEnabled(ctx context.Context, challenge provisioner.ACMEChallenge) bool IsAttestationFormatEnabled(ctx context.Context, format provisioner.ACMEAttestationFormat) bool GetAttestationRoots() (*x509.CertPool, bool) + GetIssuerDomainNames() []string + GetAccountHashPrefix() string GetID() string GetName() string DefaultTLSCertDuration() time.Duration @@ -119,6 +121,8 @@ type MockProvisioner struct { MisChallengeEnabled func(ctx context.Context, challenge provisioner.ACMEChallenge) bool MisAttFormatEnabled func(ctx context.Context, format provisioner.ACMEAttestationFormat) bool MgetAttestationRoots func() (*x509.CertPool, bool) + MgetIssuerDomainNames func() []string + MgetAccountHashPrefix func() string MdefaultTLSCertDuration func() time.Duration MgetOptions func() *provisioner.Options } @@ -178,6 +182,24 @@ func (m *MockProvisioner) GetAttestationRoots() (*x509.CertPool, bool) { return m.Mret1.(*x509.CertPool), m.Mret1 != nil } +// GetIssuerDomainNames mock +func (m *MockProvisioner) GetIssuerDomainNames() []string { + if m.MgetIssuerDomainNames != nil { + return m.MgetIssuerDomainNames() + } + names, _ := m.Mret1.([]string) + return names +} + +// GetAccountHashPrefix mock +func (m *MockProvisioner) GetAccountHashPrefix() string { + if m.MgetAccountHashPrefix != nil { + return m.MgetAccountHashPrefix() + } + prefix, _ := m.Mret1.(string) + return prefix +} + // DefaultTLSCertDuration mock func (m *MockProvisioner) DefaultTLSCertDuration() time.Duration { if m.MdefaultTLSCertDuration != nil { diff --git a/acme/order.go b/acme/order.go index 875d3aa5b..31764296a 100644 --- a/acme/order.go +++ b/acme/order.go @@ -161,6 +161,26 @@ func (o *Order) getAuthorizationFingerprint(ctx context.Context, db DB) (string, return "", nil } +// checkPersistentAuthorizations returns an error if an authorization validated +// with a dns-persist-01 challenge has expired. These authorizations can expire +// before the order does, because their expiration is limited by the +// persistUntil parameter of the validation record. +func (o *Order) checkPersistentAuthorizations(ctx context.Context, db DB) error { + now := clock.Now() + for _, azID := range o.AuthorizationIDs { + az, err := db.GetAuthorization(ctx, azID) + if err != nil { + return WrapErrorISE(err, "error getting authorization %q", azID) + } + for _, ch := range az.Challenges { + if ch.Type == DNSPERSIST01 && ch.Status == StatusValid && now.After(az.ExpiresAt) { + return NewError(ErrorUnauthorizedType, "order %s authorization %s has expired", o.ID, az.ID) + } + } + } + return nil +} + // Finalize signs a certificate if the necessary conditions for Order completion // have been met. // @@ -186,6 +206,11 @@ func (o *Order) Finalize(ctx context.Context, db DB, csr *x509.CertificateReques return NewErrorISE("unexpected status %s for order %s", o.Status, o.ID) } + // Do not issue certificates using expired dns-persist-01 validations. + if err := o.checkPersistentAuthorizations(ctx, db); err != nil { + return err + } + // Get key fingerprint if any. And then compare it with the CSR fingerprint. // // In device-attest-01 challenges we should check that the keys in the CSR diff --git a/acme/order_test.go b/acme/order_test.go index a89a118a2..75e6e6fde 100644 --- a/acme/order_test.go +++ b/acme/order_test.go @@ -404,6 +404,67 @@ func TestOrder_Finalize(t *testing.T) { err: NewErrorISE("unrecognized order status: %s", o.Status), } }, + "fail/get-authorization-error": func(t *testing.T) test { + now := clock.Now() + o := &Order{ + ID: "oID", + AccountID: "accID", + Status: StatusReady, + ExpiresAt: now.Add(5 * time.Minute), + AuthorizationIDs: []string{"a"}, + } + return test{ + o: o, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + return nil, errors.New("force") + }, + }, + err: NewErrorISE(`error getting authorization "a": force`), + } + }, + "fail/expired-dns-persist-01-authorization": func(t *testing.T) test { + now := clock.Now() + o := &Order{ + ID: "oID", + AccountID: "accID", + Status: StatusReady, + ExpiresAt: now.Add(5 * time.Minute), + AuthorizationIDs: []string{"a", "b"}, + } + return test{ + o: o, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + switch id { + case "a": + return &Authorization{ + ID: id, + Status: StatusValid, + ExpiresAt: o.ExpiresAt, + Challenges: []*Challenge{ + {Type: DNS01, Status: StatusValid}, + }, + }, nil + case "b": + return &Authorization{ + ID: id, + Status: StatusValid, + ExpiresAt: now.Add(-time.Minute), + Challenges: []*Challenge{ + {Type: DNS01, Status: StatusPending}, + {Type: DNSPERSIST01, Status: StatusValid}, + }, + }, nil + default: + assert.FatalError(t, errors.Errorf("unexpected authorization %s", id)) + return nil, errors.New("force") + } + }, + }, + err: NewError(ErrorUnauthorizedType, "order oID authorization b has expired"), + } + }, "fail/non-matching-permanent-identifier-common-name": func(t *testing.T) test { now := clock.Now() o := &Order{ From 815191c1e4ae98bae51d218cd65f205af253969b Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 12:57:15 -0500 Subject: [PATCH 3/8] Store dns-persist-01 data in the ACME nosql database Persist the issuer domain names of dns-persist-01 challenges, and allow updating the expiration of an authorization, so it can be limited by the persistUntil parameter of the validation record. --- acme/db/nosql/authz.go | 1 + acme/db/nosql/authz_test.go | 9 +++-- acme/db/nosql/challenge.go | 57 ++++++++++++++-------------- acme/db/nosql/challenge_test.go | 66 +++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 31 deletions(-) diff --git a/acme/db/nosql/authz.go b/acme/db/nosql/authz.go index be3b0fbbd..cb1d52ed6 100644 --- a/acme/db/nosql/authz.go +++ b/acme/db/nosql/authz.go @@ -116,6 +116,7 @@ func (db *DB) UpdateAuthorization(ctx context.Context, az *acme.Authorization) e nu := old.clone() nu.Status = az.Status nu.Fingerprint = az.Fingerprint + nu.ExpiresAt = az.ExpiresAt nu.Error = az.Error return db.save(ctx, old.ID, nu, old, "authz", authzTable) } diff --git a/acme/db/nosql/authz_test.go b/acme/db/nosql/authz_test.go index 6bc2b94d4..3491baa6e 100644 --- a/acme/db/nosql/authz_test.go +++ b/acme/db/nosql/authz_test.go @@ -501,9 +501,10 @@ func TestDB_UpdateAuthorization(t *testing.T) { }, "fail/db.CmpAndSwap-error": func(t *testing.T) test { updAz := &acme.Authorization{ - ID: azID, - Status: acme.StatusValid, - Error: acme.NewError(acme.ErrorMalformedType, "malformed"), + ID: azID, + Status: acme.StatusValid, + ExpiresAt: now.Add(time.Minute), + Error: acme.NewError(acme.ErrorMalformedType, "malformed"), } return test{ az: updAz, @@ -532,7 +533,7 @@ func TestDB_UpdateAuthorization(t *testing.T) { assert.Equals(t, dbNew.ChallengeIDs, dbaz.ChallengeIDs) assert.Equals(t, dbNew.Wildcard, dbaz.Wildcard) assert.Equals(t, dbNew.CreatedAt, dbaz.CreatedAt) - assert.Equals(t, dbNew.ExpiresAt, dbaz.ExpiresAt) + assert.Equals(t, dbNew.ExpiresAt, updAz.ExpiresAt) assert.Equals(t, dbNew.Error.Error(), acme.NewError(acme.ErrorMalformedType, "The request message was malformed").Error()) return nil, false, errors.New("force") }, diff --git a/acme/db/nosql/challenge.go b/acme/db/nosql/challenge.go index e7c9aa295..7193f73d4 100644 --- a/acme/db/nosql/challenge.go +++ b/acme/db/nosql/challenge.go @@ -13,16 +13,17 @@ import ( ) type dbChallenge struct { - ID string `json:"id"` - AccountID string `json:"accountID"` - Type acme.ChallengeType `json:"type"` - Status acme.Status `json:"status"` - Token string `json:"token"` - Value string `json:"value"` - Target string `json:"target,omitempty"` - ValidatedAt string `json:"validatedAt"` - CreatedAt time.Time `json:"createdAt"` - Error *acme.Error `json:"error"` // TODO(hs): a bit dangerous; should become db-specific type + ID string `json:"id"` + AccountID string `json:"accountID"` + Type acme.ChallengeType `json:"type"` + Status acme.Status `json:"status"` + Token string `json:"token"` + Value string `json:"value"` + Target string `json:"target,omitempty"` + IssuerDomainNames []string `json:"issuerDomainNames,omitempty"` + ValidatedAt string `json:"validatedAt"` + CreatedAt time.Time `json:"createdAt"` + Error *acme.Error `json:"error"` // TODO(hs): a bit dangerous; should become db-specific type } func (dbc *dbChallenge) clone() *dbChallenge { @@ -55,14 +56,15 @@ func (db *DB) CreateChallenge(ctx context.Context, ch *acme.Challenge) error { } dbch := &dbChallenge{ - ID: ch.ID, - AccountID: ch.AccountID, - Value: ch.Value, - Status: acme.StatusPending, - Token: ch.Token, - CreatedAt: clock.Now(), - Type: ch.Type, - Target: ch.Target, + ID: ch.ID, + AccountID: ch.AccountID, + Value: ch.Value, + Status: acme.StatusPending, + Token: ch.Token, + CreatedAt: clock.Now(), + Type: ch.Type, + Target: ch.Target, + IssuerDomainNames: ch.IssuerDomainNames, } return db.save(ctx, ch.ID, dbch, nil, "challenge", challengeTable) @@ -78,15 +80,16 @@ func (db *DB) GetChallenge(ctx context.Context, id, authzID string) (*acme.Chall } ch := &acme.Challenge{ - ID: dbch.ID, - AccountID: dbch.AccountID, - Type: dbch.Type, - Value: dbch.Value, - Status: dbch.Status, - Token: dbch.Token, - Error: dbch.Error, - ValidatedAt: dbch.ValidatedAt, - Target: dbch.Target, + ID: dbch.ID, + AccountID: dbch.AccountID, + Type: dbch.Type, + Value: dbch.Value, + Status: dbch.Status, + Token: dbch.Token, + Error: dbch.Error, + ValidatedAt: dbch.ValidatedAt, + Target: dbch.Target, + IssuerDomainNames: dbch.IssuerDomainNames, } return ch, nil } diff --git a/acme/db/nosql/challenge_test.go b/acme/db/nosql/challenge_test.go index 4eb815f58..4ed33dc6f 100644 --- a/acme/db/nosql/challenge_test.go +++ b/acme/db/nosql/challenge_test.go @@ -200,6 +200,45 @@ func TestDB_CreateChallenge(t *testing.T) { _id: idPtr, } }, + "ok/dns-persist-01": func(t *testing.T) test { + var ( + id string + idPtr = &id + ch = &acme.Challenge{ + AccountID: "accountID", + Type: "dns-persist-01", + Status: acme.StatusPending, + Value: "test.ca.smallstep.com", + IssuerDomainNames: []string{"ca.example.com", "ca.example.net"}, + } + ) + + return test{ + ch: ch, + db: &db.MockNoSQLDB{ + MCmpAndSwap: func(bucket, key, old, nu []byte) ([]byte, bool, error) { + *idPtr = string(key) + assert.Equals(t, bucket, challengeTable) + assert.Equals(t, string(key), ch.ID) + assert.Equals(t, old, nil) + + dbc := new(dbChallenge) + assert.FatalError(t, json.Unmarshal(nu, dbc)) + assert.Equals(t, dbc.ID, string(key)) + assert.Equals(t, dbc.AccountID, ch.AccountID) + assert.Equals(t, dbc.Type, ch.Type) + assert.Equals(t, dbc.Status, ch.Status) + assert.Equals(t, dbc.Token, "") + assert.Equals(t, dbc.Value, ch.Value) + assert.Equals(t, dbc.IssuerDomainNames, ch.IssuerDomainNames) + assert.True(t, clock.Now().Add(-time.Minute).Before(dbc.CreatedAt)) + assert.True(t, clock.Now().Add(time.Minute).After(dbc.CreatedAt)) + return nil, true, nil + }, + }, + _id: idPtr, + } + }, } for name, run := range tests { tc := run(t) @@ -280,6 +319,32 @@ func TestDB_GetChallenge(t *testing.T) { dbc: dbc, } }, + "ok/dns-persist-01": func(t *testing.T) test { + dbc := &dbChallenge{ + ID: chID, + AccountID: "accountID", + Type: "dns-persist-01", + Status: acme.StatusPending, + Value: "test.ca.smallstep.com", + IssuerDomainNames: []string{"ca.example.com", "ca.example.net"}, + CreatedAt: clock.Now(), + ValidatedAt: "foobar", + Error: acme.NewErrorISE("The server experienced an internal error"), + } + b, err := json.Marshal(dbc) + assert.FatalError(t, err) + return test{ + db: &db.MockNoSQLDB{ + MGet: func(bucket, key []byte) ([]byte, error) { + assert.Equals(t, bucket, challengeTable) + assert.Equals(t, string(key), chID) + + return b, nil + }, + }, + dbc: dbc, + } + }, } for name, run := range tests { tc := run(t) @@ -307,6 +372,7 @@ func TestDB_GetChallenge(t *testing.T) { assert.Equals(t, ch.Status, tc.dbc.Status) assert.Equals(t, ch.Token, tc.dbc.Token) assert.Equals(t, ch.Value, tc.dbc.Value) + assert.Equals(t, ch.IssuerDomainNames, tc.dbc.IssuerDomainNames) assert.Equals(t, ch.ValidatedAt, tc.dbc.ValidatedAt) assert.Equals(t, ch.Error.Error(), tc.dbc.Error.Error()) } From cc65a906453594fba8b66a980d940f9678d98027 Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 12:59:23 -0500 Subject: [PATCH 4/8] Offer dns-persist-01 challenges in the ACME API Create dns-persist-01 challenges for DNS identifiers, including wildcards, when the challenge is enabled in the provisioner. The challenges include the issuer domain names instead of a token. The ACME directory advertises the accountHashPrefix and issuerDomainNames metadata required to provision the validation records. --- acme/api/handler.go | 21 +++++-- acme/api/handler_test.go | 44 ++++++++++++--- acme/api/order.go | 7 +++ acme/api/order_test.go | 117 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 175 insertions(+), 14 deletions(-) diff --git a/acme/api/handler.go b/acme/api/handler.go index 9ad6ae36d..cd74855a2 100644 --- a/acme/api/handler.go +++ b/acme/api/handler.go @@ -196,6 +196,8 @@ type Meta struct { Website string `json:"website,omitempty"` CaaIdentities []string `json:"caaIdentities,omitempty"` ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"` + AccountHashPrefix string `json:"accountHashPrefix,omitempty"` + IssuerDomainNames []string `json:"issuerDomainNames,omitempty"` } // Directory represents an ACME directory for configuring clients. @@ -235,28 +237,35 @@ func GetDirectory(w http.ResponseWriter, r *http.Request) { NewOrder: linker.GetLink(ctx, acme.NewOrderLinkType), RevokeCert: linker.GetLink(ctx, acme.RevokeCertLinkType), KeyChange: linker.GetLink(ctx, acme.KeyChangeLinkType), - Meta: createMetaObject(acmeProv), + Meta: createMetaObject(ctx, acmeProv), }) } // createMetaObject creates a Meta object if the ACME provisioner // has one or more properties that are written in the ACME directory output. // It returns nil if none of the properties are set. -func createMetaObject(p *provisioner.ACME) *Meta { - if shouldAddMetaObject(p) { - return &Meta{ +func createMetaObject(ctx context.Context, p *provisioner.ACME) *Meta { + if shouldAddMetaObject(ctx, p) { + m := &Meta{ TermsOfService: p.TermsOfService, Website: p.Website, CaaIdentities: p.CaaIdentities, ExternalAccountRequired: p.RequireEAB, } + // The properties used to provision dns-persist-01 validation records + // are only advertised if the challenge is enabled. + if p.IsChallengeEnabled(ctx, provisioner.DNS_PERSIST_01) { + m.AccountHashPrefix = p.AccountHashPrefix + m.IssuerDomainNames = p.IssuerDomainNames + } + return m } return nil } // shouldAddMetaObject returns whether or not the ACME provisioner // has properties configured that must be added to the ACME directory object. -func shouldAddMetaObject(p *provisioner.ACME) bool { +func shouldAddMetaObject(ctx context.Context, p *provisioner.ACME) bool { switch { case p.TermsOfService != "": return true @@ -266,6 +275,8 @@ func shouldAddMetaObject(p *provisioner.ACME) bool { return true case p.RequireEAB: return true + case p.IsChallengeEnabled(ctx, provisioner.DNS_PERSIST_01): + return true default: return false } diff --git a/acme/api/handler_test.go b/acme/api/handler_test.go index bd7bb50e3..09f4d68bc 100644 --- a/acme/api/handler_test.go +++ b/acme/api/handler_test.go @@ -837,27 +837,57 @@ func Test_createMetaObject(t *testing.T) { ExternalAccountRequired: true, }, }, + { + name: "dns-persist-01", + p: &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca.local"}, + AccountHashPrefix: "https://ca.local/acme/acme/account-hash/", + }, + want: &Meta{ + AccountHashPrefix: "https://ca.local/acme/acme/account-hash/", + IssuerDomainNames: []string{"ca.local"}, + }, + }, + { + name: "dns-persist-01-disabled", + p: &provisioner.ACME{ + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DNS_01}, + IssuerDomainNames: []string{"ca.local"}, + AccountHashPrefix: "https://ca.local/acme/acme/account-hash/", + }, + want: nil, + }, { name: "full-meta", p: &provisioner.ACME{ - Type: "ACME", - Name: "acme", - TermsOfService: "https://terms.ca.local", - Website: "https://ca.local", - CaaIdentities: []string{"ca.local", "ca.remote"}, - RequireEAB: true, + Type: "ACME", + Name: "acme", + TermsOfService: "https://terms.ca.local", + Website: "https://ca.local", + CaaIdentities: []string{"ca.local", "ca.remote"}, + RequireEAB: true, + Challenges: []provisioner.ACMEChallenge{provisioner.DNS_01, provisioner.DNS_PERSIST_01}, + IssuerDomainNames: []string{"ca.local"}, + AccountHashPrefix: "https://ca.local/acme/acme/account-hash/", }, want: &Meta{ TermsOfService: "https://terms.ca.local", Website: "https://ca.local", CaaIdentities: []string{"ca.local", "ca.remote"}, ExternalAccountRequired: true, + AccountHashPrefix: "https://ca.local/acme/acme/account-hash/", + IssuerDomainNames: []string{"ca.local"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := createMetaObject(tt.p) + got := createMetaObject(context.Background(), tt.p) if !cmp.Equal(tt.want, got) { t.Errorf("createMetaObject() diff =\n%s", cmp.Diff(tt.want, got)) } diff --git a/acme/api/order.go b/acme/api/order.go index 03c8ab9a1..4e154aa01 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -378,6 +378,12 @@ func newAuthorization(ctx context.Context, az *acme.Authorization) error { Status: acme.StatusPending, Target: target, } + // The dns-persist-01 challenge does not use a token, the validation + // record must contain one of the issuer domain names instead. + if typ == acme.DNSPERSIST01 { + ch.Token = "" + ch.IssuerDomainNames = prov.GetIssuerDomainNames() + } if err := db.CreateChallenge(ctx, ch); err != nil { return acme.WrapErrorISE(err, "error creating challenge") } @@ -506,6 +512,7 @@ func challengeTypes(az *acme.Authorization) []acme.ChallengeType { if !az.Wildcard { chTypes = append(chTypes, []acme.ChallengeType{acme.HTTP01, acme.TLSALPN01}...) } + chTypes = append(chTypes, acme.DNSPERSIST01) case acme.PermanentIdentifier: chTypes = []acme.ChallengeType{acme.DEVICEATTEST01} case acme.WireUser: diff --git a/acme/api/order_test.go b/acme/api/order_test.go index 58bbaa5b1..4089b72a4 100644 --- a/acme/api/order_test.go +++ b/acme/api/order_test.go @@ -588,6 +588,19 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= provisioner.WIREDPOP_01, }, } + dnsPersistProvisioner := &provisioner.ACME{ + Type: "ACME", + Name: "test@acme-provisioner.com", + Challenges: []provisioner.ACMEChallenge{ + provisioner.DNS_01, + provisioner.DNS_PERSIST_01, + }, + IssuerDomainNames: []string{"ca.example.com", "ca.example.net"}, + AccountHashPrefix: "https://ca.example.com/acme/account-hash/", + } + if err := dnsPersistProvisioner.Init(provisioner.Config{Claims: globalProvisionerClaims}); err != nil { + t.Fatal(err) + } type test struct { az *acme.Authorization prov acme.Provisioner @@ -867,6 +880,106 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= az: az, } }, + "ok/dns-persist-01": func(t *testing.T) test { + az := &acme.Authorization{ + AccountID: "accID", + Identifier: acme.Identifier{ + Type: "dns", + Value: "zap.internal", + }, + Status: acme.StatusPending, + ExpiresAt: clock.Now(), + } + count := 0 + var ch1, ch2 **acme.Challenge + return test{ + prov: dnsPersistProvisioner, + db: &acme.MockDB{ + MockCreateChallenge: func(ctx context.Context, ch *acme.Challenge) error { + switch count { + case 0: + ch.ID = "dns" + assert.Equals(t, ch.Type, acme.DNS01) + assert.Equals(t, ch.Token, az.Token) + assert.Equals(t, len(ch.IssuerDomainNames), 0) + ch1 = &ch + case 1: + ch.ID = "dns-persist" + assert.Equals(t, ch.Type, acme.DNSPERSIST01) + assert.Equals(t, ch.Token, "") + assert.Equals(t, ch.IssuerDomainNames, []string{"ca.example.com", "ca.example.net"}) + ch2 = &ch + default: + assert.FatalError(t, errors.New("test logic error")) + return errors.New("force") + } + count++ + assert.Equals(t, ch.AccountID, az.AccountID) + assert.Equals(t, ch.Status, acme.StatusPending) + assert.Equals(t, ch.Value, "zap.internal") + return nil + }, + MockCreateAuthorization: func(ctx context.Context, _az *acme.Authorization) error { + assert.Equals(t, _az.AccountID, az.AccountID) + assert.Equals(t, _az.Token, az.Token) + assert.Equals(t, _az.Status, acme.StatusPending) + assert.Equals(t, _az.Identifier, az.Identifier) + assert.Equals(t, _az.ExpiresAt, az.ExpiresAt) + assert.Equals(t, _az.Challenges, []*acme.Challenge{*ch1, *ch2}) + assert.Equals(t, _az.Wildcard, false) + return nil + }, + }, + az: az, + } + }, + "ok/dns-persist-01-wildcard": func(t *testing.T) test { + az := &acme.Authorization{ + AccountID: "accID", + Identifier: acme.Identifier{ + Type: "dns", + Value: "*.zap.internal", + }, + Status: acme.StatusPending, + ExpiresAt: clock.Now(), + } + count := 0 + var ch1, ch2 **acme.Challenge + return test{ + prov: dnsPersistProvisioner, + db: &acme.MockDB{ + MockCreateChallenge: func(ctx context.Context, ch *acme.Challenge) error { + switch count { + case 0: + ch.ID = "dns" + assert.Equals(t, ch.Type, acme.DNS01) + ch1 = &ch + case 1: + ch.ID = "dns-persist" + assert.Equals(t, ch.Type, acme.DNSPERSIST01) + assert.Equals(t, ch.IssuerDomainNames, []string{"ca.example.com", "ca.example.net"}) + ch2 = &ch + default: + assert.FatalError(t, errors.New("test logic error")) + return errors.New("force") + } + count++ + assert.Equals(t, ch.Value, "zap.internal") + return nil + }, + MockCreateAuthorization: func(ctx context.Context, _az *acme.Authorization) error { + assert.Equals(t, _az.Identifier, acme.Identifier{ + Type: "dns", + Value: "zap.internal", + }) + assert.Equals(t, _az.Challenges, []*acme.Challenge{*ch1, *ch2}) + assert.Equals(t, _az.Wildcard, true) + return nil + }, + }, + az: az, + } + }, "ok/permanent-identifier-disabled": func(t *testing.T) test { az := &acme.Authorization{ AccountID: "accID", @@ -2688,7 +2801,7 @@ func TestHandler_challengeTypes(t *testing.T) { Wildcard: false, }, }, - want: []acme.ChallengeType{acme.DNS01, acme.HTTP01, acme.TLSALPN01}, + want: []acme.ChallengeType{acme.DNS01, acme.HTTP01, acme.TLSALPN01, acme.DNSPERSIST01}, }, { name: "ok/wildcard", @@ -2698,7 +2811,7 @@ func TestHandler_challengeTypes(t *testing.T) { Wildcard: true, }, }, - want: []acme.ChallengeType{acme.DNS01}, + want: []acme.ChallengeType{acme.DNS01, acme.DNSPERSIST01}, }, { name: "ok/ip", From e750bb8a89cc78c4c9b2648af0fb70a4857d89a8 Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 12:59:36 -0500 Subject: [PATCH 5/8] Add dns-persist-01 to the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29108dda9..fbc220f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Support for ML-DSA (FIPS 204) keys, available when built with Go 1.27 or newer. JOSE-based flows (JWK, JWS and JWT tokens) do not support ML-DSA yet. +- Support for the ACME `dns-persist-01` challenge defined in + [draft-ietf-acme-dns-persist-02](https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist-02). + It is disabled by default. To enable it, add `dns-persist-01` to the + `challenges` of an ACME provisioner and set its new `issuerDomainNames` and + `accountHashPrefix` properties. ### Changed From 0dd0f97ca8cfe86e3ca04210bfc51873ab65278b Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 13:04:52 -0500 Subject: [PATCH 6/8] Return detailed dns-persist-01 errors to ACME clients Validation records can be provisioned by a party other than the ACME client, so include the reason a record was rejected in the error detail to make them easier to fix. --- acme/challenge.go | 28 ++++++++++++++-------------- acme/challenge_test.go | 41 +++++++++++++++++++++-------------------- acme/order.go | 2 +- acme/order_test.go | 2 +- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 7b4af8a3a..8c81fcaa2 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -439,7 +439,7 @@ func dnsPersist01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.J return WrapErrorISE(err, "error loading account") } if !acc.IsValid() { - return storeError(ctx, db, ch, true, NewError(ErrorUnauthorizedType, + return storeError(ctx, db, ch, true, NewDetailedError(ErrorUnauthorizedType, "account %s is not valid", acc.ID)) } @@ -448,7 +448,7 @@ func dnsPersist01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.J // current request. accountURL := acc.GetLocation() if accountURL == "" { - return storeError(ctx, db, ch, true, NewError(ErrorUnauthorizedType, + return storeError(ctx, db, ch, true, NewDetailedError(ErrorUnauthorizedType, "account %s does not have the stored location required by %s challenges", acc.ID, ch.Type)) } @@ -456,7 +456,7 @@ func dnsPersist01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.J // at the base domain. domain, err := x509util.SanitizeName(strings.TrimSuffix(strings.TrimPrefix(ch.Value, "*."), ".")) if err != nil || len(domain) > 253 { - return storeError(ctx, db, ch, true, NewError(ErrorMalformedType, + return storeError(ctx, db, ch, true, NewDetailedError(ErrorMalformedType, "invalid DNS name %s", ch.Value)) } @@ -507,7 +507,7 @@ func dnsPersist01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.J } if record == nil { if recordErr == nil { - recordErr = NewError(ErrorUnauthorizedType, + recordErr = NewDetailedError(ErrorUnauthorizedType, "no TXT record found at %s for issuer domain names %s", host, strings.Join(ch.IssuerDomainNames, ", ")) } return storeError(ctx, db, ch, false, recordErr) @@ -566,7 +566,7 @@ func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHash tag, value, ok := strings.Cut(param, "=") tag, value = strings.Trim(tag, " \t"), strings.Trim(value, " \t") if !ok || !isDNSPersist01Tag(tag) || !isDNSPersist01Value(value) { - return nil, true, NewError(ErrorMalformedType, + return nil, true, NewDetailedError(ErrorMalformedType, "TXT record %q has an invalid parameter %q", txt, param) } @@ -574,7 +574,7 @@ func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHash // persistUntil is never ignored because of its case. tag = strings.ToLower(tag) if seen[tag] { - return nil, true, NewError(ErrorMalformedType, + return nil, true, NewDetailedError(ErrorMalformedType, "TXT record %q has a duplicate parameter %q", txt, tag) } seen[tag] = true @@ -590,7 +590,7 @@ func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHash case "persistuntil": ts, err := strconv.ParseInt(value, 10, 64) if err != nil || strings.Trim(value, "0123456789") != "" { - return nil, true, NewError(ErrorMalformedType, + return nil, true, NewDetailedError(ErrorMalformedType, "TXT record %q has an invalid persistUntil value %q", txt, value) } rec.persistUntil = time.Unix(ts, 0).UTC() @@ -599,7 +599,7 @@ func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHash } if rec.accountURI == "" { - return nil, true, NewError(ErrorMalformedType, + return nil, true, NewDetailedError(ErrorMalformedType, "TXT record %q is missing the accounturi parameter", txt) } return rec, true, nil @@ -610,16 +610,16 @@ func parseDNSPersist01Record(txt string, issuerDomainNames []string, accountHash func validateDNSPersist01AccountURI(value, accountHashPrefix string) *Error { rest, ok := strings.CutPrefix(value, accountHashPrefix) if !ok { - return NewError(ErrorMalformedType, + return NewDetailedError(ErrorMalformedType, "accounturi %q does not start with %q", value, accountHashPrefix) } alg, hash, _ := strings.Cut(rest, "/") if alg != dnsPersist01HashAlgorithm { - return NewError(ErrorMalformedType, + return NewDetailedError(ErrorMalformedType, "accounturi %q does not use the %s hash algorithm", value, dnsPersist01HashAlgorithm) } if b, err := base64.RawURLEncoding.Strict().DecodeString(hash); err != nil || len(b) != sha256.Size { - return NewError(ErrorMalformedType, + return NewDetailedError(ErrorMalformedType, "accounturi %q does not contain a valid %s hash value", value, dnsPersist01HashAlgorithm) } return nil @@ -630,13 +630,13 @@ func validateDNSPersist01AccountURI(value, accountHashPrefix string) *Error { func verifyDNSPersist01Record(rec *dnsPersist01Record, now time.Time, expected []string, wildcard bool, domain string) *Error { switch { case !slices.Contains(expected, rec.accountURI): - return NewError(ErrorUnauthorizedType, + return NewDetailedError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", expected[0], rec.accountURI) case !rec.persistUntil.IsZero() && now.After(rec.persistUntil): - return NewError(ErrorUnauthorizedType, + return NewDetailedError(ErrorUnauthorizedType, "TXT record expired at %s", rec.persistUntil.Format(time.RFC3339)) case wildcard && !rec.wildcard: - return NewError(ErrorUnauthorizedType, + return NewDetailedError(ErrorUnauthorizedType, "TXT record does not have the policy=wildcard parameter required for *.%s", domain) default: return nil diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 51d4edf3c..0a1202cc4 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -5683,20 +5683,20 @@ func TestDNSPersist01Validate(t *testing.T) { value: "example.com", account: &Account{ID: "123", Status: StatusDeactivated, LocationPrefix: "https://ca.example/acct/"}, wantStatus: StatusInvalid, - wantChErr: NewError(ErrorUnauthorizedType, "account 123 is not valid"), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "account 123 is not valid"), }, { name: "fail/account-without-location", value: "example.com", account: &Account{ID: "123", Status: StatusValid}, wantStatus: StatusInvalid, - wantChErr: NewError(ErrorUnauthorizedType, "account 123 does not have the stored location required by dns-persist-01 challenges"), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "account 123 does not have the stored location required by dns-persist-01 challenges"), }, { name: "fail/invalid-domain", value: "exa mple.com", wantStatus: StatusInvalid, - wantChErr: NewError(ErrorMalformedType, "invalid DNS name exa mple.com"), + wantChErr: NewDetailedError(ErrorMalformedType, "invalid DNS name exa mple.com"), }, { name: "fail/lookup-error", @@ -5710,105 +5710,105 @@ func TestDNSPersist01Validate(t *testing.T) { value: "example.com", txtRecords: []string{"ca1.example; accounturi=" + accountURI}, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "no TXT record found at _validation-persist.example.com for issuer domain names authority.example, ca.example.net"), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "no TXT record found at _validation-persist.example.com for issuer domain names authority.example, ca.example.net"), }, { name: "fail/missing-account-uri", value: "example.com", txtRecords: []string{"authority.example; policy=wildcard"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; policy=wildcard" is missing the accounturi parameter`), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; policy=wildcard" is missing the accounturi parameter`), }, { name: "fail/missing-parameters", value: "example.com", txtRecords: []string{"authority.example;"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example;" is missing the accounturi parameter`), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example;" is missing the accounturi parameter`), }, { name: "fail/duplicate-parameter", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=1; persistuntil=2"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=1; persistuntil=2" has a duplicate parameter "persistuntil"`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=1; persistuntil=2" has a duplicate parameter "persistuntil"`, accountURI), }, { name: "fail/parameter-without-value", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy" has an invalid parameter " policy"`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy" has an invalid parameter " policy"`, accountURI), }, { name: "fail/empty-parameter", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + ";"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s;" has an invalid parameter ""`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s;" has an invalid parameter ""`, accountURI), }, { name: "fail/invalid-tag", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; -policy=wildcard"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; -policy=wildcard" has an invalid parameter " -policy=wildcard"`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; -policy=wildcard" has an invalid parameter " -policy=wildcard"`, accountURI), }, { name: "fail/invalid-value", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy=wild card"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy=wild card" has an invalid parameter " policy=wild card"`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; policy=wild card" has an invalid parameter " policy=wild card"`, accountURI), }, { name: "fail/account-uri-prefix", value: "example.com", txtRecords: []string{"authority.example; accounturi=https://ca.example/acct/123"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/acct/123" does not start with "https://ca.example/account-hash/"`), + wantChErr: NewDetailedError(ErrorMalformedType, `accounturi "https://ca.example/acct/123" does not start with "https://ca.example/account-hash/"`), }, { name: "fail/account-uri-algorithm", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + prefix + "sha-512/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-512/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw" does not use the sha-256 hash algorithm`), + wantChErr: NewDetailedError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-512/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk-kw" does not use the sha-256 hash algorithm`), }, { name: "fail/account-uri-hash", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + prefix + "sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk" does not contain a valid sha-256 hash value`), + wantChErr: NewDetailedError(ErrorMalformedType, `accounturi "https://ca.example/account-hash/sha-256/5SQm7n6tPh2-PlLbCKGnViTXX5z19SCN4cPGQHSk" does not contain a valid sha-256 hash value`), }, { name: "fail/account-uri-mismatch", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + otherAccountURI}, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), }, { name: "fail/account-uri-other-domain", value: "www.example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI}, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", wwwAccountURI, accountURI), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", wwwAccountURI, accountURI), }, { name: "fail/persist-until-invalid", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=+1767225600"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=+1767225600" has an invalid persistUntil value "+1767225600"`, accountURI), + wantChErr: NewDetailedError(ErrorMalformedType, `TXT record "authority.example; accounturi=%s; persistUntil=+1767225600" has an invalid persistUntil value "+1767225600"`, accountURI), }, { name: "fail/persist-until-expired", value: "example.com", txtRecords: []string{"authority.example; accounturi=" + accountURI + "; persistUntil=1721952000"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "TXT record expired at 2024-07-26T00:00:00Z"), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "TXT record expired at 2024-07-26T00:00:00Z"), }, { name: "fail/wildcard-without-policy", @@ -5816,7 +5816,7 @@ func TestDNSPersist01Validate(t *testing.T) { wildcard: true, txtRecords: []string{"authority.example; accounturi=" + accountURI + "; policy=other"}, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "TXT record does not have the policy=wildcard parameter required for *.example.com"), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "TXT record does not have the policy=wildcard parameter required for *.example.com"), }, { name: "fail/first-error", @@ -5826,7 +5826,7 @@ func TestDNSPersist01Validate(t *testing.T) { "ca.example.net; policy=wildcard", }, wantStatus: StatusPending, - wantChErr: NewError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), + wantChErr: NewDetailedError(ErrorUnauthorizedType, "accounturi does not match; expected %s, but got %s", accountURI, otherAccountURI), }, { name: "fail/update-authorization-error", @@ -5920,6 +5920,7 @@ func TestDNSPersist01Validate(t *testing.T) { if tt.wantChErr != nil { if assert.NotNil(t, ch.Error) { assert.Equal(t, tt.wantChErr.Type, ch.Error.Type) + assert.Equal(t, tt.wantChErr.Detail, ch.Error.Detail) assert.Equal(t, tt.wantChErr.Status, ch.Error.Status) assert.Equal(t, tt.wantChErr.Err.Error(), ch.Error.Err.Error()) } diff --git a/acme/order.go b/acme/order.go index 31764296a..776b32baf 100644 --- a/acme/order.go +++ b/acme/order.go @@ -174,7 +174,7 @@ func (o *Order) checkPersistentAuthorizations(ctx context.Context, db DB) error } for _, ch := range az.Challenges { if ch.Type == DNSPERSIST01 && ch.Status == StatusValid && now.After(az.ExpiresAt) { - return NewError(ErrorUnauthorizedType, "order %s authorization %s has expired", o.ID, az.ID) + return NewDetailedError(ErrorUnauthorizedType, "order %s authorization %s has expired", o.ID, az.ID) } } } diff --git a/acme/order_test.go b/acme/order_test.go index 75e6e6fde..c044dff7a 100644 --- a/acme/order_test.go +++ b/acme/order_test.go @@ -462,7 +462,7 @@ func TestOrder_Finalize(t *testing.T) { } }, }, - err: NewError(ErrorUnauthorizedType, "order oID authorization b has expired"), + err: NewDetailedError(ErrorUnauthorizedType, "order oID authorization b has expired"), } }, "fail/non-matching-permanent-identifier-common-name": func(t *testing.T) test { From ee22513581f1f5ae4c89e87b9d2710bdcf48f460 Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 15:49:50 -0500 Subject: [PATCH 7/8] Bump linkedca for the dns-persist-01 challenge type Pin the smallstep/linkedca#170 branch until it is released. The newer linkedca requires go 1.26.7. --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 6541d937c..589b741a7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/smallstep/certificates -go 1.26.0 +go 1.26.7 require ( cloud.google.com/go/longrunning v1.3.0 @@ -32,7 +32,7 @@ require ( github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 github.com/smallstep/cli-utils v0.12.2 github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 - github.com/smallstep/linkedca v0.26.0 + github.com/smallstep/linkedca v0.26.1-0.20261015204622-92df9588f2e2 github.com/smallstep/nosql v0.8.0 github.com/smallstep/pkcs7 v0.2.3 github.com/smallstep/scep v0.0.0-20250318231241-a25cabb69492 diff --git a/go.sum b/go.sum index 103cd1e7d..fa1a62fed 100644 --- a/go.sum +++ b/go.sum @@ -360,8 +360,8 @@ github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9do github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 h1:KcK2guFXrE5sX/nvF1b+atHP6DjRB1gCv3ppyTXB2Zk= github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= -github.com/smallstep/linkedca v0.26.0 h1:NsxTVo3zI3KwOFFiVodeHtuGgKq0b5kOUXVLjjNTvXY= -github.com/smallstep/linkedca v0.26.0/go.mod h1:Z8c7EgVrSHNhshIhRnUGfKfE796+TwF2eyErhqANmJQ= +github.com/smallstep/linkedca v0.26.1-0.20261015204622-92df9588f2e2 h1:3l8II+J/ODpnllwLGCGLeG31FBc1aSA0cpj2VAX/irY= +github.com/smallstep/linkedca v0.26.1-0.20261015204622-92df9588f2e2/go.mod h1:G1h7UYvH4wDF19X6uqiaNfxu8Z5UMDGfOJL8pJCgDns= github.com/smallstep/nosql v0.8.0 h1:FBTCUfKPmWYbrozW+RBKu+fnvbn+zr5rVli/XB4Jp4A= github.com/smallstep/nosql v0.8.0/go.mod h1:5dUpNotHLHhOUapP0PLBVVfp3tG1DFC31VRccg+Cqwo= github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= From ec97ec478545543bf5b8cc477a287c0dc942729f Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Thu, 15 Oct 2026 15:49:50 -0500 Subject: [PATCH 8/8] Convert dns-persist-01 options to and from linkedca Map the dns-persist-01 challenge type and the issuerDomainNames and accountHashPrefix properties in both directions. Without this, remote provisioner management and `step ca provisioner update` silently drop them, and a provisioner configured with only dns-persist-01 falls back to the default challenges. --- authority/provisioners.go | 8 ++++++++ authority/provisioners_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/authority/provisioners.go b/authority/provisioners.go index 3413ab309..164f4cffc 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -905,6 +905,8 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface, Challenges: challengesToCertificates(cfg.Challenges), AttestationFormats: attestationFormatsToCertificates(cfg.AttestationFormats), AttestationRoots: provisionerPEMToCertificates(cfg.AttestationRoots), + IssuerDomainNames: cfg.IssuerDomainNames, + AccountHashPrefix: cfg.AccountHashPrefix, Claims: claims, Options: options, }, nil @@ -1184,6 +1186,8 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro Challenges: challengesToLinkedca(p.Challenges), AttestationFormats: attestationFormatsToLinkedca(p.AttestationFormats), AttestationRoots: provisionerPEMToLinkedca(p.AttestationRoots), + IssuerDomainNames: p.IssuerDomainNames, + AccountHashPrefix: p.AccountHashPrefix, }, }, }, @@ -1331,6 +1335,8 @@ func challengesToCertificates(challenges []linkedca.ACMEProvisioner_ChallengeTyp ret = append(ret, provisioner.TLS_ALPN_01) case linkedca.ACMEProvisioner_DEVICE_ATTEST_01: ret = append(ret, provisioner.DEVICE_ATTEST_01) + case linkedca.ACMEProvisioner_DNS_PERSIST_01: + ret = append(ret, provisioner.DNS_PERSIST_01) } } return ret @@ -1350,6 +1356,8 @@ func challengesToLinkedca(challenges []provisioner.ACMEChallenge) []linkedca.ACM ret = append(ret, linkedca.ACMEProvisioner_TLS_ALPN_01) case provisioner.DEVICE_ATTEST_01: ret = append(ret, linkedca.ACMEProvisioner_DEVICE_ATTEST_01) + case provisioner.DNS_PERSIST_01: + ret = append(ret, linkedca.ACMEProvisioner_DNS_PERSIST_01) } } return ret diff --git a/authority/provisioners_test.go b/authority/provisioners_test.go index b53bbb4e8..3bc6d7d1c 100644 --- a/authority/provisioners_test.go +++ b/authority/provisioners_test.go @@ -340,6 +340,37 @@ func TestProvisionerWebhookToLinkedca(t *testing.T) { } } +func TestProvisionerToLinkedca_acmeDNSPersist01(t *testing.T) { + p := &provisioner.ACME{ + ID: "acme-id", + Type: "ACME", + Name: "acme", + Challenges: []provisioner.ACMEChallenge{provisioner.DNS_01, provisioner.DNS_PERSIST_01}, + CaaIdentities: []string{"ca.example.com"}, + IssuerDomainNames: []string{"ca.example.com"}, + AccountHashPrefix: "https://ca.example.com/acme/acme/account-hash/", + } + + lp, err := ProvisionerToLinkedca(p) + require.NoError(t, err) + lacme := lp.GetDetails().GetACME() + require.NotNil(t, lacme) + require.Equal(t, []linkedca.ACMEProvisioner_ChallengeType{ + linkedca.ACMEProvisioner_DNS_01, linkedca.ACMEProvisioner_DNS_PERSIST_01, + }, lacme.Challenges) + require.Equal(t, []string{"ca.example.com"}, lacme.IssuerDomainNames) + require.Equal(t, "https://ca.example.com/acme/acme/account-hash/", lacme.AccountHashPrefix) + + cp, err := ProvisionerToCertificates(lp) + require.NoError(t, err) + require.IsType(t, &provisioner.ACME{}, cp) + got := cp.(*provisioner.ACME) + require.Equal(t, p.Challenges, got.Challenges) + require.Equal(t, p.CaaIdentities, got.CaaIdentities) + require.Equal(t, p.IssuerDomainNames, got.IssuerDomainNames) + require.Equal(t, p.AccountHashPrefix, got.AccountHashPrefix) +} + func Test_wrapRAProvisioner(t *testing.T) { type args struct { p provisioner.Interface