slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import (
|
||||
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
@@ -41,7 +42,7 @@ import (
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.15.0"
|
||||
var version = "0.16.0"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -245,7 +246,9 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
desiredProvider := reconcile.NewCachingProvider()
|
||||
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
|
||||
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
|
||||
loop.SetEnvelopeObserver(desired.NewSyncer(client, desiredProvider, logger))
|
||||
desiredSyncer := desired.NewSyncer(client, desiredProvider, logger)
|
||||
// The signed-jobs runner (slice 10B) is wired as a SECOND envelope observer below (after the
|
||||
// gate is built) — when the heartbeat flags pending signed ops, it fetches + verifies + executes.
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -287,6 +290,18 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
}
|
||||
gate := reconcile.NewGate(verifier, cfg.Hub.HostID, reconcile.SlogAudit{Logger: logger}, logger)
|
||||
|
||||
// Signed-jobs runner (slice 10B): the consumer of the hub's signed-jobs queue. On a heartbeat
|
||||
// that flags pending signed ops, it fetches each opaque blob, runs it through the gate (the
|
||||
// LOCKED authz pipeline: pinned-key SSHSIG → namespace → allow-list → crypto → host → time →
|
||||
// durable nonce-burn) and, only on all-pass, hands the verified op to the storage-WIPE executor
|
||||
// — which re-resolves the DURABLE device id + re-inspects (8C) before mkfs. This closes the 8C
|
||||
// data-bearing `pending_signature` gap. With no signers pinned the gate refuses every job
|
||||
// (pending_signature) and nothing executes — correct. Wired as a second envelope observer
|
||||
// alongside the desired-state syncer.
|
||||
wipeExec := signedjobs.NewWipeExecutor(hostOps, logger)
|
||||
jobsRunner := signedjobs.NewRunner(client, gate, wipeExec, cfg.Hub.HostID, logger)
|
||||
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
|
||||
|
||||
// Storage watchdog (slice 5): the third daemon goroutine. Fast-polls the known target
|
||||
// set for attached↔disconnected transitions → debounced out-of-band report; and, on a
|
||||
// known mount-backed target's device returning unmounted, dispatches a benign re-mount
|
||||
@@ -510,6 +525,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
||||
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
|
||||
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
|
||||
HostMetrics: collector,
|
||||
HostID: cfg.Hub.HostID, // slice 10B: anti-retarget host in the data-bearing-format pending-op
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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")
|
||||
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", "", "for storage_wipe: the DURABLE device id (byid:…|byuuid:…)")
|
||||
fstype = flag.String("fstype", "ext4", "for storage_wipe: the filesystem to mkfs after wipe")
|
||||
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 wipe convenience flags.
|
||||
params := strings.TrimSpace(*paramsRaw)
|
||||
if params == "" {
|
||||
if *op == "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)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// signWithSSHKeygen signs `message` with `ssh-keygen -Y sign -n <namespace>`, 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
|
||||
}
|
||||
Reference in New Issue
Block a user