// Command felhom-opsign is the OPERATOR's offline signing CLI for destructive ops (slice 10B, // decision (a): offline operator key + signing CLI, hardware-key-ready). // // It constructs the canonical OpBlob bytes by REUSING internal/authz.CanonicalBlob — the exact // production path the agent's verifier authenticates over — so signer and verifier can never drift. // It signs that canonical message with the operator's key via `ssh-keygen -Y sign`, which makes it // hardware-ready: an `sk-`/YubiKey key works through ssh-keygen unchanged. The output is a signed-op // envelope { op_blob_b64, sig_armored } to hand to the hub's jobs queue (optionally uploaded with // --upload). This CLI touches ONLY the operator's signing key — never the hub's or agent's keys. // // Example — sign a data-bearing wipe (closing the 8C pending_signature gap): // // felhom-opsign -op storage_wipe -host demo-felhom-01 \ // -durable-id byid:wwn-0x5000c500abcd1234 -fstype ext4 \ // -key-id ops-key-1 -key ~/.ssh/felhom_op_ed25519 -ttl 30m package main import ( "bytes" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/authz" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "felhom-opsign:", err) os.Exit(1) } } func run() error { var ( op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update") host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)") guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)") keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)") paramsRaw = flag.String("params", "", "op params as JSON (overrides -durable-id/-fstype)") durableID = flag.String("durable-id", "", "storage_wipe: the DURABLE device id (byid:…|byuuid:…); decommission: the drive's STORAGE durable-id (e.g. uuid:)") fstype = flag.String("fstype", "ext4", "for storage_wipe: the filesystem to mkfs after wipe") agentVer = flag.String("agent-version", "", "for agent_update: the target agent version (e.g. 0.70.1)") sha256Hex = flag.String("sha256", "", "for agent_update: the pinned lowercase-hex sha256 of the target binary") keyFile = flag.String("key", "", "operator signing key (ssh private key / sk- key handle) for ssh-keygen -Y sign") ttl = flag.Duration("ttl", 30*time.Minute, "validity window from now (issued_at..expires_at)") nonce = flag.String("nonce", "", "explicit nonce (default: a fresh 128-bit random nonce)") uploadURL = flag.String("upload", "", "optional hub base URL to POST the signed op to the jobs queue") hubKey = flag.String("hub-key", "", "hub bearer key for --upload (operator's hub key — NOT a signing key)") out = flag.String("o", "", "write the envelope JSON to this file (default: stdout)") ) flag.Parse() if *op == "" || *host == "" || *keyID == "" || *keyFile == "" { return fmt.Errorf("-op, -host, -key-id and -key are required") } // Params: explicit JSON, or built from the convenience flags. params := strings.TrimSpace(*paramsRaw) if params == "" { switch *op { case "storage_wipe": if *durableID == "" { return fmt.Errorf("storage_wipe needs -durable-id (byid:…|byuuid:…) — a path-only binding is refused by the agent") } pj, _ := json.Marshal(map[string]string{"durable_id": *durableID, "fstype": *fstype}) params = string(pj) case "decommission": // Decommission binds to the drive's STORAGE durable-id (the watchdog's key, e.g. // "uuid:"), NOT the device-level byid:/byuuid: scheme. The agent records this // id into the intent map, so it must match what the storage observer reports. if *durableID == "" { return fmt.Errorf("decommission needs -durable-id (the drive's storage durable-id, e.g. uuid:)") } pj, _ := json.Marshal(map[string]string{"durable_id": *durableID}) params = string(pj) case "agent_update": // The agent downloads the binary for -agent-version and verifies it against -sha256. // The sha is the ONLY integrity root, so both are mandatory and the sha is strict-validated. if *agentVer == "" || *sha256Hex == "" { return fmt.Errorf("agent_update needs -agent-version and -sha256 (the pinned binary hash)") } if !isHex64(*sha256Hex) { return fmt.Errorf("agent_update -sha256 must be 64 lowercase hex chars (got %d)", len(*sha256Hex)) } pj, _ := json.Marshal(map[string]string{"version": *agentVer, "sha256": *sha256Hex}) params = string(pj) default: params = "{}" } } n := *nonce if n == "" { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Errorf("generating nonce: %w", err) } n = hex.EncodeToString(b[:]) } now := time.Now().UTC() issued := now expires := now.Add(*ttl) // Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over. blob, err := authz.CanonicalBlob(*op, *host, *guest, *keyID, n, params, issued, expires) if err != nil { return err } sigArmored, err := signWithSSHKeygen(blob, *keyFile) if err != nil { return err } env := map[string]string{ "op_blob_b64": base64.StdEncoding.EncodeToString(blob), "sig_armored": sigArmored, } envJSON, _ := json.Marshal(env) // Emit the envelope (stdout or file). Also print the human summary to stderr (never the key). fmt.Fprintf(os.Stderr, "signed: op=%s host=%s guest=%q key_id=%s nonce=%s expires=%s\n", *op, *host, *guest, *keyID, n, expires.Format(time.RFC3339)) if *out != "" { if err := os.WriteFile(*out, envJSON, 0o600); err != nil { return err } fmt.Fprintln(os.Stderr, "wrote envelope to", *out) } else { fmt.Println(string(envJSON)) } if *uploadURL != "" { if *hubKey == "" { return fmt.Errorf("--upload needs --hub-key (the operator's hub bearer key)") } if err := upload(*uploadURL, *hubKey, *host, envJSON); err != nil { return fmt.Errorf("upload to hub: %w", err) } fmt.Fprintln(os.Stderr, "uploaded signed op to the hub jobs queue") } return nil } // isHex64 reports whether s is exactly 64 lowercase hex chars (a sha256 hex digest). func isHex64(s string) bool { if len(s) != 64 { return false } for _, c := range s { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } // signWithSSHKeygen signs `message` with `ssh-keygen -Y sign -n `, the hardware-ready // path (sk-/YubiKey keys work unchanged). It writes the message to a temp file, runs ssh-keygen, // and reads the armored SSHSIG it produces. The namespace is the agent's FIXED domain separator. func signWithSSHKeygen(message []byte, keyFile string) (string, error) { dir, err := os.MkdirTemp("", "felhom-opsign-") if err != nil { return "", err } defer os.RemoveAll(dir) msgPath := filepath.Join(dir, "op.blob") if err := os.WriteFile(msgPath, message, 0o600); err != nil { return "", err } cmd := exec.Command("ssh-keygen", "-Y", "sign", "-n", authz.Namespace, "-f", keyFile, msgPath) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("ssh-keygen -Y sign: %w: %s", err, strings.TrimSpace(stderr.String())) } sig, err := os.ReadFile(msgPath + ".sig") if err != nil { return "", fmt.Errorf("reading produced signature: %w", err) } return string(sig), nil } // upload POSTs the signed-op envelope to the hub's jobs queue (POST /api/v1/admin/hosts/{id}/jobs). // The queued blob is base64(envelope JSON); the hub stores it opaquely (it cannot forge or open it). func upload(baseURL, hubKey, hostID string, envJSON []byte) error { body, _ := json.Marshal(map[string]string{ "blob_b64": base64.StdEncoding.EncodeToString(envJSON), }) // 10A's enqueue lives under /admin/hosts/{id}/jobs (operator/global key). The queued blob is // base64(envelope JSON); the hub stores it opaquely. url := strings.TrimRight(baseURL, "/") + "/api/v1/admin/hosts/" + hostID + "/jobs" req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+hubKey) req.Header.Set("Content-Type", "application/json") hc := &http.Client{Timeout: 15 * time.Second} resp, err := hc.Do(req) if err != nil { return err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } return nil }