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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user