Files
diskless-workstation/serving/fleet-manager/cli.go
T
Audiobuzz c40dd5ac93 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.
2026-07-04 04:26:30 +00:00

154 lines
4.8 KiB
Go

// cli.go — the `register-admin` bootstrap subcommand and the one-time enrolment
// token it mints.
//
// WebAuthn registration is a BROWSER ceremony (the credential is born on the
// YubiKey via navigator.credentials.create) — a CLI can't do it directly. So the
// CLI's job is to AUTHORISE an enrolment: it mints a short-lived HMAC-signed token
// (over a shared enrol key on the mgmt host) and prints an enrolment URL. The
// browser completes the ceremony; the register endpoint verifies the token. This
// keeps registration closed (no anyone-can-register endpoint) while the actual
// key material never leaves the YubiKey. Single-use is enforced by the credStore
// jti ledger.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type enrollClaims struct {
Name string
JTI string
Exp int64
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
func hmacSum(key []byte, msg string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(msg))
return m.Sum(nil)
}
// mintEnrollToken -> base64url(name|exp|jti) "." base64url(hmac).
func mintEnrollToken(key []byte, name string, ttl time.Duration) (string, error) {
jb := make([]byte, 12)
if _, err := rand.Read(jb); err != nil {
return "", err
}
jti := hex.EncodeToString(jb)
exp := time.Now().Add(ttl).Unix()
msg := name + "|" + strconv.FormatInt(exp, 10) + "|" + jti
return b64([]byte(msg)) + "." + b64(hmacSum(key, msg)), nil
}
func verifyEnrollToken(key []byte, token string) (enrollClaims, error) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return enrollClaims{}, fmt.Errorf("malformed token")
}
msg, err := unb64(parts[0])
if err != nil {
return enrollClaims{}, fmt.Errorf("bad payload")
}
sig, err := unb64(parts[1])
if err != nil {
return enrollClaims{}, fmt.Errorf("bad signature encoding")
}
if subtle.ConstantTimeCompare(sig, hmacSum(key, string(msg))) != 1 {
return enrollClaims{}, fmt.Errorf("bad signature")
}
f := strings.SplitN(string(msg), "|", 3)
if len(f) != 3 {
return enrollClaims{}, fmt.Errorf("bad claims")
}
exp, _ := strconv.ParseInt(f[1], 10, 64)
if time.Now().Unix() > exp {
return enrollClaims{}, fmt.Errorf("token expired")
}
return enrollClaims{Name: f[0], JTI: f[2], Exp: exp}, nil
}
// loadEnrollKey reads the shared enrol key (>=32 bytes). Used by the SERVICE — it
// must already exist (the same key the CLI mints with).
func loadEnrollKey(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("no enrol key file configured (FLEET_MGR_ENROLL_KEY_FILE)")
}
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(b) < 32 {
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
}
return b, nil
}
// loadOrCreateEnrollKey is the CLI's variant: it generates the key on first use.
func loadOrCreateEnrollKey(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("set -key or FLEET_MGR_ENROLL_KEY_FILE")
}
if b, err := os.ReadFile(path); err == nil {
if len(b) < 32 {
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
}
return b, nil
} else if !os.IsNotExist(err) {
return nil, err
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "note: generated a new enrol key at %s (mount the SAME file into the service)\n", path)
return key, nil
}
// runRegisterAdmin implements: fleet-manager register-admin [-key F] [-origin URL] <name>
func runRegisterAdmin(args []string) int {
fs := flag.NewFlagSet("register-admin", flag.ContinueOnError)
keyFile := fs.String("key", env("FLEET_MGR_ENROLL_KEY_FILE", ""), "shared enrol key file")
origin := fs.String("origin", env("FLEET_MGR_RP_ORIGIN", ""), "admin UI origin (for the enrolment URL)")
ttl := fs.Duration("ttl", 10*time.Minute, "enrolment token validity")
if err := fs.Parse(args); err != nil {
return 2
}
name := fs.Arg(0)
if name == "" {
fmt.Fprintln(os.Stderr, "usage: fleet-manager register-admin [-key F] [-origin URL] [-ttl D] <name>")
return 2
}
key, err := loadOrCreateEnrollKey(*keyFile)
if err != nil {
fmt.Fprintf(os.Stderr, "enrol key: %v\n", err)
return 1
}
tok, err := mintEnrollToken(key, name, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "mint: %v\n", err)
return 1
}
base := strings.TrimRight(*origin, "/")
if base == "" {
base = "https://<admin-ui-origin>"
}
fmt.Printf("Enrolment link for %q (valid %s) — open on the mgmt network, then tap the YubiKey:\n\n %s/#/enrol?token=%s\n\n", name, ttl.String(), base, tok)
return 0
}