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