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