Skip to content

Commit 9a9101a

Browse files
committed
acme: drop landmark-relative alternate URL (fixes lego issuance hang)
The /cert/{id}/alternate URL served the §9 "not yet available" stub (503 + Retry-After: 3600) until a covering landmark existed — the normal case immediately after issuance, since landmarks lag. ACME clients follow the rel="alternate" Link on the finalize/cert responses right away; lego's go-retryablehttp transport honours Retry-After on a 503 and sleeps its full value per retry (RetryMax 5 × 3600s) against a context.Background request with no client timeout, so issuance hung for hours. It was intermittent only when a covering landmark happened to already exist. Remove the alternate URL, its 503 stub, the rel="alternate" Link headers, and the alternate-only acme.Config fields (Landmarks, SubtreeProof, LogNumber). The standalone cert is fully usable on its own; the landmark-relative form is now produced out-of-band from the log with `cactus-cli cert landmark-relative`. Drop the alternate-URL tests (RP fast-path verification stays covered by integration TestCLICertLandmarkRelative) and update the README/MTC/ test-instance docs.
1 parent ba96ac7 commit 9a9101a

14 files changed

Lines changed: 59 additions & 786 deletions

MTC.md

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -321,14 +321,15 @@ In cactus:
321321
- `cert.BuildLandmarkRelativeCert` re-uses the existing standalone
322322
cert's TBS (so subject, validity, SPKI all match) and replaces
323323
only the signature value with a landmark MTCProof.
324-
- `acme.Server`'s alternate URL `(/cert/<id>/alternate)` returns a
325-
`503 + Retry-After` until a covering landmark exists, then
326-
returns the real landmark-relative cert.
324+
- `cmd/cactus-cli cert landmark-relative` derives the landmark-relative
325+
cert from a standalone cert plus the log's tiles, once a covering
326+
landmark exists. The ACME API itself only serves standalone certs.
327327

328328
The relying-party side is exercised by
329-
`integration/TestRelyingPartyFastPath`: the test simulates an RP
330-
that has only `/landmarks` and the tile-served subtree hashes, then
331-
verifies a landmark-relative cert without consulting any cosigner key.
329+
`integration/TestCLICertLandmarkRelative`: it converts a standalone cert
330+
to landmark-relative form via `cactus-cli` and verifies it against the
331+
live log (signature-free, the inclusion proof reconstructs the log's
332+
subtree hash) without consulting any cosigner key.
332333

333334
## §7.2: Verifying a certificate
334335

