Files
diskless-workstation/serving/fleet-manager/webauthn.go
T
Audiobuzz 76446edd75 fleet-manager: allow multiple WebAuthn origins (comma-separated RP_ORIGIN)
Passkeys bind to the exact scheme://host:port the browser used, so a single
configured origin fails "Error validating origin" when reached via a different
form (e.g. :9500 direct vs a 443 proxy). Split FLEET_MGR_RP_ORIGIN on commas so
several exact origins can be allowed. Single-value config is unchanged.
2026-07-04 11:07:15 +00:00

210 lines
6.2 KiB
Go

// 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"
"strings"
"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)")
}
// FLEET_MGR_RP_ORIGIN may list several exact origins (comma-separated) — e.g.
// the direct :9500 form and a 443-proxy form — since passkeys bind to the
// exact scheme://host:port the browser used. Each must match a real origin.
var origins []string
for _, o := range strings.Split(cfg.rpOrigin, ",") {
if o = strings.TrimSpace(o); o != "" {
origins = append(origins, o)
}
}
return webauthn.New(&webauthn.Config{
RPID: cfg.rpID,
RPDisplayName: cfg.rpDisplayName,
RPOrigins: origins,
})
}