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