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.
166 lines
5.3 KiB
Go
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
|
|
}
|