@@ -376,23 +377,27 @@ ACME is what the authenticating party (the cert holder) uses to
376377
`Accept: application/pem-certificate-chain-with-properties`. The
377378
server then includes a `CertificatePropertyList` alongside the
378379
PEM (cactus uses an adjacent `MTC PROPERTIES` PEM block; the
379-
trust-anchor-ids draft hasn't pinned the wire format yet). The
380-
property list carries a single:
381-
- `trust_anchor_id` — the **CA ID** for standalone certs (§8.1), or
382-
the specific landmark's ID `CA-ID.1.logNumber.L` for
383-
landmark-relative certs (§8.2).
384-
385-
A relying party advertises a **landmark group**
386-
`CA-ID.2.logNumber.L` (§8.2.1) in its `trust_anchors`, signalling
387-
support for the CA's standalone certs and all active landmarks at
388-
once.
389-
3. **Alternate URL.** Per RFC 8555 §7.4.2, the `Link: <...>;
390-
rel="alternate"` header on the `/finalize` response points at
391-
the landmark-relative variant of the cert. Until a landmark
392-
covers the entry, that URL returns `503 + Retry-After`.
393-
394-
Cactus implements all of the above in `acme/` plus the
395-
property-list builder in `cert/properties.go`.
380+
trust-anchor-ids draft hasn't pinned the wire format yet). For the
381+
standalone certs the ACME API issues, the property list carries a
382+
single `trust_anchor_id` — the **CA ID** (§8.1).
383+
384+
(The landmark-relative form, whose `trust_anchor_id` is the specific
385+
landmark's ID `CA-ID.1.logNumber.L` per §8.2, is produced out-of-band
386+
with `cactus-cli cert landmark-relative`, not over ACME. A relying
387+
party advertises a **landmark group** `CA-ID.2.logNumber.L` (§8.2.1)
388+
in its `trust_anchors` to accept the CA's standalone certs and all
389+
active landmarks at once.)
390+
391+
Cactus implements the above in `acme/` plus the property-list builder
392+
in `cert/properties.go`.
393+
394+
> **Note.** Earlier revisions also exposed the landmark-relative cert
395+
> over ACME as a `rel="alternate"` URL (RFC 8555 §7.4.2) that returned
396+
> `503 + Retry-After` until a covering landmark existed. That was
397+
> removed: ACME clients (e.g. lego, via `go-retryablehttp`) follow the
398+
> alternate link immediately and honour the `Retry-After` on the 503,
399+
> stalling issuance for the full interval. The landmark-relative form is
400+
> now obtained from the log with `cactus-cli` instead.
396401
397402
## Cactus implementation map
398403

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ operate it, and where to look in the code.
4747
`signatureAlgorithm` is `id-alg-mtcProof` and whose `signatureValue`
4848
body is an `MTCProof` blob carrying an inclusion proof + cosigner
4949
signatures.
50-
- Issues **landmark-relative certificates** (§6.3) alongside the
51-
standalone ones. Allocates landmarks per §6.3.2, serves a
52-
`/landmarks` endpoint per §6.3.1, and switches the §9 alternate URL
53-
from a `503` stub to a real signature-less cert once a covering
54-
landmark exists.
50+
- Supports **landmark-relative certificates** (§6.3). Allocates
51+
landmarks per §6.3.2 and serves a `/landmarks` endpoint per §6.3.1.
52+
The signature-free landmark-relative form is derived from the log
53+
out-of-band with `cactus-cli cert landmark-relative` (not over the
54+
ACME API).
5555
- Acts as a **CA cosigner** using ML-DSA-44 (requires a Go 1.27+ build).
5656
- Optionally **runs as a cosigning mirror** for an external upstream
5757
log ([tlog-mirror], [tlog-cosignature]). In mirror mode cactus

acme/alternate_test.go

Lines changed: 0 additions & 101 deletions
This file was deleted.

acme/handler.go

Lines changed: 6 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ import (
2121

2222
"github.com/letsencrypt/cactus/ca"
2323
"github.com/letsencrypt/cactus/cert"
24-
"github.com/letsencrypt/cactus/landmark"
2524
cactusmetrics "github.com/letsencrypt/cactus/metrics"
26-
"github.com/letsencrypt/cactus/tlogx"
2725
)
2826

2927
// ChallengeMode controls how challenges are validated.
@@ -52,31 +50,15 @@ type Config struct {
5250
// "invalid" on failure. Optional.
5351
OrdersByStatus cactusmetrics.CounterVec
5452

55-
// Landmarks, if non-nil, enables the alternate-URL
56-
// switchover: GET /cert/{id}/alternate returns a real
57-
// landmark-relative cert once a covering landmark exists.
58-
// Otherwise the alternate URL keeps the §9-permitted 503 stub.
59-
Landmarks *landmark.Sequence
60-
61-
// SubtreeProof, if set, is used to compute inclusion proofs for
62-
// landmark-relative cert assembly. Must be set whenever
63-
// Landmarks is. Typically `(*log.Log).SubtreeProof`.
64-
SubtreeProof func(start, end, index uint64) (tlogx.Hash, []tlogx.Hash, error)
65-
66-
// LogID is the issuance log's trust anchor ID (§5.2). Required
67-
// only when Landmarks is set (and for the standalone-cert
68-
// `trust_anchor_id` property emitted in
69-
// application/pem-certificate-chain-with-properties).
53+
// LogID is the issuance log's trust anchor ID (§5.2). Used as the
54+
// fallback for the standalone-cert `trust_anchor_id` property
55+
// emitted in application/pem-certificate-chain-with-properties when
56+
// CAID is unset.
7057
LogID cert.TrustAnchorID
7158

72-
// CAID is the CA's CA ID (§5.1). Required whenever Landmarks is set;
73-
// landmark trust anchor IDs are derived from it and LogNumber
74-
// (CA-ID.1.logNumber.L, §6.3.1).
59+
// CAID is the CA's CA ID (§5.1). Emitted as the standalone cert's
60+
// `trust_anchor_id` property (draft-04 §8.1).
7561
CAID cert.TrustAnchorID
76-
77-
// LogNumber is the issuance log's number (§5.2). Required whenever
78-
// Landmarks is set.
79-
LogNumber uint16
8062
}
8163

8264
// Server is the ACME HTTP server.
@@ -130,7 +112,6 @@ func (s *Server) Handler() http.Handler {
130112
mux.HandleFunc("POST /order/{id}", s.handleOrder)
131113
mux.HandleFunc("POST /finalize/{id}", s.handleFinalize)
132114
mux.HandleFunc("POST /cert/{id}", s.handleCert)
133-
mux.HandleFunc("POST /cert/{id}/alternate", s.handleCertAlternate)
134115
return mux
135116
}
136117

@@ -975,7 +956,6 @@ func (s *Server) handleFinalize(w http.ResponseWriter, r *http.Request) {
975956
w.Header().Set("Location", loc)
976957
s.issueNonce(w)
977958
w.Header().Set("Content-Type", "application/json")
978-
w.Header().Set("Link", `<`+s.urlFor("/cert/"+certID+"/alternate")+`>;rel="alternate"`)
979959
_ = json.NewEncoder(w).Encode(s.orderJSON(o))
980960
}
981961

@@ -1005,7 +985,6 @@ func (s *Server) handleCert(w http.ResponseWriter, r *http.Request) {
1005985
}
1006986
s.issueNonce(w)
1007987
accept := r.Header.Get("Accept")
1008-
w.Header().Set("Link", `<`+s.urlFor("/cert/"+id+"/alternate")+`>;rel="alternate"`)
1009988
if strings.Contains(accept, "application/pem-certificate-chain-with-properties") {
1010989
w.Header().Set("Content-Type", "application/pem-certificate-chain-with-properties")
1011990
// Standalone cert: the trust_anchor_id property naming the CA
@@ -1037,126 +1016,6 @@ func (s *Server) handleCert(w http.ResponseWriter, r *http.Request) {
10371016
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
10381017
}
10391018

1040-
// handleCertAlternate returns the landmark-relative cert for
1041-
// the given cert id, falling back to 503 + Retry-After (the
1042-
// §9-permitted stub) when a covering landmark hasn't been allocated yet
1043-
// or when landmark mode is disabled.
1044-
func (s *Server) handleCertAlternate(w http.ResponseWriter, r *http.Request) {
1045-
id := r.PathValue("id")
1046-
parsed, acct, err := s.readJWS(r, true)
1047-
if err != nil {
1048-
s.writeJWSError(w, err)
1049-
return
1050-
}
1051-
if !EmptyPayloadOK(parsed.Payload) {
1052-
s.problem(w, http.StatusBadRequest, "urn:ietf:params:acme:error:malformed",
1053-
"cert download must be POST-as-GET (empty payload)")
1054-
return
1055-
}
1056-
s.state.mu.Lock()
1057-
standalone, ok := s.certs[id]
1058-
s.state.mu.Unlock()
1059-
if !ok {
1060-
s.problem(w, http.StatusNotFound, "urn:ietf:params:acme:error:malformed", "no certificate")
1061-
return
1062-
}
1063-
if !s.certBelongsToAccount(id, acct.ID) {
1064-
s.problem(w, http.StatusUnauthorized, "urn:ietf:params:acme:error:unauthorized",
1065-
"certificate does not belong to this account")
1066-
return
1067-
}
1068-
s.issueNonce(w)
1069-
1070-
// Pull the serial out of the standalone cert and split off the log
1071-
// index: draft-04 §6.1, serial = (log_number << 48) | index.
1072-
tbs, _, _, err := cert.SplitCertificate(standalone)
1073-
if err != nil {
1074-
http.Error(w, "split cert: "+err.Error(), http.StatusInternalServerError)
1075-
return
1076-
}
1077-
_, serial, err := cert.RebuildLogEntryFromTBS(tbs, nil)
1078-
if err != nil {
1079-
http.Error(w, "decode TBS: "+err.Error(), http.StatusInternalServerError)
1080-
return
1081-
}
1082-
_, index, err := cert.SplitSerial(serial)
1083-
if err != nil {
1084-
http.Error(w, "decode serial: "+err.Error(), http.StatusInternalServerError)
1085-
return
1086-
}
1087-
1088-
if s.cfg.Landmarks == nil || s.cfg.SubtreeProof == nil {
1089-
s.serveAltStub(w)
1090-
return
1091-
}
1092-
1093-
lm, ok := s.cfg.Landmarks.ContainingIndex(index)
1094-
if !ok {
1095-
s.serveAltStub(w)
1096-
return
1097-
}
1098-
1099-
// Pick the §4.5 covering subtree of [prev_treeSize, lm.TreeSize)
1100-
// that contains the entry index.
1101-
subtrees := s.cfg.Landmarks.LandmarkSubtrees(lm)
1102-
var chosen tlogx.Subtree
1103-
for _, st := range subtrees {
1104-
if index >= st.Start && index < st.End {
1105-
chosen = st
1106-
break
1107-
}
1108-
}
1109-
if chosen.End == 0 {
1110-
// Inconsistent state: ContainingIndex said yes but no covering
1111-
// subtree contains the index. Treat as not-yet-available.
1112-
s.serveAltStub(w)
1113-
return
1114-
}
1115-
1116-
subtreeHash, proof, err := s.cfg.SubtreeProof(chosen.Start, chosen.End, index)
1117-
if err != nil {
1118-
http.Error(w, "subtree proof: "+err.Error(), http.StatusInternalServerError)
1119-
return
1120-
}
1121-
1122-
mtcSubtree := cert.MTCSubtree{
1123-
LogID: s.cfg.LogID,
1124-
Start: chosen.Start, End: chosen.End,
1125-
Hash: subtreeHash,
1126-
}
1127-
der, err := cert.BuildLandmarkRelativeCert(standalone, s.cfg.LogID, mtcSubtree, proof)
1128-
if err != nil {
1129-
http.Error(w, "build landmark cert: "+err.Error(), http.StatusInternalServerError)
1130-
return
1131-
}
1132-
1133-
accept := r.Header.Get("Accept")
1134-
if strings.Contains(accept, "application/pem-certificate-chain-with-properties") {
1135-
w.Header().Set("Content-Type", "application/pem-certificate-chain-with-properties")
1136-
// draft-04 §8.2: a landmark-relative certificate's trust anchor
1137-
// ID is the individual landmark ID (CA-ID.1.logNumber.L).
1138-
// Relying parties advertise a landmark group (§8.2.1) instead.
1139-
props := []cert.CertificateProperty{
1140-
{Type: cert.PropertyTrustAnchorID, TrustAnchorID: lm.TrustAnchorID(s.cfg.CAID, s.cfg.LogNumber)},
1141-
}
1142-
pl, err := cert.BuildPropertyList(props)
1143-
if err != nil {
1144-
http.Error(w, "build properties: "+err.Error(), http.StatusInternalServerError)
1145-
return
1146-
}
1147-
w.Write(cert.EncodePEMWithProperties(der, pl))
1148-
return
1149-
}
1150-
w.Header().Set("Content-Type", "application/pem-certificate-chain")
1151-
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: der})
1152-
}
1153-
1154-
// serveAltStub is the §9-permitted "not yet available" response.
1155-
func (s *Server) serveAltStub(w http.ResponseWriter) {
1156-
w.Header().Set("Retry-After", "3600")
1157-
http.Error(w, "landmark-relative certificate not yet available", http.StatusServiceUnavailable)
1158-
}
1159-
11601019
// certBelongsToAccount returns true if the cert with the given id is
11611020
// the certificate produced for an order whose AccountID matches.
11621021
func (s *Server) certBelongsToAccount(certID, accountID string) bool {

acme/handler_test.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,6 @@ func TestEndToEndIssuance(t *testing.T) {
265265
t.Errorf("suspiciously short cert: %d bytes", len(block.Bytes))
266266
}
267267

268-
// 8. Alternate URL returns 503 + Retry-After.
269-
altURL := strings.TrimSuffix(ord2.Certificate, "/cert") + "/cert/" + lastSegment(ord2.Certificate) + "/alternate"
270-
resp, _ = postAsGet(t, base, altURL, acctKey, kid, "")
271-
if resp.StatusCode != http.StatusServiceUnavailable {
272-
t.Logf("alternate URL: status=%d (expected 503)", resp.StatusCode)
273-
}
274-
275268
_ = orderURL
276269
_ = ord
277270
}

0 commit comments

Comments
 (0)