Files
Audiobuzz c40dd5ac93 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.
2026-07-04 04:26:30 +00:00

104 lines
3.6 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// mockBroker stands in for the enrol-broker admin plane. It asserts the manager
// asks for JSON on approve, and returns a decided Enrolment.
func mockBroker(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /pending", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []brokerEnrolment{{
ID: "enr-1", State: "pending", Fingerprint: "amber-castle-river-delta-orbit-nova",
CN: "fleet-host", PubKeyHash: "pk123", Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"},
}})
})
mux.HandleFunc("POST /approve/{id}", func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
t.Errorf("approve did not send Accept: application/json (got %q)", r.Header.Get("Accept"))
}
writeJSON(w, http.StatusOK, brokerEnrolment{
ID: r.PathValue("id"), State: "approved",
Fingerprint: "amber-castle-river-delta-orbit-nova", CN: "fleet-host",
CertSerial: "beef0009", Hostnqn: "nqn.2026-06.org.example.fleet:host-beef0009",
Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"},
})
})
mux.HandleFunc("POST /deny/{id}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
return httptest.NewServer(mux)
}
func TestAuthoriseApproveBirthsInventory(t *testing.T) {
bs := mockBroker(t)
defer bs.Close()
store, _ := newJSONStore("")
s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}}
mux := http.NewServeMux()
s.apiRoutes(mux)
// pending proxies through
pw := httptest.NewRecorder()
mux.ServeHTTP(pw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
if pw.Code != http.StatusOK {
t.Fatalf("pending -> %d", pw.Code)
}
var plist struct {
Pending []brokerEnrolment `json:"pending"`
}
json.Unmarshal(pw.Body.Bytes(), &plist)
if len(plist.Pending) != 1 || plist.Pending[0].ID != "enr-1" {
t.Fatalf("pending list = %+v", plist.Pending)
}
// approve with a binding -> inventory record born
aw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/enrol/enr-1/approve",
strings.NewReader(`{"asset_tag":"KIOSK-9","switch_port":"sw1/9","location":"Bay 3"}`))
mux.ServeHTTP(aw, req)
if aw.Code != http.StatusOK {
t.Fatalf("approve -> %d: %s", aw.Code, aw.Body.String())
}
h, ok := store.Get("beef0009")
if !ok {
t.Fatal("inventory record was not born on approve")
}
if h.Identity.Hostnqn != "nqn.2026-06.org.example.fleet:host-beef0009" {
t.Errorf("hostnqn = %q", h.Identity.Hostnqn)
}
if h.Identity.MAC != "de:ad:be:ef:00:09" || h.Identity.EKHash != "ek-abc" {
t.Errorf("identity context not carried: %+v", h.Identity)
}
if h.Binding.AssetTag != "KIOSK-9" || h.Binding.SwitchPort != "sw1/9" || h.Binding.Location != "Bay 3" {
t.Errorf("binding not recorded: %+v", h.Binding)
}
if h.Enrolled.Fingerprint != "amber-castle-river-delta-orbit-nova" {
t.Errorf("fingerprint = %q", h.Enrolled.Fingerprint)
}
if h.Enrolled.Approver != "dev-insecure" { // no client cert in the test request
t.Errorf("approver = %q, want the manager admin CN", h.Enrolled.Approver)
}
}
func TestAuthoriseDisabledWhenNoBroker(t *testing.T) {
store, _ := newJSONStore("")
s := &server{store: store, broker: nil} // authorise not configured
mux := http.NewServeMux()
s.apiRoutes(mux)
rw := httptest.NewRecorder()
mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when no broker, got %d", rw.Code)
}
}