Files
diskless-workstation/serving/fleet-manager/cli.go
T
Audiobuzz 2b3708c718 fleet-manager: register-admin link uses the first of multiple RP origins
Follow-up to comma-separated RP_ORIGIN support: the enrolment URL builder used
the raw origin string, which would embed the whole comma list. Take the first
origin for the link.
2026-07-05 00:24:10 +00:00

166 lines
5.3 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"
"errors"
"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 errors.Is(err, os.ErrNotExist) {
// The key is created by the admin bootstrap — a fresh secure-mode start
// before any admin exists lands here. Point at the fix, not the errno.
return nil, fmt.Errorf("enrol key %s not found — bootstrap the admin plane first: `fleet-manager register-admin <name>` (or set FLEET_MGR_ADMIN_INSECURE=true for dev)", 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
}
// RP_ORIGIN may list several origins (comma-separated); the enrolment link
// uses the first one.
base := *origin
if i := strings.IndexByte(base, ','); i >= 0 {
base = base[:i]
}
base = strings.TrimRight(strings.TrimSpace(base), "/")
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
}