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
+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})
}