Skip to content

Commit e23181c

Browse files
authored
fix(vantage): retire earlier certificates when a revoked name is re-added (#134)
Agent authorization checked only the client certificate's CommonName against the registry. Revoke deleted the row and Register re-inserted it without creating any credential, so after `vantage revoke X` and `vantage add X` -- the documented way to retire a credential -- every certificate ever issued for X (CA-signed, ten-year validity) authenticated again. The hub now records the serial of every certificate IssueClientCert mints (vantage_certs, cascading from vantages) and requireAgent authorizes a certificate only if its CN is registered and its serial was issued for that registration. Revoke drops the recorded serials, so a re-added name accepts only certificates issued after the re-add. Regenerate keeps its semantics: earlier certificates for a live name stay valid. IssueClientCert now requires a registered name (ErrNotRegistered) and records the serial before returning the cert. Upgrade: vantages registered before this change have no recorded serials. legacy_certs is added with DEFAULT true (applied to existing rows) and the default is then switched to false, all in the schema's single implicit transaction, so pre-existing vantages keep accepting any hub-CA certificate with their name until revoked, while vantages registered later -- and later startups re-running the schema -- are never flagged. README, docs/federation.md, ROADMAP and CHANGELOG updated so the documented retirement procedure is accurate and the upgrade behavior is described.
1 parent 4022c86 commit e23181c

13 files changed

Lines changed: 524 additions & 63 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ All notable changes to **Heliograph** are recorded here. The format follows
1515
versions: the CVE fixes still can't regress, and newer revisions install without breaking the build.
1616

1717
### Security
18+
- **Revoking and re-adding a vantage now retires its earlier certificates.** Agent authorization
19+
checked only the client certificate's CommonName against the registry, so after
20+
`smoked vantage revoke <name>` followed by `vantage add <name>` — the documented way to retire a
21+
credential — every certificate ever issued for that name (CA-signed, valid for ten years)
22+
authenticated again. The hub now records the serial of every certificate it issues and accepts a
23+
certificate only if its serial was issued for the vantage's current registration; revoking drops
24+
the recorded serials, so a re-added name accepts only certificates issued after the re-add.
25+
Regenerate (the dashboard's **Regenerate**, or `vantage add` for a registered name) is unchanged:
26+
earlier certificates keep working alongside the new one. **Upgrade:** vantages registered before
27+
this release have no recorded serials, so on the first start they become *legacy* registrations
28+
that keep accepting any hub-CA certificate bearing their name — deployed agents keep reporting
29+
with no action — until they are revoked. Revoke and re-add such a vantage once (and redeploy its
30+
new bundle) to bring it under serial checking. Certificates minted by an older `smoked` binary
31+
against an upgraded database are not recorded and are refused for vantages registered after the
32+
upgrade. See *Certificate serial tracking* in `docs/federation.md`.
1833
- **`golang.org/x/text` bumped from v0.39.0 to v0.42.0** for [GO-2026-6629](https://pkg.go.dev/vuln/GO-2026-6629)
1934
(a panic in `x/text/secure/precis`, fixed in v0.41.0). `govulncheck` flagged it through `pgx`'s
2035
SCRAM password handling. That path runs the operator-supplied database password through a

‎README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,12 @@ vantage**: type a name and it downloads a ready-to-run `<name>-vantage.tar.gz`
271271
to print the rendered `agent.yaml` to stdout, or pass `-json` for the raw PEMs). The panel also
272272
**list**s vantages and lets you **regenerate** one — issuing and downloading a fresh certificate
273273
bundle for an existing name, which does *not* invalidate the certificate already deployed there
274-
(there's no revocation list; both stay valid until the vantage is revoked). Revocation is by
275-
removal: `smoked vantage revoke <name>` deletes it from the registry, and any certificate bearing
276-
that name is rejected on its very next request even though the certificate itself remains
277-
cryptographically valid. To fully retire a credential, revoke the vantage and re-add it.
274+
(both stay valid until the vantage is revoked). Revocation is by removal: `smoked vantage revoke
275+
<name>` deletes it from the registry, and every certificate issued for that name is rejected on
276+
its very next request even though the certificate itself remains cryptographically valid. The hub
277+
records the serial of each certificate it issues and accepts only those issued for a vantage's
278+
current registration, so to fully retire a credential, revoke the vantage and re-add it: the
279+
re-add's fresh bundle works, and no certificate issued before the revoke is accepted again.
278280

279281
**Bundled Caddy** (automatic Let's Encrypt, dashboard only) — opt-in via the `federation` compose
280282
profile:
@@ -529,8 +531,8 @@ internal/
529531
mcp/ MCP server (stdio) — diagnosis tools + a stage/review/apply config-write flow
530532
over the same HTTP API, for AI-assisted operation (+ tests)
531533
federation/ per-vantage assignment builder + measurement fingerprint
532-
vantage/ per-vantage registry + self-bootstrapped CA (mTLS client-cert auth; revoke by
533-
removing the name)
534+
vantage/ per-vantage registry + self-bootstrapped CA (mTLS client-cert auth by name +
535+
issued serial; revoke by removing the name)
534536
agentwire/ shared hub<->agent wire types
535537
agent/ smoke-agent buffer + on-disk spool + hub client
536538
importer/

‎ROADMAP.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ password-gated admin API, both of which mint the client certificate. Federation
5252
- ✅ Per-round vantage dimension in the store (the hub probes as `local`)
5353
- ✅ `vantages:` config (inherited down the tree) + a pure per-vantage assignment builder + a
5454
content-version hash for the `304` check
55-
- ✅ Vantage registry + a self-bootstrapped CA (mTLS client-cert auth by CommonName;
56-
revoke-by-removal — deleting a vantage rejects its certificate on the next request even though
57-
the certificate itself remains valid) + `smoked vantage add/ls/revoke` CLI + a password-gated
55+
- ✅ Vantage registry + a self-bootstrapped CA (mTLS client-cert auth by CommonName + issued
56+
serial; revoke-by-removal — deleting a vantage rejects its certificates on the next request even
57+
though they remain valid, and re-adding the name accepts only newly issued ones) + `smoked vantage
58+
add/ls/revoke` CLI + a password-gated
5859
`/api/admin/vantages` API with a session login
5960
- ✅ Agent-facing endpoints: `GET /agent/v1/assignment` (304-aware, carries effective probe-level
6061
config) + `POST /agent/v1/results` (mutual-TLS client-certificate auth, validated + idempotent

‎docs/federation.md‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ name and it downloads a ready-to-run `<name>-vantage.tar.gz` — `agent.yaml`
109109
unpack it, and `docker compose up -d` — no key, ever. The panel also **list**s
110110
vantages (name, created, last-seen, target count), **regenerate**s one (issues
111111
and downloads a *fresh* certificate bundle for an existing name — the
112-
previously issued certificate keeps working, since there's no revocation list;
113-
to actually retire a credential, **revoke** the vantage and re-**add** it), and
114-
**revoke**s one (removes it from the registry — see Security model below).
112+
previously issued certificate keeps working too; to retire every earlier
113+
certificate, **revoke** the vantage and re-**add** it), and **revoke**s one
114+
(removes it from the registry — see Security model below).
115115
It's reached through the proxy (behind the dashboard's Basic Auth, then its
116116
own admin password) or on `http://localhost:8087/` on the hub.
117117

@@ -149,11 +149,19 @@ revoked vantage "nyc" # this CN is rejected on its very next request
149149
```
150150

151151
Revocation is by **removal from the registry**, not certificate expiry or a
152-
CRL: `requireAgent` looks up the presented certificate's CommonName against the
153-
active vantage list on every request, so a revoked vantage is locked out
154-
immediately even though its certificate itself remains cryptographically
155-
valid until it expires. Re-running `vantage add <name>` for a revoked name
156-
re-registers it and issues it a fresh certificate.
152+
CRL. The hub records the serial number of every certificate it issues, and on
153+
every request `requireAgent` checks that the presented certificate's CommonName
154+
is a registered vantage *and* that its serial was issued for that registration.
155+
Revoking deletes the vantage together with its recorded serials, so it is
156+
locked out immediately even though its certificates remain cryptographically
157+
valid until they expire. Re-running `vantage add <name>` for a revoked name
158+
re-registers it and issues a fresh certificate — and only that certificate is
159+
accepted; every certificate issued before the revoke stays rejected. In short:
160+
161+
- **Regenerate** (dashboard, or `vantage add` for a registered name) adds a
162+
certificate; the ones issued earlier keep working.
163+
- **Revoke, then re-add** retires every earlier certificate for the name; only
164+
the new bundle works. This is how to retire a leaked or replaced credential.
157165

158166
## Step 3 — Run the agent at the vantage
159167

@@ -245,12 +253,15 @@ spool_dir: /var/lib/smoke-agent/spool
245253
(`RequireAndVerifyClientCert`) before any request reaches a handler; a
246254
request presenting no certificate, or one not signed by the hub's CA, never
247255
gets that far. There is no bearer token to leak, forward, or accidentally log.
248-
- **Authorization is the certificate's CommonName**, checked against the active
249-
(registered, not revoked) vantage list on *every* request — so revoking a
250-
vantage takes effect on its very next request even though the certificate
251-
itself remains cryptographically valid. A CN that isn't a currently active
252-
vantage → `403`; no client certificate at all → the TLS handshake itself
253-
fails before an HTTP status is ever produced.
256+
- **Authorization is the certificate's CommonName and serial number**, checked
257+
on *every* request: the CN must be an active (registered, not revoked)
258+
vantage, and the serial must be one the hub issued for that registration. So
259+
revoking a vantage takes effect on its very next request even though the
260+
certificate itself remains cryptographically valid, and re-adding the name
261+
later does not revive it. A CN that isn't a currently active vantage, or a
262+
certificate not issued for its current registration → `403`; no client
263+
certificate at all → the TLS handshake itself fails before an HTTP status is
264+
ever produced.
254265
- **The hub is authoritative** for each target's probe/host; the agent only sends
255266
raw round-trip times for its assigned targets. Unassigned or malformed results
256267
are dropped and counted.
@@ -286,6 +297,19 @@ retention window** — the raw rows behind them are already gone, so the long-ra
286297
logs a warning when this runs. If that history matters, snapshot the database
287298
before upgrading. New deployments are unaffected.
288299

300+
### Certificate serial tracking
301+
302+
Hubs at v2.2.0 and earlier did not record the serials of the certificates they
303+
issued. On the first start after upgrading, every vantage already in the
304+
registry is marked as a **legacy** registration: it keeps accepting any
305+
certificate signed by the hub's CA that bears its name, so its deployed agent
306+
keeps reporting with no action needed. Vantages added after the upgrade accept
307+
only certificates the hub recorded. A legacy vantage stays that way until it is
308+
revoked; to bring one under serial checking, revoke and re-add it and redeploy
309+
the new bundle — which also retires every certificate issued for it before.
310+
Mint certificates with the upgraded `smoked`: one minted by an older binary is
311+
not recorded, so a vantage registered after the upgrade rejects it.
312+
289313
### Agent fingerprint migration
290314

291315
Each agent round now carries a **measurement fingerprint** so the hub can reject a
@@ -345,8 +369,10 @@ a path reuse can pause data.
345369
vantage) to get a bundle matching the hub's current CA.
346370
- **Agent gets `403 unknown or revoked vantage`** — the certificate is valid
347371
and trusted, but its CommonName isn't a currently registered vantage (never
348-
registered, or `vantage revoke`d). `vantage add <name>` (re-)registers it and
349-
issues a fresh certificate; update the agent with the new bundle.
372+
registered, or `vantage revoke`d), or the certificate was issued before the
373+
vantage was last revoked (an old bundle still deployed after a revoke and
374+
re-add). `vantage add <name>` (re-)registers it if needed and issues a fresh
375+
certificate; update the agent with the new bundle.
350376
- **Vantage never appears / `LAST-SEEN never`** — the agent can't reach the hub
351377
on its mTLS port (DNS/firewall/`-agent-addr` not published), or no target lists
352378
that vantage. Check the agent log and that `vantages:` includes the name;

‎internal/api/admin_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"crypto/tls"
99
"encoding/json"
1010
"io"
11+
"math/big"
1112
"net/http"
1213
"net/http/httptest"
1314
"strings"
@@ -37,7 +38,7 @@ func (f *fakeKeys) Revoke(_ context.Context, name string) (bool, error) {
3738
f.revoked = append(f.revoked, name)
3839
return name == "nyc", nil
3940
}
40-
func (f *fakeKeys) IsActive(_ context.Context, name string) (bool, error) {
41+
func (f *fakeKeys) IsActive(_ context.Context, name string, _ *big.Int) (bool, error) {
4142
return name == "nyc", nil // mirrors List(), which only knows about "nyc"
4243
}
4344

‎internal/api/agentauth.go‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ func vantageFrom(r *http.Request) string {
1818
return v
1919
}
2020

21-
// requireAgent authorizes a federation agent by the CommonName of its mTLS client certificate.
22-
// It does not perform the TLS handshake itself: the mTLS listener (wired separately, a later
23-
// task) already requires a CA-signed client cert before a request ever reaches a handler here —
24-
// this layer only authorizes the identity that cert presented, the same shape as requireAdmin
25-
// authorizing an admin session. A CN must belong to a currently active (registered, not
26-
// revoked) vantage; on success it is stamped onto the request context via vantageCtxKey exactly
27-
// as the old Bearer-key auth did, so vantageFrom/agentAssignment/agentResults need no changes.
21+
// requireAgent authorizes a federation agent by its mTLS client certificate. It does not perform
22+
// the TLS handshake itself: the mTLS listener (AgentTLSConfig) already requires a CA-signed
23+
// client cert before a request ever reaches a handler here — this layer only authorizes the
24+
// identity that cert presented, the same shape as requireAdmin authorizing an admin session. The
25+
// CN must belong to a currently active (registered, not revoked) vantage AND the certificate's
26+
// serial must be one issued for that registration (Vantages.IsActive), so a certificate minted
27+
// before a revoke stays rejected after the same name is re-added. On success the CN is stamped
28+
// onto the request context via vantageCtxKey, so vantageFrom/agentAssignment/agentResults need
29+
// no changes.
2830
//
2931
// SAFE ONLY behind a listener whose tls.Config sets ClientAuth: tls.RequireAndVerifyClientCert
3032
// (the config AgentTLSConfig, in agentmtls.go, produces). A weaker ClientAuthType such as
@@ -43,8 +45,9 @@ func (srv *Server) requireAgent(next http.HandlerFunc) http.HandlerFunc {
4345
http.Error(w, `{"error":"vantage store unavailable"}`, http.StatusInternalServerError)
4446
return
4547
}
46-
cn := r.TLS.PeerCertificates[0].Subject.CommonName
47-
active, err := srv.Vantages.IsActive(r.Context(), cn)
48+
leaf := r.TLS.PeerCertificates[0]
49+
cn := leaf.Subject.CommonName
50+
active, err := srv.Vantages.IsActive(r.Context(), cn, leaf.SerialNumber)
4851
if err != nil {
4952
http.Error(w, `{"error":"store unavailable"}`, http.StatusServiceUnavailable)
5053
return

0 commit comments

Comments
 (0)