`go fix` modernised the comma-origin loop to the Go 1.24+ iterator idiom (strings.SplitSeq). No behaviour change.
210 lines
6.2 KiB
Go
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.SplitSeq(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,
|
|
})
|
|
}
|