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 stdlib-only), this runs **server-side** as its own container on the management
network, so external deps are fine (`grpc-go`, `influxdb-client-go`). 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 | | **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 The agent plane uses the host's **reused TPM identity cert** (the same one used
cert** (the same one used for 802.1X EAP-TLS and from which the NVMe `hostnqn` for 802.1X EAP-TLS and from which the NVMe `hostnqn` derives — "one identity,
derives — "one identity, three stages"), while the admin plane uses a **separate** three stages"). The admin plane authenticates the **human** with a hardware
CA so an enrolled host can never reach approve/deny/lifecycle. See the identity **passkey** (phishing-resistant, origin-bound), not a client cert — so there's no
discussion in the enrolment design §5. 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) ## 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) ## API (admin plane)
| Route | Status | | Route | Auth | Status |
|---|---| |---|---|---|
| `GET /healthz` | ✅ ckpt 1 | | `GET /healthz` | open | ✅ ckpt 1 |
| `GET /api/hosts` | ✅ ckpt 1 — fleet overview | | `POST /auth/register/begin\|finish` | enrol token | ✅ ckpt 5 — passkey registration ceremony |
| `GET /api/hosts/{serial}` | ✅ ckpt 1 — host detail | | `POST /auth/login/begin\|finish` | passkey | ✅ ckpt 5 — usernameless discoverable login → session |
| `PUT /api/hosts/{serial}/desired` | ✅ ckpt 1 — set desired-state (audited to admin CN) | | `POST /auth/logout` | session | ✅ ckpt 5 |
| `GET /api/enrol/pending` | ✅ ckpt 3 — proxies the broker queue | | `GET /api/whoami` | session | ✅ ckpt 5 — resume session + recover CSRF token |
| `POST /api/enrol/{id}/approve` | ✅ ckpt 3 — signs via broker + **births inventory** (identity/binding/enrolled) | | `GET /api/hosts` | session | ✅ ckpt 1 — fleet overview |
| `POST /api/enrol/{id}/deny` | ✅ ckpt 3 | | `GET /api/hosts/{serial}` | session | ✅ ckpt 1 — host detail |
| `GET /api/hosts/{serial}/metrics?field=&range=` | ✅ ckpt 4 — Flux time-series (field whitelist) | | `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 `/api/*` is gated by the passkey session (mutations also need the CSRF header).
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 <a name="authorise-pillar"></a>The authorise pillar drives the enrol-broker's
(its own admin mTLS CN) and births the inventory record. The broker stays the admin plane over **mTLS** (the manager is a service client, gated by the dedicated
enrolment state machine + step-ca signer (v1 "alongside" fold-in). 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. **Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2.
`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down. `Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down.
@@ -86,9 +115,14 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink`
| Var | Default | Notes | | Var | Default | Notes |
|---|---|---| |---|---|---|
| `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener | | `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener |
| `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | admin server cert/key (mTLS) | | `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | UI server cert/key (server-auth; **LE DNS-01**, hot-reloaded) |
| `FLEET_MGR_ADMIN_CLIENT_CA` | — | **dedicated admin CA** (NOT the fleet CA) | | `FLEET_MGR_RP_ID` | — | WebAuthn RP ID = UI hostname (`diskless-server.bitasylum.org`) |
| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, no client-cert — **dev only** | | `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_ADDR` | `127.0.0.1:9600` | gRPC agent listener |
| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key | | `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. | | `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 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 whitelist** + hex-serial guard (no caller input reaches the query raw). Unit
tests for point construction, query building, and unsafe-input rejection. 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).
+14 -30
View File
@@ -1,10 +1,7 @@
// admin.go — the admin JSON API (the SPA's backend) served on the mTLS admin // admin.go — the session-gated admin JSON API (the SPA's backend). Mounted under
// plane. Checkpoint 1 covers the MONITOR read surface + LIFECYCLE desired-state; // /api/ behind requireSession; browser identity is the WebAuthn passkey session
// the AUTHORISE routes (enrolment approve/deny) land in checkpoint 3 and the // (session.go), so every mutating action is attributed to the signed-in admin
// InfluxDB-backed metrics routes in checkpoint 4. // (adminName), not a client cert.
//
// Every mutating action is attributed to the admin's mTLS client-cert CN
// (adminCN), the same audit convention the broker uses.
package main package main
import ( import (
@@ -16,12 +13,11 @@ import (
"time" "time"
) )
func (s *server) routes(mux *http.ServeMux) { // apiRoutes registers the session-gated admin API (mounted under /api/ behind
// Liveness (no auth-sensitive data; also reachable in insecure dev mode). // requireSession). The public liveness + WebAuthn ceremonies live in authRoutes.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { func (s *server) apiRoutes(mux *http.ServeMux) {
w.Header().Set("Content-Type", "text/plain") // Session probe (lets the SPA resume + recover its CSRF token on reload).
_, _ = w.Write([]byte("ok\n")) mux.HandleFunc("GET /api/whoami", s.handleWhoami)
})
// MONITOR — fleet overview + per-host detail. // MONITOR — fleet overview + per-host detail.
mux.HandleFunc("GET /api/hosts", s.handleListHosts) 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()) writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error())
return return
} }
// Provenance: the client-cert CN is authoritative; ignore any body-supplied value. // Provenance: the signed-in admin is authoritative; ignore any body value.
d.UpdatedBy = adminCN(r) d.UpdatedBy = adminName(r)
h, ok, err := s.store.SetDesired(serial, d) h, ok, err := s.store.SetDesired(serial, d)
if err != nil { 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 // handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS
// the inventory record from the returned enrolment + the optional binding the // 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. // (the human at the SPA), not the broker's view of the manager service.
func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) { func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
if s.broker == nil { if s.broker == nil {
@@ -131,13 +127,13 @@ func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
host := &Host{ host := &Host{
Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash}, Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash},
Binding: binding, 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 { if err := s.store.Upsert(host); err != nil {
writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error()) writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error())
return 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) born, _ := s.store.Get(e.CertSerial)
writeJSON(w, http.StatusOK, born) writeJSON(w, http.StatusOK, born)
@@ -190,18 +186,6 @@ func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 ------------------------------------------------ // ---- JSON response helpers ------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) { func writeJSON(w http.ResponseWriter, code int, v any) {
+131
View File
@@ -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")
}
}
+177
View File
@@ -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})
}
+2 -2
View File
@@ -43,7 +43,7 @@ func TestAuthoriseApproveBirthsInventory(t *testing.T) {
store, _ := newJSONStore("") store, _ := newJSONStore("")
s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}} s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}}
mux := http.NewServeMux() mux := http.NewServeMux()
s.routes(mux) s.apiRoutes(mux)
// pending proxies through // pending proxies through
pw := httptest.NewRecorder() pw := httptest.NewRecorder()
@@ -93,7 +93,7 @@ func TestAuthoriseDisabledWhenNoBroker(t *testing.T) {
store, _ := newJSONStore("") store, _ := newJSONStore("")
s := &server{store: store, broker: nil} // authorise not configured s := &server{store: store, broker: nil} // authorise not configured
mux := http.NewServeMux() mux := http.NewServeMux()
s.routes(mux) s.apiRoutes(mux)
rw := httptest.NewRecorder() rw := httptest.NewRecorder()
mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
+153
View File
@@ -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] <name>
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] <name>")
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://<admin-ui-origin>"
}
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
}
+13 -3
View File
@@ -14,6 +14,7 @@ module fleet-manager
go 1.25.0 go 1.25.0
require ( require (
github.com/go-webauthn/webauthn v0.17.4
github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/influxdata/influxdb-client-go/v2 v2.14.0
google.golang.org/grpc v1.82.0 google.golang.org/grpc v1.82.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
@@ -21,11 +22,20 @@ require (
require ( require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 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/google/uuid v1.6.0 // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
github.com/oapi-codegen/runtime v1.0.0 // indirect github.com/oapi-codegen/runtime v1.0.0 // indirect
golang.org/x/net v0.53.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
golang.org/x/sys v0.43.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect
golang.org/x/text v0.36.0 // 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 google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
) )
+32 -8
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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/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 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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= 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/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 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= 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= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
+118 -40
View File
@@ -1,27 +1,27 @@
// fleet-manager — server-side control plane for the diskless kiosk fleet. // fleet-manager — server-side control plane for the diskless kiosk fleet.
// //
// Two planes, mirroring the enrol-broker's two-listener pattern but with distinct // Two planes with DISTINCT trust models:
// trust anchors (see docs/tpm-enrolment-design.md §5 "identity"):
// //
// - ADMIN plane (this checkpoint): HTTPS + mTLS verified by the DEDICATED admin // - ADMIN plane: HTTPS (server-auth TLS) + browser identity via WebAuthn/passkey
// CA (admin-pki.sh) — NOT the fleet CA — so an enrolled host cert can never // (YubiKey) → server-side session. Serves the JSON API + the SPA to admins.
// reach approve/deny/lifecycle. Serves the JSON API and (later) the SPA to // (manager→broker is separately mTLS; that's service-to-service.)
// admin browsers. // - AGENT plane: gRPC + mTLS verified by the FLEET CA — the host's reused TPM
// - AGENT plane (checkpoint 2): gRPC + mTLS verified by the FLEET CA — the // identity cert. Bidi stream: health/metrics up, desired-state down.
// 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 package main
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/go-webauthn/webauthn/webauthn"
) )
type config struct { type config struct {
@@ -29,7 +29,6 @@ type config struct {
adminInsecure bool adminInsecure bool
adminCert string adminCert string
adminKey string adminKey string
adminClientCA string // dedicated admin CA (verifies admin browser certs)
agentAddr string agentAddr string
agentCert string agentCert string
@@ -45,6 +44,14 @@ type config struct {
// InfluxDB (the manager is the only reader/writer; token is server-side only). // InfluxDB (the manager is the only reader/writer; token is server-side only).
influx influxConfig 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 inventoryPath string
seed bool seed bool
hostnqnBase string hostnqnBase string
@@ -56,7 +63,6 @@ func loadConfig() config {
adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true", adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true",
adminCert: env("FLEET_MGR_ADMIN_CERT", ""), adminCert: env("FLEET_MGR_ADMIN_CERT", ""),
adminKey: env("FLEET_MGR_ADMIN_KEY", ""), adminKey: env("FLEET_MGR_ADMIN_KEY", ""),
adminClientCA: env("FLEET_MGR_ADMIN_CLIENT_CA", ""),
agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"), agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"),
agentCert: env("FLEET_MGR_AGENT_CERT", ""), agentCert: env("FLEET_MGR_AGENT_CERT", ""),
@@ -75,6 +81,13 @@ func loadConfig() config {
bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"), 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"), inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
seed: env("FLEET_MGR_SEED", "") == "true", seed: env("FLEET_MGR_SEED", "") == "true",
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"), 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 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 broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
influx *influxSink // metrics gateway; nil if InfluxDB 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() { func main() {
// Subcommand: `fleet-manager register-admin <name>` 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) log.SetFlags(log.LstdFlags | log.LUTC)
cfg := loadConfig() cfg := loadConfig()
@@ -144,12 +170,42 @@ func main() {
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)") log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
} }
mux := http.NewServeMux() // Admin browser auth = WebAuthn/passkey (secure mode only). Sets up the RP,
s.routes(mux) // 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 <name>` 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{ srv := &http.Server{
Addr: cfg.adminAddr, Addr: cfg.adminAddr,
Handler: mux, Handler: root,
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
@@ -157,8 +213,7 @@ func main() {
} }
if cfg.adminInsecure { if cfg.adminInsecure {
log.Printf("WARNING: admin plane INSECURE (plaintext, no client-cert auth) on %s — dev/lab only", cfg.adminAddr) log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr)
log.Printf("fleet-manager admin (insecure) listening on %s", cfg.adminAddr)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }
@@ -167,41 +222,64 @@ func main() {
log.Fatalf("admin TLS: %v", err) log.Fatalf("admin TLS: %v", err)
} }
srv.TLSConfig = tlsCfg 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 log.Fatal(srv.ListenAndServeTLS("", "")) // certs are in TLSConfig
} }
// adminTLS builds the mTLS server config: our server cert + REQUIRE a client cert // adminTLS builds the admin plane's SERVER-AUTH TLS. Browser identity is a passkey
// signed by the dedicated admin CA. Reusing the fleet CA here would let any host // (WebAuthn), not a client cert, so there is no ClientCAs/ClientAuth here — just
// cert authenticate to the admin plane — privilege escalation — so it is a hard // our server cert. (manager→broker mTLS is separate; see broker.go.)
// error to omit the admin client CA in secure mode. //
// 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) { func adminTLS(cfg config) (*tls.Config, error) {
if cfg.adminCert == "" || cfg.adminKey == "" { 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)") 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 == "" { rl := &certReloader{certFile: cfg.adminCert, keyFile: cfg.adminKey}
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)") if _, err := rl.GetCertificate(nil); err != nil { // fail fast on a bad/missing cert
}
cert, err := tls.LoadX509KeyPair(cfg.adminCert, cfg.adminKey)
if err != nil {
return nil, err 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{ return &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert}, GetCertificate: rl.GetCertificate,
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool,
}, nil }, 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 --------------------------------------------------------- // ---- tiny helpers ---------------------------------------------------------
func env(k, def string) string { func env(k, def string) string {
+149
View File
@@ -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"
}
+199
View File
@@ -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},
})
}