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