// 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 ` (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] 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] ") 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://" } 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 } // runRegisterHost implements: // // fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] // // It seeds or updates an inventory record for a host enrolled OUT OF BAND (the // standalone bench broker, or a manager inheriting an existing fleet), so the // agent stream — which the manager refuses for an unknown serial (agent.go // "host is not enrolled") — is accepted. Normally the record is born by the // authorise-approve flow; this is the manual import path. It writes through the // real jsonStore (atomic save), so it must point at the SAME inventory file the // service uses (FLEET_MGR_INVENTORY, default /data in the container). Run it via // `docker compose run --rm fleet-manager register-host ...`. func runRegisterHost(args []string) int { fs := flag.NewFlagSet("register-host", flag.ContinueOnError) name := fs.String("name", "", "starship name (dnsmasq fleet-namer join; advisory)") inv := fs.String("inventory", env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"), "inventory file (must match the service's FLEET_MGR_INVENTORY)") nqn := fs.String("hostnqn", "", "host NQN (default: $HOSTNQN_BASE + serial)") if err := fs.Parse(args); err != nil { return 2 } serial := strings.ToLower(strings.TrimSpace(fs.Arg(0))) if !isHexSerial(serial) { fmt.Fprintln(os.Stderr, "usage: fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] ") return 2 } store, err := newJSONStore(*inv) if err != nil { fmt.Fprintf(os.Stderr, "inventory %s: %v\n", *inv, err) return 1 } hostnqn := *nqn if hostnqn == "" { hostnqn = env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-") + serial } now := time.Now().UTC() h, existed := store.Get(serial) if !existed { h = &Host{Identity: Identity{CertSerial: serial}, FirstSeen: now} } h.Identity.CertSerial = serial h.Identity.Hostnqn = hostnqn if *name != "" { h.Identity.StarshipName = *name } if h.Enrolled.At.IsZero() { h.Enrolled = Enrolled{Approver: "register-host CLI", At: now} } h.LastSeen = now if err := store.Upsert(h); err != nil { fmt.Fprintf(os.Stderr, "write inventory %s: %v\n", *inv, err) return 1 } verb := "registered" if existed { verb = "updated" } fmt.Printf("%s host %s (starship=%q hostnqn=%s) in %s\n", verb, serial, h.Identity.StarshipName, hostnqn, *inv) return 0 } // isHexSerial reports whether s is a non-empty lowercase-hex cert serial (the // inventory key form). Serials can be odd-length hex (big-int %x), so we validate // characters rather than decoding. func isHexSerial(s string) bool { if s == "" { return false } for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { return false } } return true }