Skip to content

Commit ddffee8

Browse files
authored
Retry without 'replaces' field when appropriate (#50)
* test: add replace test * Retry without replaces-field when appropriate Fixes caddyserver/certmagic#361
1 parent d34f7bb commit ddffee8

5 files changed

Lines changed: 228 additions & 12 deletions

File tree

acme/problem.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const (
133133
ProblemTypeNamespace = "urn:ietf:params:acme:error:"
134134

135135
ProblemTypeAccountDoesNotExist = ProblemTypeNamespace + "accountDoesNotExist"
136+
ProblemTypeAlreadyReplaced = ProblemTypeNamespace + "alreadyReplaced"
136137
ProblemTypeAlreadyRevoked = ProblemTypeNamespace + "alreadyRevoked"
137138
ProblemTypeBadCSR = ProblemTypeNamespace + "badCSR"
138139
ProblemTypeBadNonce = ProblemTypeNamespace + "badNonce"

client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@ func (c *Client) ObtainCertificate(ctx context.Context, params OrderParameters)
133133
// create order for a new certificate
134134
order, err = c.Client.NewOrder(ctx, params.Account, order)
135135
if err != nil {
136+
var problem acme.Problem
137+
if errors.As(err, &problem) {
138+
if problem.Type == acme.ProblemTypeAlreadyReplaced && order.Replaces != "" {
139+
// retry without replace
140+
// https://github.com/caddyserver/certmagic/issues/361
141+
order.Replaces = ""
142+
continue
143+
}
144+
}
136145
return nil, fmt.Errorf("creating new order: %w", err)
137146
}
138147

go.mod

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
module github.com/mholt/acmez/v3
22

3-
go 1.21.0
4-
5-
toolchain go1.23.1
3+
go 1.24.0
64

75
require (
8-
golang.org/x/crypto v0.27.0
9-
golang.org/x/net v0.29.0
6+
code.pfad.fr/check v1.1.0
7+
github.com/letsencrypt/pebble/v2 v2.9.1-0.20260116233549-fcc2230629f9
8+
golang.org/x/crypto v0.38.0
9+
golang.org/x/net v0.40.0
1010
)
1111

12-
require golang.org/x/text v0.18.0 // indirect
12+
require (
13+
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
14+
github.com/letsencrypt/challtestsrv v1.3.2 // indirect
15+
github.com/miekg/dns v1.1.62 // indirect
16+
golang.org/x/mod v0.24.0 // indirect
17+
golang.org/x/sync v0.14.0 // indirect
18+
golang.org/x/sys v0.33.0 // indirect
19+
golang.org/x/text v0.25.0 // indirect
20+
golang.org/x/tools v0.33.0 // indirect
21+
)

go.sum

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
1-
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
2-
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
3-
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
4-
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
5-
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
6-
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
1+
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
2+
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
3+
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
4+
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
5+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
6+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7+
github.com/letsencrypt/challtestsrv v1.3.2 h1:pIDLBCLXR3B1DLmOmkkqg29qVa7DDozBnsOpL9PxmAY=
8+
github.com/letsencrypt/challtestsrv v1.3.2/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc=
9+
github.com/letsencrypt/pebble/v2 v2.9.0 h1:xlgphcCRBNUfBi8Gh3ZKbB4mujCr96JWUGvfk2jkJLA=
10+
github.com/letsencrypt/pebble/v2 v2.9.0/go.mod h1:+ShT/VIcv1/Z2APBfYhLrtIwIl+fU2AUDFk49pXf63Q=
11+
github.com/letsencrypt/pebble/v2 v2.9.1-0.20260116233549-fcc2230629f9 h1:L8Ck0oH2tzZfe0lyHfF2Nnufw4oJKbgzze+KQVEDu/E=
12+
github.com/letsencrypt/pebble/v2 v2.9.1-0.20260116233549-fcc2230629f9/go.mod h1:+ShT/VIcv1/Z2APBfYhLrtIwIl+fU2AUDFk49pXf63Q=
13+
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
14+
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
15+
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
16+
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
17+
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
18+
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
19+
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
20+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
21+
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
22+
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
23+
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
24+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
25+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
26+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
27+
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
28+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
29+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
30+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
31+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
32+
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
33+
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
34+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
35+
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
36+
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=

pebble_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2026 oliverpool
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package acmez_test
16+
17+
import (
18+
"context"
19+
"crypto/ecdsa"
20+
"crypto/elliptic"
21+
"crypto/rand"
22+
"crypto/x509"
23+
"encoding/pem"
24+
"io"
25+
"log"
26+
"log/slog"
27+
"net"
28+
"net/http"
29+
"net/http/httptest"
30+
"strconv"
31+
"sync/atomic"
32+
"testing"
33+
34+
"code.pfad.fr/check"
35+
"github.com/letsencrypt/pebble/v2/ca"
36+
"github.com/letsencrypt/pebble/v2/db"
37+
"github.com/letsencrypt/pebble/v2/va"
38+
"github.com/letsencrypt/pebble/v2/wfe"
39+
"github.com/mholt/acmez/v3"
40+
"github.com/mholt/acmez/v3/acme"
41+
)
42+
43+
func newHttpSolver(t *testing.T) (port int, solver acmez.Solver) {
44+
hsolver := &httpSolver{}
45+
s := httptest.NewServer(hsolver)
46+
t.Cleanup(s.Close)
47+
48+
hostPort := s.Listener.Addr().String()
49+
_, sport, err := net.SplitHostPort(hostPort)
50+
check.Equal(t, nil, err)
51+
port, err = strconv.Atoi(sport)
52+
check.Equal(t, nil, err)
53+
54+
return port, hsolver
55+
}
56+
57+
type httpSolver struct {
58+
challenge atomic.Pointer[acme.Challenge]
59+
}
60+
61+
// ServeHTTP implements http.Handler.
62+
func (h *httpSolver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
63+
chal := h.challenge.Load()
64+
if chal == nil || r.URL.Path != chal.HTTP01ResourcePath() || r.Method != http.MethodGet {
65+
http.NotFound(w, r)
66+
return
67+
}
68+
w.Header().Add("Content-Type", "text/plain")
69+
w.Write([]byte(chal.KeyAuthorization))
70+
}
71+
72+
// Present implements Solver.
73+
func (h *httpSolver) Present(ctx context.Context, chal acme.Challenge) error {
74+
h.challenge.Store(&chal)
75+
return nil
76+
}
77+
78+
// CleanUp implements Solver.
79+
func (h *httpSolver) CleanUp(context.Context, acme.Challenge) error {
80+
h.challenge.Store(nil)
81+
return nil
82+
}
83+
84+
func TestAlreadyReplaced(t *testing.T) {
85+
solverPort, solver := newHttpSolver(t)
86+
client := newAcmeClient(t, solverPort, 0)
87+
c := acmez.Client{
88+
Client: client,
89+
ChallengeSolvers: map[string]acmez.Solver{
90+
"http-01": solver,
91+
},
92+
}
93+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
94+
check.Equal(t, nil, err)
95+
account, err := c.NewAccount(t.Context(), acme.Account{
96+
TermsOfServiceAgreed: true,
97+
PrivateKey: privateKey,
98+
})
99+
check.Equal(t, nil, err)
100+
101+
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
102+
check.Equal(t, nil, err)
103+
sans := []string{"127.0.0.1"}
104+
105+
// initial cert
106+
initialCerts, err := c.ObtainCertificateForSANs(t.Context(), account, certKey, sans)
107+
check.Equal(t, nil, err)
108+
block, _ := pem.Decode(initialCerts[0].ChainPEM)
109+
toReplace, err := x509.ParseCertificate(block.Bytes)
110+
check.Equal(t, nil, err)
111+
112+
{
113+
// initial relacement
114+
csr, err := acmez.NewCSR(certKey, sans)
115+
check.Equal(t, nil, err)
116+
params, err := acmez.OrderParametersFromCSR(account, csr)
117+
check.Equal(t, nil, err)
118+
params.Replaces = toReplace
119+
_, err = c.ObtainCertificate(t.Context(), params)
120+
check.Equal(t, nil, err)
121+
}
122+
123+
{
124+
// second replacement (of the same certificate)
125+
csr, err := acmez.NewCSR(certKey, sans)
126+
check.Equal(t, nil, err)
127+
params, err := acmez.OrderParametersFromCSR(account, csr)
128+
check.Equal(t, nil, err)
129+
params.Replaces = toReplace
130+
_, err = c.ObtainCertificate(t.Context(), params)
131+
check.Equal(t, nil, err)
132+
}
133+
}
134+
135+
func newAcmeClient(t *testing.T, httpPort, tlsPort int) *acme.Client {
136+
s := newPebbleServer(t, httpPort, tlsPort)
137+
return &acme.Client{
138+
Directory: s.URL + wfe.DirectoryPath,
139+
HTTPClient: s.Client(),
140+
// Logger: slog.New(slog.NewTextHandler(t.Output(), nil)),
141+
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
142+
}
143+
}
144+
145+
func newPebbleServer(t *testing.T, httpPort, tlsPort int) *httptest.Server {
146+
t.Setenv("PEBBLE_VA_NOSLEEP", "1") // https://github.com/letsencrypt/pebble/blob/23ab0beb482ac4760d7f3064141128a74d0d9430/va/va.go#L51
147+
// logger := log.New(t.Output(), "test", log.Llongfile)
148+
logger := log.New(io.Discard, "test", log.Llongfile)
149+
db := db.NewMemoryStore()
150+
keyAlg := "rsa"
151+
alternateRoots := 0
152+
chainLength := 1
153+
profiles := map[string]ca.Profile{
154+
"default": {
155+
Description: "The default profile",
156+
ValidityPeriod: 0, // Will be overridden by the CA's default
157+
},
158+
}
159+
ca := ca.New(logger, db, "", keyAlg, alternateRoots, chainLength, profiles)
160+
va := va.New(logger, httpPort, tlsPort, true, "", db)
161+
wfeImpl := wfe.New(logger, db, va, ca, true, false, 0, 0)
162+
163+
s := httptest.NewUnstartedServer(wfeImpl.Handler())
164+
s.StartTLS()
165+
t.Cleanup(s.Close)
166+
return s
167+
}

0 commit comments

Comments
 (0)