fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)

The human->UI hop now authenticates with a hardware passkey (YubiKey), not an
mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker
stays mTLS — service-to-service.)

- webauthn.go: credential store (registered admins + passkeys + a one-time-token
  jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup.
- cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed
  enrolment link over a shared enrol key; the browser completes the WebAuthn
  create ceremony. No anyone-can-register endpoint; single-use via the ledger.
- authapi.go: register/login ceremonies (usernameless discoverable login,
  userVerification required) + logout + whoami; short-lived in-memory ceremony
  store between begin/finish.
- session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie)
  + requireSession middleware gating /api/*, with a CSRF header check on mutations.
  adminName now comes from the session (audit attribution).
- main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH
  (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt
  DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org.
  /api/* mounted behind requireSession; /auth/* + /healthz public.

Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE
cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01
can't validate; DNS-01 proves control of bitasylum.org via TXT + supports
wildcards). External ACME client issues+renews to the cert path; manager reloads.

Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token
mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store
persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony
(virtual authenticator / real YubiKey) is a bench item.
This commit is contained in:
Audiobuzz
2026-07-04 04:26:30 +00:00
parent 9c86cd4c5a
commit c40dd5ac93
11 changed files with 1055 additions and 110 deletions
+67 -27
View File
@@ -10,18 +10,40 @@ Unlike `fleet-hostapi` / `fleet-enrol` (baked into the reproducible verity image
stdlib-only), this runs **server-side** as its own container on the management
network, so external deps are fine (`grpc-go`, `influxdb-client-go`).
## Two mTLS planes (distinct trust anchors)
## Two planes (distinct trust models)
| Plane | Transport | Verifies | Who | Purpose |
| Plane | Transport | Browser/peer identity | Who | Purpose |
|---|---|---|---|---|
| **admin** | HTTPS + mTLS | **dedicated admin CA** (`admin-pki.sh`) | admin browsers | JSON API + SPA (monitor / authorise / lifecycle) |
| **admin** | HTTPS (server-auth) | **WebAuthn/passkey** (YubiKey) → session | admin browsers | JSON API + SPA (monitor / authorise / lifecycle) |
| **agent** | gRPC + mTLS | **fleet CA** (host TPM cert) | enrolled hosts | bidi stream: health/metrics up, desired-state down |
The split is load-bearing: the agent plane uses the host's **reused TPM identity
cert** (the same one used for 802.1X EAP-TLS and from which the NVMe `hostnqn`
derives — "one identity, three stages"), while the admin plane uses a **separate**
CA so an enrolled host can never reach approve/deny/lifecycle. See the identity
discussion in the enrolment design §5.
The agent plane uses the host's **reused TPM identity cert** (the same one used
for 802.1X EAP-TLS and from which the NVMe `hostnqn` derives — "one identity,
three stages"). The admin plane authenticates the **human** with a hardware
**passkey** (phishing-resistant, origin-bound), not a client cert — so there's no
cert to import and nothing to phish. (The manager→broker hop is separately mTLS;
that's service-to-service — see [Authorise](#authorise-pillar).)
### Admin auth: passkeys/WebAuthn
- **Login** is usernameless (discoverable passkey) with `userVerification:
required` — tap the YubiKey + PIN. A successful assertion mints an opaque,
server-side session (`Secure`/`HttpOnly`/`SameSite=Strict` cookie); mutating
requests must also echo the session CSRF token in `X-Fleet-CSRF`.
- **Bootstrap** is closed: `fleet-manager register-admin <name>` (run on the mgmt
host) mints a one-time, HMAC-signed enrolment link; the admin opens it in the
browser and registers their passkey. Single-use (jti ledger). Further admins can
be enrolled from the UI once one exists.
- **RP is origin-bound**: `FLEET_MGR_RP_ID` / `FLEET_MGR_RP_ORIGIN` must be the
UI's real hostname — for this deployment **`diskless-server.bitasylum.org`** /
`https://diskless-server.bitasylum.org`.
- **HTTPS cert** for the UI is a publicly-trusted cert via **Let's Encrypt
DNS-01** (the UI is on the mgmt network, not internet-reachable, so HTTP-01 /
TLS-ALPN-01 can't validate; DNS-01 proves control of `bitasylum.org` via a TXT
record and supports wildcards). An external ACME client (lego/certbot + DNS
provider) issues + renews to `FLEET_MGR_ADMIN_CERT/_KEY`; the manager
**hot-reloads** the cert on renewal (no restart). A trusted cert also keeps the
WebAuthn secure-context clean (no browser warnings).
## Monitoring path (no host ever touches InfluxDB)
@@ -57,22 +79,29 @@ inspectable, reproducible; a DB swap is a drop-in behind the `Store` interface.
## API (admin plane)
| Route | Status |
|---|---|
| `GET /healthz` | ✅ ckpt 1 |
| `GET /api/hosts` | ✅ ckpt 1 — fleet overview |
| `GET /api/hosts/{serial}` | ✅ ckpt 1 — host detail |
| `PUT /api/hosts/{serial}/desired` | ✅ ckpt 1 — set desired-state (audited to admin CN) |
| `GET /api/enrol/pending` | ✅ ckpt 3 — proxies the broker queue |
| `POST /api/enrol/{id}/approve` | ✅ ckpt 3 — signs via broker + **births inventory** (identity/binding/enrolled) |
| `POST /api/enrol/{id}/deny` | ✅ ckpt 3 |
| `GET /api/hosts/{serial}/metrics?field=&range=` | ✅ ckpt 4 — Flux time-series (field whitelist) |
| Route | Auth | Status |
|---|---|---|
| `GET /healthz` | open | ✅ ckpt 1 |
| `POST /auth/register/begin\|finish` | enrol token | ✅ ckpt 5 — passkey registration ceremony |
| `POST /auth/login/begin\|finish` | passkey | ✅ ckpt 5 — usernameless discoverable login → session |
| `POST /auth/logout` | session | ✅ ckpt 5 |
| `GET /api/whoami` | session | ✅ ckpt 5 — resume session + recover CSRF token |
| `GET /api/hosts` | session | ✅ ckpt 1 — fleet overview |
| `GET /api/hosts/{serial}` | session | ✅ ckpt 1 — host detail |
| `PUT /api/hosts/{serial}/desired` | session + CSRF | ✅ ckpt 1 — set desired-state (audited to admin) |
| `GET /api/enrol/pending` | session | ✅ ckpt 3 — proxies the broker queue |
| `POST /api/enrol/{id}/approve` | session + CSRF | ✅ ckpt 3 — signs via broker + **births inventory** |
| `POST /api/enrol/{id}/deny` | session + CSRF | ✅ ckpt 3 |
| `GET /api/hosts/{serial}/metrics?field=&range=` | session | ✅ ckpt 4 — Flux time-series (field whitelist) |
The authorise pillar drives the enrol-broker's admin plane over mTLS (the manager
is a service client, gated by the dedicated admin CA) asking for JSON, so an
approve returns the decided enrolment; the manager records the **human** approver
(its own admin mTLS CN) and births the inventory record. The broker stays the
enrolment state machine + step-ca signer (v1 "alongside" fold-in).
`/api/*` is gated by the passkey session (mutations also need the CSRF header).
<a name="authorise-pillar"></a>The authorise pillar drives the enrol-broker's
admin plane over **mTLS** (the manager is a service client, gated by the dedicated
admin CA) asking for JSON, so an approve returns the decided enrolment; the manager
records the **human** approver (the signed-in passkey admin) and births the
inventory record. The broker stays the enrolment state machine + step-ca signer
(v1 "alongside" fold-in).
**Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2.
`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down.
@@ -86,9 +115,14 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink`
| Var | Default | Notes |
|---|---|---|
| `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener |
| `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | admin server cert/key (mTLS) |
| `FLEET_MGR_ADMIN_CLIENT_CA` | — | **dedicated admin CA** (NOT the fleet CA) |
| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, no client-cert — **dev only** |
| `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | UI server cert/key (server-auth; **LE DNS-01**, hot-reloaded) |
| `FLEET_MGR_RP_ID` | — | WebAuthn RP ID = UI hostname (`diskless-server.bitasylum.org`) |
| `FLEET_MGR_RP_ORIGIN` | — | WebAuthn origin (`https://diskless-server.bitasylum.org`) |
| `FLEET_MGR_RP_NAME` | `Fleet Command` | RP display name |
| `FLEET_MGR_ENROLL_KEY_FILE` | — | shared HMAC key for enrolment tokens (CLI + service) |
| `FLEET_MGR_ADMIN_STORE` | `/var/lib/fleet-manager/admins.json` | registered-passkey store |
| `FLEET_MGR_SESSION_TTL_MIN` | `720` | session lifetime (minutes) |
| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, **no passkey auth** — dev only |
| `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener |
| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key |
| `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. |
@@ -130,4 +164,10 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name,
read API (`/api/hosts/{serial}/metrics`) that runs a Flux query with a **field
whitelist** + hex-serial guard (no caller input reaches the query raw). Unit
tests for point construction, query building, and unsafe-input rejection.
- Next: themed SPA (ckpt 5) → Docker/compose + PKI + docs (ckpt 6).
- **Checkpoint 5 (done):** admin **WebAuthn/passkey** auth replacing browser mTLS —
credential store, `register-admin` bootstrap CLI (one-time HMAC enrol token),
register/login ceremonies (usernameless + UV required), server-side sessions +
CSRF middleware gating `/api/*`, admin TLS switched to server-auth with a
hot-reloading cert (for LE DNS-01 renewals). Tests: enrol token, session
middleware, cred-store persistence. Live passkey ceremony is a bench item.
- Next: themed SPA (ckpt 6) → Docker/compose + PKI/RP + docs (ckpt 7).