From c40dd5ac93ccee0905a81af4476b93f95a8f81c2 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Sat, 4 Jul 2026 04:26:30 +0000 Subject: [PATCH] =?UTF-8?q?fleet-manager:=20checkpoint=205=20=E2=80=94=20a?= =?UTF-8?q?dmin=20WebAuthn/passkey=20auth=20(replaces=20browser=20mTLS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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. --- serving/fleet-manager/README.md | 94 +++++++++---- serving/fleet-manager/admin.go | 44 ++---- serving/fleet-manager/auth_test.go | 131 ++++++++++++++++++ serving/fleet-manager/authapi.go | 177 ++++++++++++++++++++++++ serving/fleet-manager/broker_test.go | 4 +- serving/fleet-manager/cli.go | 153 ++++++++++++++++++++ serving/fleet-manager/go.mod | 16 ++- serving/fleet-manager/go.sum | 40 ++++-- serving/fleet-manager/main.go | 158 +++++++++++++++------ serving/fleet-manager/session.go | 149 ++++++++++++++++++++ serving/fleet-manager/webauthn.go | 199 +++++++++++++++++++++++++++ 11 files changed, 1055 insertions(+), 110 deletions(-) create mode 100644 serving/fleet-manager/auth_test.go create mode 100644 serving/fleet-manager/authapi.go create mode 100644 serving/fleet-manager/cli.go create mode 100644 serving/fleet-manager/session.go create mode 100644 serving/fleet-manager/webauthn.go diff --git a/serving/fleet-manager/README.md b/serving/fleet-manager/README.md index 89355d8..6c8102a 100644 --- a/serving/fleet-manager/README.md +++ b/serving/fleet-manager/README.md @@ -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 ` (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). + +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). diff --git a/serving/fleet-manager/admin.go b/serving/fleet-manager/admin.go index d561680..60b4f67 100644 --- a/serving/fleet-manager/admin.go +++ b/serving/fleet-manager/admin.go @@ -1,10 +1,7 @@ -// admin.go — the admin JSON API (the SPA's backend) served on the mTLS admin -// plane. Checkpoint 1 covers the MONITOR read surface + LIFECYCLE desired-state; -// the AUTHORISE routes (enrolment approve/deny) land in checkpoint 3 and the -// InfluxDB-backed metrics routes in checkpoint 4. -// -// Every mutating action is attributed to the admin's mTLS client-cert CN -// (adminCN), the same audit convention the broker uses. +// admin.go — the session-gated admin JSON API (the SPA's backend). Mounted under +// /api/ behind requireSession; browser identity is the WebAuthn passkey session +// (session.go), so every mutating action is attributed to the signed-in admin +// (adminName), not a client cert. package main import ( @@ -16,12 +13,11 @@ import ( "time" ) -func (s *server) routes(mux *http.ServeMux) { - // Liveness (no auth-sensitive data; also reachable in insecure dev mode). - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok\n")) - }) +// apiRoutes registers the session-gated admin API (mounted under /api/ behind +// requireSession). The public liveness + WebAuthn ceremonies live in authRoutes. +func (s *server) apiRoutes(mux *http.ServeMux) { + // Session probe (lets the SPA resume + recover its CSRF token on reload). + mux.HandleFunc("GET /api/whoami", s.handleWhoami) // MONITOR — fleet overview + per-host detail. mux.HandleFunc("GET /api/hosts", s.handleListHosts) @@ -62,8 +58,8 @@ func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error()) return } - // Provenance: the client-cert CN is authoritative; ignore any body-supplied value. - d.UpdatedBy = adminCN(r) + // Provenance: the signed-in admin is authoritative; ignore any body value. + d.UpdatedBy = adminName(r) h, ok, err := s.store.SetDesired(serial, d) if err != nil { @@ -101,7 +97,7 @@ func (s *server) handleEnrolPending(w http.ResponseWriter, r *http.Request) { // handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS // the inventory record from the returned enrolment + the optional binding the -// human records at approval time. The approver is THIS admin's client-cert CN +// human records at approval time. The approver is THIS signed-in admin's identity // (the human at the SPA), not the broker's view of the manager service. func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) { if s.broker == nil { @@ -131,13 +127,13 @@ func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) { host := &Host{ Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash}, Binding: binding, - Enrolled: Enrolled{Approver: adminCN(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint}, + Enrolled: Enrolled{Approver: adminName(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint}, } if err := s.store.Upsert(host); err != nil { writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error()) return } - log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminCN(r), id) + log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminName(r), id) born, _ := s.store.Get(e.CertSerial) writeJSON(w, http.StatusOK, born) @@ -190,18 +186,6 @@ func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// adminCN returns the CN of the admin's mTLS client cert, for audit attribution. -// In insecure dev mode there is no peer cert, so it reports a sentinel. -func adminCN(r *http.Request) string { - if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { - if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" { - return cn - } - return "unknown-admin" - } - return "dev-insecure" -} - // ---- JSON response helpers ------------------------------------------------ func writeJSON(w http.ResponseWriter, code int, v any) { diff --git a/serving/fleet-manager/auth_test.go b/serving/fleet-manager/auth_test.go new file mode 100644 index 0000000..88df5f5 --- /dev/null +++ b/serving/fleet-manager/auth_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestEnrollTokenRoundTrip(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + tok, err := mintEnrollToken(key, "alice", 10*time.Minute) + if err != nil { + t.Fatalf("mint: %v", err) + } + claims, err := verifyEnrollToken(key, tok) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.Name != "alice" || claims.JTI == "" { + t.Fatalf("claims = %+v", claims) + } + + // wrong key rejected + if _, err := verifyEnrollToken([]byte("wrongwrongwrongwrongwrongwrongwr"), tok); err == nil { + t.Error("expected wrong-key rejection") + } + // tampered signature rejected + if _, err := verifyEnrollToken(key, tok[:len(tok)-2]+"xx"); err == nil { + t.Error("expected tamper rejection") + } + // expired rejected + exp, _ := mintEnrollToken(key, "bob", -time.Minute) + if _, err := verifyEnrollToken(key, exp); err == nil { + t.Error("expected expiry rejection") + } +} + +func TestSessionMiddleware(t *testing.T) { + s := &server{cfg: config{}, sessions: newSessionStore(time.Hour)} // adminInsecure=false + id, csrf := s.sessions.create("alice") + + var reached string + guarded := s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = adminName(r) + w.WriteHeader(http.StatusOK) + })) + + // no cookie -> 401 + rw := httptest.NewRecorder() + guarded.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/hosts", nil)) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("no cookie: got %d want 401", rw.Code) + } + + withCookie := func(method string, csrfHdr string) *http.Request { + r := httptest.NewRequest(method, "/api/x", nil) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id}) + if csrfHdr != "" { + r.Header.Set(csrfHeader, csrfHdr) + } + return r + } + + // valid GET -> reaches handler as alice + reached = "" + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodGet, "")) + if rw.Code != http.StatusOK || reached != "alice" { + t.Fatalf("valid GET: code=%d admin=%q", rw.Code, reached) + } + + // mutating POST without CSRF -> 403 + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodPost, "")) + if rw.Code != http.StatusForbidden { + t.Fatalf("POST no CSRF: got %d want 403", rw.Code) + } + + // mutating POST with correct CSRF -> reaches handler + reached = "" + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodPost, csrf)) + if rw.Code != http.StatusOK || reached != "alice" { + t.Fatalf("POST with CSRF: code=%d admin=%q", rw.Code, reached) + } + + // unknown cookie -> 401 + rw = httptest.NewRecorder() + bad := httptest.NewRequest(http.MethodGet, "/api/x", nil) + bad.AddCookie(&http.Cookie{Name: sessionCookie, Value: "nope"}) + guarded.ServeHTTP(rw, bad) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("bad cookie: got %d want 401", rw.Code) + } +} + +func TestCredStorePersistence(t *testing.T) { + path := t.TempDir() + "/admins.json" + s, err := newCredStore(path) + if err != nil { + t.Fatalf("new: %v", err) + } + u := s.getOrCreate("alice") + handle := u.IDb + if err := s.addCredential("alice", webauthn.Credential{ID: []byte("cred-1"), PublicKey: []byte("pk")}); err != nil { + t.Fatalf("add: %v", err) + } + s.markTokenUsed("jti-1", time.Now().Add(time.Hour).Unix()) + + // reload from disk + s2, err := newCredStore(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if s2.count() != 1 { + t.Fatalf("admins after reload = %d", s2.count()) + } + got, ok := s2.byHandle(handle) + if !ok || got.Name != "alice" || len(got.Creds) != 1 { + t.Fatalf("byHandle after reload: ok=%v %+v", ok, got) + } + if !s2.tokenUsed("jti-1") { + t.Error("used-token ledger did not survive reload") + } + if s2.tokenUsed("jti-unknown") { + t.Error("unknown token reported used") + } +} diff --git a/serving/fleet-manager/authapi.go b/serving/fleet-manager/authapi.go new file mode 100644 index 0000000..ccc8474 --- /dev/null +++ b/serving/fleet-manager/authapi.go @@ -0,0 +1,177 @@ +// authapi.go — the WebAuthn ceremony endpoints (unauthenticated: they ARE the +// gate) + logout + whoami. register/* is authorised by the one-time enrol token +// from the CLI; login/* is usernameless (discoverable passkey). A successful +// login mints the server-side session (session.go). +package main + +import ( + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// ceremony holds the in-flight WebAuthn SessionData between begin and finish, +// plus the enrol claims for a registration. Short-lived, in-memory. +type ceremony struct { + sess *webauthn.SessionData + name string + jti string + exp int64 + expires time.Time +} + +type ceremonyStore struct { + mu sync.Mutex + m map[string]ceremony +} + +func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} } + +func (c *ceremonyStore) put(sess *webauthn.SessionData, name, jti string, exp int64) string { + id := randToken() + c.mu.Lock() + now := time.Now() + for k, v := range c.m { + if now.After(v.expires) { + delete(c.m, k) + } + } + c.m[id] = ceremony{sess: sess, name: name, jti: jti, exp: exp, expires: now.Add(5 * time.Minute)} + c.mu.Unlock() + return id +} + +func (c *ceremonyStore) take(id string) (ceremony, bool) { + c.mu.Lock() + defer c.mu.Unlock() + cer, ok := c.m[id] + if ok { + delete(c.m, id) + } + if ok && time.Now().After(cer.expires) { + return ceremony{}, false + } + return cer, ok +} + +func (s *server) authRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + }) + if s.wa == nil { + return // insecure/dev mode has no passkey ceremonies + } + mux.HandleFunc("POST /auth/register/begin", s.handleRegisterBegin) + mux.HandleFunc("POST /auth/register/finish", s.handleRegisterFinish) + mux.HandleFunc("POST /auth/login/begin", s.handleLoginBegin) + mux.HandleFunc("POST /auth/login/finish", s.handleLoginFinish) + mux.HandleFunc("POST /auth/logout", s.handleLogout) +} + +// register/begin — authorised by the one-time enrol token (?token=). +func (s *server) handleRegisterBegin(w http.ResponseWriter, r *http.Request) { + claims, err := verifyEnrollToken(s.enrollKey, r.URL.Query().Get("token")) + if err != nil { + writeErr(w, http.StatusUnauthorized, "invalid enrolment token: "+err.Error()) + return + } + if s.creds.tokenUsed(claims.JTI) { + writeErr(w, http.StatusConflict, "enrolment token already used") + return + } + user := s.creds.getOrCreate(claims.Name) + sel := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.VerificationRequired, + } + options, sessionData, err := s.wa.BeginRegistration(user, webauthn.WithAuthenticatorSelection(sel)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "begin registration: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, claims.Name, claims.JTI, claims.Exp), "options": options}) +} + +func (s *server) handleRegisterFinish(w http.ResponseWriter, r *http.Request) { + cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) + if !ok || cer.sess == nil { + writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") + return + } + if s.creds.tokenUsed(cer.jti) { + writeErr(w, http.StatusConflict, "enrolment token already used") + return + } + user := s.creds.getOrCreate(cer.name) + cred, err := s.wa.FinishRegistration(user, *cer.sess, r) + if err != nil { + writeErr(w, http.StatusBadRequest, "registration failed: "+err.Error()) + return + } + if err := s.creds.addCredential(cer.name, *cred); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + s.creds.markTokenUsed(cer.jti, cer.exp) + log.Printf("audit ADMIN-ENROL name=%q (passkey registered)", cer.name) + writeJSON(w, http.StatusOK, map[string]any{"admin": cer.name, "registered": true}) +} + +// login/begin — usernameless discoverable login. +func (s *server) handleLoginBegin(w http.ResponseWriter, r *http.Request) { + options, sessionData, err := s.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "begin login: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, "", "", 0), "options": options}) +} + +func (s *server) handleLoginFinish(w http.ResponseWriter, r *http.Request) { + cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) + if !ok || cer.sess == nil { + writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") + return + } + var matched *adminUser + handler := func(rawID, userHandle []byte) (webauthn.User, error) { + u, ok := s.creds.byHandle(userHandle) + if !ok { + return nil, fmt.Errorf("unknown passkey") + } + matched = u + return u, nil + } + cred, err := s.wa.FinishDiscoverableLogin(handler, *cer.sess, r) + if err != nil || matched == nil { + writeErr(w, http.StatusUnauthorized, "login failed") + return + } + s.creds.updateCredential(matched.IDb, *cred) // persist the signature counter + id, csrf := s.sessions.create(matched.Name) + setSessionCookie(w, id, s.cfg.sessionTTL) + log.Printf("audit ADMIN-LOGIN name=%q", matched.Name) + writeJSON(w, http.StatusOK, map[string]any{"admin": matched.Name, "csrf": csrf}) +} + +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + s.sessions.delete(c.Value) + } + clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +// handleWhoami is session-gated (registered under /api): lets the SPA resume an +// existing session on reload and recover the CSRF token. +func (s *server) handleWhoami(w http.ResponseWriter, r *http.Request) { + sess, _ := sessionFrom(r) + writeJSON(w, http.StatusOK, map[string]any{"admin": adminName(r), "csrf": sess.csrf}) +} diff --git a/serving/fleet-manager/broker_test.go b/serving/fleet-manager/broker_test.go index bfc2aa1..cc6f9a2 100644 --- a/serving/fleet-manager/broker_test.go +++ b/serving/fleet-manager/broker_test.go @@ -43,7 +43,7 @@ func TestAuthoriseApproveBirthsInventory(t *testing.T) { store, _ := newJSONStore("") s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}} mux := http.NewServeMux() - s.routes(mux) + s.apiRoutes(mux) // pending proxies through pw := httptest.NewRecorder() @@ -93,7 +93,7 @@ func TestAuthoriseDisabledWhenNoBroker(t *testing.T) { store, _ := newJSONStore("") s := &server{store: store, broker: nil} // authorise not configured mux := http.NewServeMux() - s.routes(mux) + s.apiRoutes(mux) rw := httptest.NewRecorder() mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) diff --git a/serving/fleet-manager/cli.go b/serving/fleet-manager/cli.go new file mode 100644 index 0000000..e4c1e55 --- /dev/null +++ b/serving/fleet-manager/cli.go @@ -0,0 +1,153 @@ +// cli.go — the `register-admin` bootstrap subcommand and the one-time enrolment +// token it mints. +// +// WebAuthn registration is a BROWSER ceremony (the credential is born on the +// YubiKey via navigator.credentials.create) — a CLI can't do it directly. So the +// CLI's job is to AUTHORISE an enrolment: it mints a short-lived HMAC-signed token +// (over a shared enrol key on the mgmt host) and prints an enrolment URL. The +// browser completes the ceremony; the register endpoint verifies the token. This +// keeps registration closed (no anyone-can-register endpoint) while the actual +// key material never leaves the YubiKey. Single-use is enforced by the credStore +// jti ledger. +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type enrollClaims struct { + Name string + JTI string + Exp int64 +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } +func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } + +func hmacSum(key []byte, msg string) []byte { + m := hmac.New(sha256.New, key) + m.Write([]byte(msg)) + return m.Sum(nil) +} + +// mintEnrollToken -> base64url(name|exp|jti) "." base64url(hmac). +func mintEnrollToken(key []byte, name string, ttl time.Duration) (string, error) { + jb := make([]byte, 12) + if _, err := rand.Read(jb); err != nil { + return "", err + } + jti := hex.EncodeToString(jb) + exp := time.Now().Add(ttl).Unix() + msg := name + "|" + strconv.FormatInt(exp, 10) + "|" + jti + return b64([]byte(msg)) + "." + b64(hmacSum(key, msg)), nil +} + +func verifyEnrollToken(key []byte, token string) (enrollClaims, error) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return enrollClaims{}, fmt.Errorf("malformed token") + } + msg, err := unb64(parts[0]) + if err != nil { + return enrollClaims{}, fmt.Errorf("bad payload") + } + sig, err := unb64(parts[1]) + if err != nil { + return enrollClaims{}, fmt.Errorf("bad signature encoding") + } + if subtle.ConstantTimeCompare(sig, hmacSum(key, string(msg))) != 1 { + return enrollClaims{}, fmt.Errorf("bad signature") + } + f := strings.SplitN(string(msg), "|", 3) + if len(f) != 3 { + return enrollClaims{}, fmt.Errorf("bad claims") + } + exp, _ := strconv.ParseInt(f[1], 10, 64) + if time.Now().Unix() > exp { + return enrollClaims{}, fmt.Errorf("token expired") + } + return enrollClaims{Name: f[0], JTI: f[2], Exp: exp}, nil +} + +// loadEnrollKey reads the shared enrol key (>=32 bytes). Used by the SERVICE — it +// must already exist (the same key the CLI mints with). +func loadEnrollKey(path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("no enrol key file configured (FLEET_MGR_ENROLL_KEY_FILE)") + } + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(b) < 32 { + return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path) + } + return b, nil +} + +// loadOrCreateEnrollKey is the CLI's variant: it generates the key on first use. +func loadOrCreateEnrollKey(path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("set -key or FLEET_MGR_ENROLL_KEY_FILE") + } + if b, err := os.ReadFile(path); err == nil { + if len(b) < 32 { + return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path) + } + return b, nil + } else if !os.IsNotExist(err) { + return nil, err + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + if err := os.WriteFile(path, key, 0o600); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "note: generated a new enrol key at %s (mount the SAME file into the service)\n", path) + return key, nil +} + +// runRegisterAdmin implements: fleet-manager register-admin [-key F] [-origin URL] +func runRegisterAdmin(args []string) int { + fs := flag.NewFlagSet("register-admin", flag.ContinueOnError) + keyFile := fs.String("key", env("FLEET_MGR_ENROLL_KEY_FILE", ""), "shared enrol key file") + origin := fs.String("origin", env("FLEET_MGR_RP_ORIGIN", ""), "admin UI origin (for the enrolment URL)") + ttl := fs.Duration("ttl", 10*time.Minute, "enrolment token validity") + if err := fs.Parse(args); err != nil { + return 2 + } + name := fs.Arg(0) + if name == "" { + fmt.Fprintln(os.Stderr, "usage: fleet-manager register-admin [-key F] [-origin URL] [-ttl D] ") + return 2 + } + key, err := loadOrCreateEnrollKey(*keyFile) + if err != nil { + fmt.Fprintf(os.Stderr, "enrol key: %v\n", err) + return 1 + } + tok, err := mintEnrollToken(key, name, *ttl) + if err != nil { + fmt.Fprintf(os.Stderr, "mint: %v\n", err) + return 1 + } + base := strings.TrimRight(*origin, "/") + if base == "" { + base = "https://" + } + fmt.Printf("Enrolment link for %q (valid %s) — open on the mgmt network, then tap the YubiKey:\n\n %s/#/enrol?token=%s\n\n", name, ttl.String(), base, tok) + return 0 +} diff --git a/serving/fleet-manager/go.mod b/serving/fleet-manager/go.mod index cece8ff..c112a48 100644 --- a/serving/fleet-manager/go.mod +++ b/serving/fleet-manager/go.mod @@ -14,6 +14,7 @@ module fleet-manager go 1.25.0 require ( + github.com/go-webauthn/webauthn v0.17.4 github.com/influxdata/influxdb-client-go/v2 v2.14.0 google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.11 @@ -21,11 +22,20 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect github.com/oapi-codegen/runtime v1.0.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect ) diff --git a/serving/fleet-manager/go.sum b/serving/fleet-manager/go.sum index c1b50cf..efc1a94 100644 --- a/serving/fleet-manager/go.sum +++ b/serving/fleet-manager/go.sum @@ -7,14 +7,28 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= @@ -24,13 +38,19 @@ github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1 github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -43,12 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= diff --git a/serving/fleet-manager/main.go b/serving/fleet-manager/main.go index 10b21c4..845d460 100644 --- a/serving/fleet-manager/main.go +++ b/serving/fleet-manager/main.go @@ -1,27 +1,27 @@ // fleet-manager — server-side control plane for the diskless kiosk fleet. // -// Two planes, mirroring the enrol-broker's two-listener pattern but with distinct -// trust anchors (see docs/tpm-enrolment-design.md §5 "identity"): +// Two planes with DISTINCT trust models: // -// - ADMIN plane (this checkpoint): HTTPS + mTLS verified by the DEDICATED admin -// CA (admin-pki.sh) — NOT the fleet CA — so an enrolled host cert can never -// reach approve/deny/lifecycle. Serves the JSON API and (later) the SPA to -// admin browsers. -// - AGENT plane (checkpoint 2): gRPC + mTLS verified by the FLEET CA — the -// host's reused TPM identity cert. Bidi stream: health/metrics up, -// desired-state down. +// - ADMIN plane: HTTPS (server-auth TLS) + browser identity via WebAuthn/passkey +// (YubiKey) → server-side session. Serves the JSON API + the SPA to admins. +// (manager→broker is separately mTLS; that's service-to-service.) +// - AGENT plane: gRPC + mTLS verified by the FLEET CA — the host's reused TPM +// identity cert. Bidi stream: health/metrics up, desired-state down. // -// Checkpoint 1 wires only the admin plane + inventory + a seed host. +// A `register-admin` subcommand mints a one-time passkey-enrolment link. package main import ( "crypto/tls" - "crypto/x509" + "fmt" "log" "net/http" "os" "strconv" + "sync" "time" + + "github.com/go-webauthn/webauthn/webauthn" ) type config struct { @@ -29,7 +29,6 @@ type config struct { adminInsecure bool adminCert string adminKey string - adminClientCA string // dedicated admin CA (verifies admin browser certs) agentAddr string agentCert string @@ -45,6 +44,14 @@ type config struct { // InfluxDB (the manager is the only reader/writer; token is server-side only). influx influxConfig + // Admin browser auth = WebAuthn/passkey (replaces browser mTLS). Origin-bound. + rpID string + rpOrigin string + rpDisplayName string + enrollKeyFile string + credStorePath string + sessionTTL time.Duration + inventoryPath string seed bool hostnqnBase string @@ -56,7 +63,6 @@ func loadConfig() config { adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true", adminCert: env("FLEET_MGR_ADMIN_CERT", ""), adminKey: env("FLEET_MGR_ADMIN_KEY", ""), - adminClientCA: env("FLEET_MGR_ADMIN_CLIENT_CA", ""), agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"), agentCert: env("FLEET_MGR_AGENT_CERT", ""), @@ -75,6 +81,13 @@ func loadConfig() config { bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"), }, + rpID: env("FLEET_MGR_RP_ID", ""), + rpOrigin: env("FLEET_MGR_RP_ORIGIN", ""), + rpDisplayName: env("FLEET_MGR_RP_NAME", "Fleet Command"), + enrollKeyFile: env("FLEET_MGR_ENROLL_KEY_FILE", ""), + credStorePath: env("FLEET_MGR_ADMIN_STORE", "/var/lib/fleet-manager/admins.json"), + sessionTTL: time.Duration(envInt("FLEET_MGR_SESSION_TTL_MIN", 720)) * time.Minute, + inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"), seed: env("FLEET_MGR_SEED", "") == "true", hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"), @@ -88,9 +101,22 @@ type server struct { hub *agentHub // live agent streams; nil if the agent plane isn't configured broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured influx *influxSink // metrics gateway; nil if InfluxDB isn't configured + + // Admin passkey auth (nil in insecure dev mode). + wa *webauthn.WebAuthn + creds *credStore + sessions *sessionStore + ceremonies *ceremonyStore + enrollKey []byte } func main() { + // Subcommand: `fleet-manager register-admin ` mints a passkey-enrolment + // link (no server needed) — the bootstrap for the WebAuthn admin plane. + if len(os.Args) > 1 && os.Args[1] == "register-admin" { + os.Exit(runRegisterAdmin(os.Args[2:])) + } + log.SetFlags(log.LstdFlags | log.LUTC) cfg := loadConfig() @@ -144,12 +170,42 @@ func main() { log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)") } - mux := http.NewServeMux() - s.routes(mux) + // Admin browser auth = WebAuthn/passkey (secure mode only). Sets up the RP, + // the credential store, the shared enrol key, sessions + ceremonies. The admin + // TLS below is server-auth only — browser identity comes from the passkey. + if !cfg.adminInsecure { + wa, err := newWebAuthn(cfg) + if err != nil { + log.Fatalf("webauthn: %v", err) + } + creds, err := newCredStore(cfg.credStorePath) + if err != nil { + log.Fatalf("admin store: %v", err) + } + key, err := loadEnrollKey(cfg.enrollKeyFile) + if err != nil { + log.Fatalf("enrol key: %v", err) + } + s.wa, s.creds, s.enrollKey = wa, creds, key + s.sessions = newSessionStore(cfg.sessionTTL) + s.ceremonies = newCeremonyStore() + if creds.count() == 0 { + log.Printf("NOTE: no admins enrolled yet — run `fleet-manager register-admin ` and open the link to add a passkey") + } + } + + // HTTP handler: public liveness + WebAuthn ceremonies at the root; the admin + // API under /api/ gated by the passkey session (requireSession). The SPA (ckpt + // 6) will be served at / from an embedded FS. + root := http.NewServeMux() + s.authRoutes(root) + apiMux := http.NewServeMux() + s.apiRoutes(apiMux) + root.Handle("/api/", s.requireSession(apiMux)) srv := &http.Server{ Addr: cfg.adminAddr, - Handler: mux, + Handler: root, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, @@ -157,8 +213,7 @@ func main() { } if cfg.adminInsecure { - log.Printf("WARNING: admin plane INSECURE (plaintext, no client-cert auth) on %s — dev/lab only", cfg.adminAddr) - log.Printf("fleet-manager admin (insecure) listening on %s", cfg.adminAddr) + log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr) log.Fatal(srv.ListenAndServe()) } @@ -167,41 +222,64 @@ func main() { log.Fatalf("admin TLS: %v", err) } srv.TLSConfig = tlsCfg - log.Printf("fleet-manager admin (mTLS) listening on %s", cfg.adminAddr) + log.Printf("fleet-manager admin (HTTPS + passkey) listening on %s (rp=%s)", cfg.adminAddr, cfg.rpID) log.Fatal(srv.ListenAndServeTLS("", "")) // certs are in TLSConfig } -// adminTLS builds the mTLS server config: our server cert + REQUIRE a client cert -// signed by the dedicated admin CA. Reusing the fleet CA here would let any host -// cert authenticate to the admin plane — privilege escalation — so it is a hard -// error to omit the admin client CA in secure mode. +// adminTLS builds the admin plane's SERVER-AUTH TLS. Browser identity is a passkey +// (WebAuthn), not a client cert, so there is no ClientCAs/ClientAuth here — just +// our server cert. (manager→broker mTLS is separate; see broker.go.) +// +// The cert is served via a hot-reloading GetCertificate so an out-of-band renewal +// is picked up WITHOUT a restart. The intended deployment is a publicly-trusted +// cert obtained by an external ACME client 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 via a TXT record and supports wildcards). The ACME client +// (lego/certbot + a DNS provider) writes cert+key to FLEET_MGR_ADMIN_CERT/_KEY on +// a shared volume; this reloader notices the mtime bump and swaps the cert live. func adminTLS(cfg config) (*tls.Config, error) { if cfg.adminCert == "" || cfg.adminKey == "" { return nil, errString("FLEET_MGR_ADMIN_CERT and FLEET_MGR_ADMIN_KEY are required (or set FLEET_MGR_ADMIN_INSECURE=true for dev)") } - if cfg.adminClientCA == "" { - return nil, errString("FLEET_MGR_ADMIN_CLIENT_CA is required — the dedicated admin CA that verifies admin browser certs (must NOT be the fleet CA)") - } - cert, err := tls.LoadX509KeyPair(cfg.adminCert, cfg.adminKey) - if err != nil { + rl := &certReloader{certFile: cfg.adminCert, keyFile: cfg.adminKey} + if _, err := rl.GetCertificate(nil); err != nil { // fail fast on a bad/missing cert return nil, err } - caPEM, err := os.ReadFile(cfg.adminClientCA) - if err != nil { - return nil, err - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caPEM) { - return nil, errString("no certificates parsed from FLEET_MGR_ADMIN_CLIENT_CA") - } return &tls.Config{ - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: pool, + MinVersion: tls.VersionTLS12, + GetCertificate: rl.GetCertificate, }, nil } +// certReloader serves the admin server cert, reloading from disk when the file's +// mtime advances (an ACME renewal), so Let's Encrypt DNS-01 rotations apply live. +type certReloader struct { + certFile, keyFile string + mu sync.Mutex + cached *tls.Certificate + mtime time.Time +} + +func (c *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + c.mu.Lock() + defer c.mu.Unlock() + fi, statErr := os.Stat(c.certFile) + if statErr == nil && (c.cached == nil || fi.ModTime().After(c.mtime)) { + cert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile) + if err != nil { + if c.cached != nil { + return c.cached, nil // serve the last good cert through a transient mid-rotation error + } + return nil, err + } + c.cached, c.mtime = &cert, fi.ModTime() + } + if c.cached == nil { + return nil, fmt.Errorf("admin cert unavailable: %v", statErr) + } + return c.cached, nil +} + // ---- tiny helpers --------------------------------------------------------- func env(k, def string) string { diff --git a/serving/fleet-manager/session.go b/serving/fleet-manager/session.go new file mode 100644 index 0000000..ac0bd44 --- /dev/null +++ b/serving/fleet-manager/session.go @@ -0,0 +1,149 @@ +// session.go — post-passkey sessions + the middleware that gates the admin API. +// +// WebAuthn authenticates a login CEREMONY; from there we carry an opaque, +// server-side session (revocable, no secrets in the cookie). Cookie is +// Secure + HttpOnly + SameSite=Strict; state-changing requests additionally must +// echo the session's CSRF token in a header (belt-and-braces beyond same-origin). +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "net/http" + "sync" + "time" +) + +const ( + sessionCookie = "fleet_session" + csrfHeader = "X-Fleet-CSRF" +) + +type ctxKey int + +const adminCtxKey ctxKey = 0 + +type session struct { + admin string + csrf string + expires time.Time +} + +type sessionStore struct { + mu sync.Mutex + ttl time.Duration + byID map[string]session +} + +func newSessionStore(ttl time.Duration) *sessionStore { + return &sessionStore{ttl: ttl, byID: map[string]session{}} +} + +func randToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// create mints a session for a just-authenticated admin, returning the session id +// (cookie value) and the CSRF token (returned to the SPA in the login response). +func (s *sessionStore) create(admin string) (id, csrf string) { + id, csrf = randToken(), randToken() + s.mu.Lock() + s.byID[id] = session{admin: admin, csrf: csrf, expires: time.Now().Add(s.ttl)} + s.mu.Unlock() + return id, csrf +} + +func (s *sessionStore) get(id string) (session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.byID[id] + if !ok { + return session{}, false + } + if time.Now().After(sess.expires) { + delete(s.byID, id) + return session{}, false + } + return sess, true +} + +func (s *sessionStore) delete(id string) { + s.mu.Lock() + delete(s.byID, id) + s.mu.Unlock() +} + +// requireSession wraps the admin API. It rejects requests without a valid session +// cookie, and (for mutating methods) without the matching CSRF header. The +// authenticated admin name is injected into the request context for audit. +func (s *server) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Insecure dev mode has no passkey auth — open, and adminName reports the + // dev sentinel. (main refuses insecure unless explicitly enabled.) + if s.cfg.adminInsecure { + next.ServeHTTP(w, r) + return + } + c, err := r.Cookie(sessionCookie) + if err != nil { + writeErr(w, http.StatusUnauthorized, "not signed in") + return + } + sess, ok := s.sessions.get(c.Value) + if !ok { + writeErr(w, http.StatusUnauthorized, "session expired") + return + } + if isMutating(r.Method) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(sess.csrf)) != 1 { + writeErr(w, http.StatusForbidden, "missing or bad CSRF token") + return + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminCtxKey, sess))) + }) +} + +// sessionFrom returns the authenticated session injected by requireSession. +func sessionFrom(r *http.Request) (session, bool) { + sess, ok := r.Context().Value(adminCtxKey).(session) + return sess, ok +} + +func isMutating(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + } + return false +} + +// setSessionCookie writes the session cookie. Secure+HttpOnly+SameSite=Strict. +func setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: id, + Path: "/", + MaxAge: int(ttl.Seconds()), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +// adminName returns the authenticated admin for audit attribution — the session +// identity in secure mode, or the dev sentinel in insecure mode. +func adminName(r *http.Request) string { + if sess, ok := sessionFrom(r); ok && sess.admin != "" { + return sess.admin + } + return "dev-insecure" +} diff --git a/serving/fleet-manager/webauthn.go b/serving/fleet-manager/webauthn.go new file mode 100644 index 0000000..bc460ed --- /dev/null +++ b/serving/fleet-manager/webauthn.go @@ -0,0 +1,199 @@ +// webauthn.go — passkey/WebAuthn admin identity: the credential store (which +// YubiKeys/passkeys are registered admins) and the webauthn.User implementation. +// +// The human→UI hop authenticates with a hardware passkey (YubiKey), NOT an mTLS +// client cert — phishing-resistant, origin-bound, better UX. (The manager→broker +// hop stays mTLS; that's service-to-service.) Registration is bootstrapped by the +// `register-admin` CLI, which mints a one-time signed enrolment token; the browser +// completes the WebAuthn create ceremony (the credential is born on the YubiKey). +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +// adminUser is a registered admin + their passkey credential(s). Implements +// webauthn.User. The ID (user handle) is a random opaque blob, NOT the name, so +// the name can change without breaking discoverable-login lookups. +type adminUser struct { + IDb []byte `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Creds []webauthn.Credential `json:"credentials"` +} + +func (u *adminUser) WebAuthnID() []byte { return u.IDb } +func (u *adminUser) WebAuthnName() string { return u.Name } +func (u *adminUser) WebAuthnDisplayName() string { return u.Name } +func (u *adminUser) WebAuthnCredentials() []webauthn.Credential { return u.Creds } + +// credStore persists registered admins + a used-enrolment-token ledger (so a +// one-time enrolment token can't be replayed). JSON file, atomic writes, mutex — +// same shape as the inventory store. The SERVICE is the only writer; the CLI only +// reads the shared enrol key (see cli.go), never this file. +type credStore struct { + mu sync.Mutex + path string + data credData + byHex map[string]*adminUser // hex(user handle) -> admin (discoverable-login lookup) +} + +type credData struct { + Admins map[string]*adminUser `json:"admins"` // by name + Used map[string]int64 `json:"used_tokens"` // jti -> expiry unix (replay ledger) +} + +func newCredStore(path string) (*credStore, error) { + s := &credStore{path: path, data: credData{Admins: map[string]*adminUser{}, Used: map[string]int64{}}, byHex: map[string]*adminUser{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, fmt.Errorf("read admin store %s: %w", path, err) + } + if len(b) > 0 { + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("parse admin store %s: %w", path, err) + } + } + if s.data.Admins == nil { + s.data.Admins = map[string]*adminUser{} + } + if s.data.Used == nil { + s.data.Used = map[string]int64{} + } + s.reindex() + return s, nil +} + +func (s *credStore) reindex() { + s.byHex = map[string]*adminUser{} + for _, u := range s.data.Admins { + s.byHex[hex.EncodeToString(u.IDb)] = u + } +} + +func (s *credStore) save() error { + if s.path == "" { + return nil + } + if dir := filepath.Dir(s.path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + } + b, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// count returns the number of registered admins (for the "no admins yet" gate). +func (s *credStore) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.data.Admins) +} + +// getOrCreate returns the admin by name, creating an unregistered shell (random +// handle, no creds yet) on first enrolment. +func (s *credStore) getOrCreate(name string) *adminUser { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.data.Admins[name]; ok { + return u + } + id := make([]byte, 32) + _, _ = rand.Read(id) + u := &adminUser{IDb: id, Name: name, CreatedAt: time.Now().UTC()} + s.data.Admins[name] = u + s.byHex[hex.EncodeToString(id)] = u + return u +} + +func (s *credStore) byHandle(handle []byte) (*adminUser, bool) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byHex[hex.EncodeToString(handle)] + return u, ok +} + +// addCredential appends a freshly-registered passkey and persists. +func (s *credStore) addCredential(name string, c webauthn.Credential) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.data.Admins[name] + if !ok { + return fmt.Errorf("no such admin %q", name) + } + u.Creds = append(u.Creds, c) + return s.save() +} + +// updateCredential persists an updated credential (e.g. the signature counter) +// after a successful login — the anti-clone protection. +func (s *credStore) updateCredential(handle []byte, c webauthn.Credential) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byHex[hex.EncodeToString(handle)] + if !ok { + return + } + for i := range u.Creds { + if string(u.Creds[i].ID) == string(c.ID) { + u.Creds[i] = c + _ = s.save() + return + } + } +} + +// tokenUsed reports whether a one-time enrolment token id was already redeemed, +// and reaps expired ledger entries. +func (s *credStore) tokenUsed(jti string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().Unix() + for j, exp := range s.data.Used { + if exp < now { + delete(s.data.Used, j) + } + } + _, used := s.data.Used[jti] + return used +} + +func (s *credStore) markTokenUsed(jti string, exp int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Used[jti] = exp + _ = s.save() +} + +// newWebAuthn builds the relying-party instance. WebAuthn is ORIGIN-BOUND, so the +// RP ID + origin must match the admin UI's hostname (per-environment config). +func newWebAuthn(cfg config) (*webauthn.WebAuthn, error) { + if cfg.rpID == "" || cfg.rpOrigin == "" { + return nil, fmt.Errorf("WebAuthn needs FLEET_MGR_RP_ID and FLEET_MGR_RP_ORIGIN (the admin UI hostname/origin)") + } + return webauthn.New(&webauthn.Config{ + RPID: cfg.rpID, + RPDisplayName: cfg.rpDisplayName, + RPOrigins: []string{cfg.rpOrigin}, + }) +}