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