// 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 }