Skip to content

Commit 314dc76

Browse files
committed
acme: serve landmark-relative cert via rel="enhancement" URL
Re-introduce the landmark-relative cert over ACME using the new "enhancement" link relation (ietf-plants-wg/merkle-tree-certs#257), instead of the rel="alternate" form that was removed for stalling clients. The standalone /cert response advertises Link: <.../cert/{id}/landmark-relative/{number}>;rel="enhancement" An enhancement is an optional, non-blocking substitute: the URL returns HTTP 202 (Accepted) until the covering landmark is allocated, then the signature-free landmark-relative cert. Clients retry the 202 honouring Retry-After but never block deploying the standalone cert — so a not-yet-available landmark can't stall issuance the way the old alternate + 503 + Retry-After did (lego's go-retryablehttp treated that 503's Retry-After as a multi-hour blocking sleep). The URL pins the number of the single landmark the entry is relative to (ContainingIndex), making it an immutable resource: 202 until that landmark is allocated, the same cert bytes forever after, and a permanent 404 for any other number. Retry-After is derived from the remaining time in the §6.3.2 landmark interval, so it reflects when the cert is actually expected. Adds landmark.Sequence.NextNumber and TimeUntilNextLandmark; re-wires the acme.Config landmark fields; new tests acme/enhancement_test.go and integration/landmark_enhancement_test.go; updates README/MTC/test-instance.
1 parent e5f8c16 commit 314dc76

11 files changed

Lines changed: 663 additions & 38 deletions

File tree

MTC.md

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,19 @@ In cactus:
322322
- `cert.BuildLandmarkRelativeCert` re-uses the existing standalone
323323
cert's TBS (so subject, validity, SPKI all match) and replaces
324324
only the signature value with a landmark MTCProof.
325-
- `cmd/cactus-cli cert landmark-relative` derives the landmark-relative
326-
cert from a standalone cert plus the log's tiles, once a covering
327-
landmark exists. The ACME API itself only serves standalone certs.
325+
- `acme.Server` advertises the landmark-relative cert as a
326+
`rel="enhancement"` URL on the standalone cert response, served by
327+
`handleCertLandmarkRelative` at `/cert/{id}/landmark-relative/{number}`
328+
(see "Doing it over ACME" below).
329+
- `cmd/cactus-cli cert landmark-relative` derives the same cert from a
330+
standalone cert plus the log's tiles, for use outside ACME.
328331

329332
The relying-party side is exercised by
330-
`integration/TestCLICertLandmarkRelative`: it converts a standalone cert
331-
to landmark-relative form via `cactus-cli` and verifies it against the
332-
live log (signature-free, the inclusion proof reconstructs the log's
333-
subtree hash) without consulting any cosigner key.
333+
`integration/TestEnhancementURLSwitchover` (over ACME) and
334+
`integration/TestCLICertLandmarkRelative` (via cactus-cli): each obtains a
335+
landmark-relative cert and verifies it against the live log (signature-free,
336+
the inclusion proof reconstructs the log's subtree hash) without consulting
337+
any cosigner key.
334338

335339
## §7.2: Verifying a certificate
336340

@@ -378,28 +382,33 @@ ACME is what the authenticating party (the cert holder) uses to
378382
`Accept: application/pem-certificate-chain-with-properties`. The
379383
server then includes a `CertificatePropertyList` alongside the
380384
PEM (cactus uses an adjacent `MTC PROPERTIES` PEM block; the
381-
trust-anchor-ids draft hasn't pinned the wire format yet). For the
382-
standalone certs the ACME API issues, the property list carries a
383-
single `trust_anchor_id` — the **CA ID** (§8.1).
384-
385-
(The landmark-relative form, whose `trust_anchor_id` is the specific
386-
landmark's ID `CA-ID.1.logNumber.L` per §8.2, is produced out-of-band
387-
with `cactus-cli cert landmark-relative`, not over ACME. A relying
385+
trust-anchor-ids draft hasn't pinned the wire format yet). The
386+
property list carries a single `trust_anchor_id` — the **CA ID**
387+
(§8.1) for a standalone cert, or the specific landmark's ID
388+
`CA-ID.1.logNumber.L` (§8.2) for a landmark-relative cert. A relying
388389
party advertises a **landmark group** `CA-ID.2.logNumber.L` (§8.2.1)
389390
in its `trust_anchors` to accept the CA's standalone certs and all
390-
active landmarks at once.)
391+
active landmarks at once.
392+
3. **Enhancement URL.** The standalone cert response carries a
393+
`Link: <…>; rel="enhancement"` header (trust-anchor-ids) pointing at
394+
the landmark-relative variant, at
395+
`/cert/{id}/landmark-relative/{number}`. The number pins the single
396+
landmark the entry is relative to (`ContainingIndex`), so the URL is
397+
an immutable resource: it returns `HTTP 202 (Accepted)` + `Retry-After`
398+
until that landmark is allocated, then the cert. An *enhancement* is
399+
an optional, non-blocking substitute — a client retries the 202 later
400+
but must never let it hold up deploying the standalone cert.
401+
402+
This replaced an earlier `rel="alternate"` + `503 + Retry-After`
403+
design. `alternate` is load-bearing: clients (e.g. lego, via
404+
`go-retryablehttp`) fetch it eagerly during issuance and honour the
405+
`Retry-After` on the 503, so a not-yet-available landmark stalled
406+
issuance for the full interval. `enhancement` + 202 is non-blocking,
407+
and pinning the landmark number keeps the URL stable.
391408

392409
Cactus implements the above in `acme/` plus the property-list builder
393410
in `cert/properties.go`.
394411

395-
> **Note.** Earlier revisions also exposed the landmark-relative cert
396-
> over ACME as a `rel="alternate"` URL (RFC 8555 §7.4.2) that returned
397-
> `503 + Retry-After` until a covering landmark existed. That was
398-
> removed: ACME clients (e.g. lego, via `go-retryablehttp`) follow the
399-
> alternate link immediately and honour the `Retry-After` on the 503,
400-
> stalling issuance for the full interval. The landmark-relative form is
401-
> now obtained from the log with `cactus-cli` instead.
402-
403412
## Cactus implementation map
404413

405414
If you're reading the code, this is the order I'd recommend:

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ operate it, and where to look in the code.
4949
signatures.
5050
- Supports **landmark-relative certificates** (§6.3). Allocates
5151
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).
52+
The standalone cert advertises the signature-free landmark-relative
53+
form as a `rel="enhancement"` URL (an optional, non-blocking substitute
54+
that returns HTTP 202 until a covering landmark exists). The same form
55+
is also derivable from the log with `cactus-cli cert landmark-relative`.
5556
- Acts as a **CA cosigner** using ML-DSA-44 (requires a Go 1.27+ build).
5657
- Optionally **runs as a cosigning mirror** for an external upstream
5758
log ([tlog-mirror], [tlog-cosignature]). In mirror mode cactus

acme/enhancement_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package acme
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rand"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
10+
"encoding/base64"
11+
"encoding/json"
12+
"net/http"
13+
"net/http/httptest"
14+
"strconv"
15+
"strings"
16+
"testing"
17+
"time"
18+
19+
"github.com/go-jose/go-jose/v4"
20+
21+
"github.com/letsencrypt/cactus/ca"
22+
"github.com/letsencrypt/cactus/cert"
23+
"github.com/letsencrypt/cactus/landmark"
24+
cactuslog "github.com/letsencrypt/cactus/log"
25+
"github.com/letsencrypt/cactus/signer"
26+
"github.com/letsencrypt/cactus/storage"
27+
)
28+
29+
// TestEnhancementURLPending pins the draft §9 "enhancement" behaviour
30+
// before a covering landmark exists: the standalone cert advertises the
31+
// landmark-relative cert via rel="enhancement" at a URL pinned to the
32+
// landmark number it will be relative to, and that URL returns HTTP 202
33+
// (Accepted) + Retry-After — non-blocking, never the rel="alternate" +
34+
// 503 that stalled clients.
35+
func TestEnhancementURLPending(t *testing.T) {
36+
dir := t.TempDir()
37+
fs, _ := storage.New(dir)
38+
seed := make([]byte, signer.SeedSize)
39+
for i := range seed {
40+
seed[i] = 0xDE
41+
}
42+
sgn, _ := signer.FromSeed(signer.AlgMLDSA44, seed)
43+
logID := cert.TrustAnchorID("32473.1")
44+
l, _ := cactuslog.New(context.Background(), cactuslog.Config{
45+
LogID: logID, CosignerID: logID,
46+
Signer: sgn, FS: fs, FlushPeriod: 25 * time.Millisecond,
47+
})
48+
defer l.Stop()
49+
issuer, _ := ca.New(l, "32473.1", 1)
50+
51+
// TimeBetweenLandmarks is long: no landmark is allocated during the
52+
// test, so the enhancement URL stays in the pending (202) state.
53+
seq, err := landmark.New(landmark.Config{
54+
CAID: logID, LogNumber: 1,
55+
TimeBetweenLandmarks: time.Hour, MaxCertLifetime: time.Hour,
56+
}, fs, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC))
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
srv, _ := New(Config{
61+
Issuer: issuer, ChallengeMode: ChallengeAutoPass,
62+
Landmarks: seq, SubtreeProof: l.SubtreeProof,
63+
LogID: logID, CAID: logID, LogNumber: 1,
64+
})
65+
hsrv := httptest.NewServer(srv.Handler())
66+
defer hsrv.Close()
67+
srv.SetExternalURL(hsrv.URL)
68+
69+
// Issue one cert end-to-end so we have a real cert URL.
70+
acctKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
71+
acctJWK := &jose.JSONWebKey{Key: acctKey.Public(), Algorithm: "ES256"}
72+
nonce := nonceFor(t, hsrv.URL)
73+
jws := jwsSign(t, acctKey, acctJWK, "", nonce, hsrv.URL+"/new-account",
74+
mustMarshal(NewAccountReq{TermsOfServiceAgreed: true}))
75+
resp, _ := post(t, hsrv.URL, "/new-account", jws)
76+
kid := resp.Header.Get("Location")
77+
nonce = resp.Header.Get("Replay-Nonce")
78+
jws = jwsSign(t, acctKey, nil, kid, nonce, hsrv.URL+"/new-order",
79+
mustMarshal(NewOrderReq{Identifiers: []Identifier{{Type: "dns", Value: "enh.test"}}}))
80+
resp, body := post(t, hsrv.URL, "/new-order", jws)
81+
var ord OrderResp
82+
json.Unmarshal(body, &ord)
83+
csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
84+
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
85+
Subject: pkix.Name{CommonName: "enh.test"}, DNSNames: []string{"enh.test"},
86+
}, csrKey)
87+
nonce = resp.Header.Get("Replay-Nonce")
88+
jws = jwsSign(t, acctKey, nil, kid, nonce, ord.Finalize,
89+
mustMarshal(FinalizeReq{CSR: base64.RawURLEncoding.EncodeToString(csr)}))
90+
resp, body = post(t, hsrv.URL, strings.TrimPrefix(ord.Finalize, hsrv.URL), jws)
91+
var ord2 OrderResp
92+
json.Unmarshal(body, &ord2)
93+
if ord2.Certificate == "" {
94+
t.Fatal("missing cert URL")
95+
}
96+
97+
// The standalone cert response advertises the landmark-relative cert
98+
// as an enhancement, pinned to a landmark number.
99+
certResp, _ := postAsGet(t, hsrv.URL, ord2.Certificate, acctKey, kid, "")
100+
link := certResp.Header.Get("Link")
101+
if !strings.Contains(link, `rel="enhancement"`) {
102+
t.Errorf("cert Link missing rel=enhancement: %q", link)
103+
}
104+
enh := linkURL(link)
105+
if !strings.Contains(enh, "/landmark-relative/") {
106+
t.Fatalf("enhancement URL not a landmark-relative URL: %q", enh)
107+
}
108+
// Index 0's covering landmark is the next one to be allocated (1).
109+
if !strings.HasSuffix(enh, "/landmark-relative/1") {
110+
t.Errorf("enhancement URL = %q, want it pinned to landmark 1", enh)
111+
}
112+
113+
// Before the landmark is allocated, the enhancement URL returns 202 +
114+
// Retry-After.
115+
r2, _ := postAsGet(t, hsrv.URL, enh, acctKey, kid, "")
116+
if r2.StatusCode != http.StatusAccepted {
117+
t.Errorf("status = %d, want 202", r2.StatusCode)
118+
}
119+
ra := r2.Header.Get("Retry-After")
120+
if ra == "" {
121+
t.Fatal("missing Retry-After header")
122+
}
123+
if _, err := strconv.Atoi(ra); err != nil {
124+
if _, err := http.ParseTime(ra); err != nil {
125+
t.Errorf("Retry-After %q is neither integer nor HTTP-date", ra)
126+
}
127+
}
128+
129+
// A landmark number this cert is not relative to is a permanent 404.
130+
wrong := strings.TrimSuffix(enh, "/1") + "/2"
131+
r3, _ := postAsGet(t, hsrv.URL, wrong, acctKey, kid, "")
132+
if r3.StatusCode != http.StatusNotFound {
133+
t.Errorf("wrong-landmark status = %d, want 404", r3.StatusCode)
134+
}
135+
}
136+
137+
// linkURL returns the URL inside a `<url>;rel="..."` Link header value.
138+
func linkURL(link string) string {
139+
start := strings.Index(link, "<")
140+
end := strings.Index(link, ">")
141+
if start < 0 || end < 0 || end < start {
142+
return ""
143+
}
144+
return link[start+1 : end]
145+
}

0 commit comments

Comments
 (0)