diff --git a/CHANGELOG.md b/CHANGELOG.md index 388078d..33eccf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.17.0 — slice 10C: escrow consumption (productionize the spike) (2026-06-10) + +Turns the throwaway 10C spike harness into a real, tested **`Consume`** path: recover the PBS key +`K` from an R-wrapped escrow blob, **gate it on the expected fingerprint**, and install it for the +restore. The spike already proved the crypto + real-data restore; this bakes its findings into +production code. **Agent-only** — 10C *reads* the four inputs as parameters (so it stays +standalone-testable); 10D sources blob/fingerprint/PBS-connection from the hub and prompts for R. +**Zero-knowledge holds**: the hub serves everything except **R** (by hand from the customer), so a +hub compromise alone still can't decrypt. + +### Added +- **`escrow.Consume(ctx, blob, R, expectedFingerprint, keyDest)`** — the consumption contract: + 1. **Unwrap** the blob (a copy — F-C6: the input blob is read-only → a failed Consume is + **retryable**) with `R`; a **wrong R fails closed** at the scrypt KDF (F-C3) → a clear, + R-free error, **nothing written**. + 2. **Fingerprint gate (F-C4)** — `KeyFingerprint(recovered)` must equal the expected (the hub + knows it); a mismatch **fails fast + loud, no install, no restore attempted**. + 3. **Atomic install (F-C2)** at `keyDest` (`0600`, write-temp-sibling→rename); any failure leaves + **no partial install**. The recovered key lives only in a `0700` tempdir that is always removed. + **Secret discipline:** `R` and key bytes are never logged/persisted (only fingerprint prefixes); + `K` is never mutated. +- **`--selftest=escrow-consume`** (`-blob -fingerprint -keydest`, R via env `FELHOM_RECOVERY_CODE` + to keep it off the command line) — invokes the real `Consume` live (the spike's S3 via the + production path, not a harness). + +### Tests (non-hollow) +- valid → key installed + `KeyFingerprint(dest) == expected` + `0600` + blob byte-unchanged; + **wrong R** → error, **no file at dest**, blob unchanged; **fingerprint mismatch** → fail fast, + **no install** (the gate runs before any restore); input validation; format-tolerant fingerprint + compare (no empty-fingerprint gate-bypass); atomic-install permissions (integration tests gated to + a host with `proxmox-backup-client`). + ## v0.16.0 — slice 10B: operator-signed destructive completion (offline key + signing CLI) (2026-06-10) The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization diff --git a/REPORT.md b/REPORT.md index 862763c..e7a8e68 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,63 +1,50 @@ -# REPORT — slice 10B (agent half): operator-signed destructive completion (v0.16.0) (2026-06-10) +# REPORT — slice 10C: escrow consumption — productionize the spike (v0.17.0) (2026-06-10) > Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md). ## What was implemented -The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization. -Decision (a): **offline operator key + signing CLI**, hardware-key-ready. The signing key is NOT in -the hub and NOT in the agent. Concrete consumer: this **closes the 8C data-bearing-wipe -`pending_signature` gap**. Pairs with hub v0.10.0. +The 10C spike proved the crypto + real-data restore on a key-less box (recover `K` from `(blob, R)`, +restore real data, GO). 10C turns that throwaway harness into a real, tested **`Consume`** path. +**Agent-only — no hub change** (10C reads its four inputs as parameters; 10D sources blob/fingerprint/ +PBS-connection from the hub and prompts for R). -### The flow (end-to-end) -8C format of a data-bearing device → agent refuses `pending_signature` + **surfaces the bound op** -(durable id + host) → operator **signs offline** (`felhom-opsign`) → uploads to the hub jobs queue → -agent's signed-jobs runner **verifies** + **executes** the wipe (re-resolve durable id + re-inspect -8C → `mkfs`) → clears the job. +### `escrow.Consume(ctx, blob, R, expectedFingerprint, keyDest)` +The consumption contract, baking in the spike findings: +1. **Unwrap** the blob (operating on a copy — **F-C6**: the input blob is read-only, so a failed + Consume is **retryable**) with `R`; a **wrong R fails closed** at the scrypt KDF (**F-C3**) → a + clear, R-free error, **nothing written**. +2. **Fingerprint gate (F-C4)** — `KeyFingerprint(recovered)` must equal the expected (the hub knows + it); a mismatch **fails fast + loud, no install, no restore attempted** (the cheap correctness + check *before* any multi-GB restore). +3. **Atomic install (F-C2)** at `keyDest` (`0600`, write-temp-sibling → rename); any failure leaves + **no partial install**. The recovered key lives only in a `0700` tempdir that is always removed. -### `cmd/felhom-opsign` (new) — the offline signing CLI -- Builds the canonical `OpBlob` by **reusing `authz.CanonicalBlob`** (the exact bytes the verifier - authenticates over — signer/verifier can't drift) and signs with **`ssh-keygen -Y sign -n - felhom-op-v1`** (hardware-ready: `sk-`/YubiKey work unchanged). Output: `{op_blob_b64, - sig_armored}`; optional `--upload`. Touches only the operator's key. +**Secret discipline:** `R` and key bytes are never logged/persisted (only fingerprint prefixes); the +input blob is read-only; `K` is never mutated. **Zero-knowledge holds**: the hub serves the blob + +expected fingerprint + PBS connection; **R is by-hand from the customer, never the hub** — a hub +compromise alone still cannot decrypt. -### The verify-and-execute machinery -- **`internal/signedjobs.Runner`** — fetches each opaque job → runs the **gate** (the LOCKED authz - pipeline: pinned-key SSHSIG → namespace → allow-list by key MATERIAL → crypto over raw bytes → - host target → time window → **durable nonce-burn LAST**) → on all-pass hands the verified op to an - `Executor`. Order: **verify → burn nonce (durable) → execute → clear**. Rejects (forged/replayed/ - expired/retargeted/non-pinned) never reach the executor. -- **`WipeExecutor`** (the 8C consumer) — resolve the signed **durable** id → re-derive + **match** - (anti-retarget) → **re-inspect (8C classifier)** still-data-bearing → `mkfs`. A path-only binding, - a vanished/changed device, or a non-data-bearing target is refused **even with a valid signature**. -- **`internal/storage` durable identity** — `DeviceDurableID` / `ResolveDurableDevice` over the - world-readable udev symlinks (`byid:` wwn/serial, `byuuid:` fallback) — no privilege, no subprocess. -- **`authz.CanonicalBlob`** promoted to production. `hub.Client.Jobs`/`CompleteJob` + `MultiObserver`. - The 8C 403 now carries a `pending_op` (op + durable id + host) + a `felhom-opsign` hint. +### `--selftest=escrow-consume` +`-blob -fingerprint -keydest`, R via env `FELHOM_RECOVERY_CODE` (off the command line / `ps`). Invokes +the **real** `Consume` live — the spike's S3 via the production path, not a harness. -### Pinning / rotation -Operator pubkeys pinned via `authz.signers` (config, trusted path — NEVER hub-alone), **multi-key** -(KeyID selects, role-scoped) for backup/rotation without a flag-day. Unchanged verifier wiring; 10B -activates the execute path (the runner is the second `EnvelopeObserver`, runs on `HasSignedOps`). - -## Tests (real crypto, non-hollow — assert the op did/did NOT run) -- `signedjobs` over the **real** gate+verifier (in-Go minted SSHSIGs): valid → executor runs once + - job cleared; **replay / non-pinned / expired / retarget / forged / no-signer** → rejected, executor - never called; malformed cleared. -- `WipeExecutor`: valid → `mkfs`; path-only / durable-mismatch / device-gone / re-inspect-non-data- - bearing / not-probed → refused, `Format` not called. -- `storage` durable: wwn-preference, uuid-fallback, path-only+traversal refusal, round-trip, missing - (symlink tests gated to Linux). `go test ./...` green. +## Tests (non-hollow) +- valid → key installed + `KeyFingerprint(dest) == expected` + `0600` + **blob byte-unchanged**; + **wrong R** → error + **no file at dest** + blob unchanged; **fingerprint mismatch** → fail fast + + **no install** (the gate runs before any restore); input validation; format-tolerant fingerprint + compare (no empty-fingerprint gate-bypass); atomic-install. `go test ./...` green (integration + round-trip gated to a host with `proxmox-backup-client`). ## Versioning / docs -- Version `0.15.0 → 0.16.0`; `CHANGELOG.md`. Doc 03 §4 (signed path live) + §6 (8C wipe completes) + - §9 (10B done) updated. +- Version `0.16.0 → 0.17.0`; `CHANGELOG.md`. Doc 03 §8a (consumption implemented; zero-knowledge) + + §9 (10C done) updated. No cross-repo golden (no wire change). ## Out of scope (per the task) -- Other destructive executors (guest_destroy, decommission, **restore-overwrite → 10D**) reuse the - same gate+runner; their executors plug in per-slice. 10B ships the machinery + the storage-wipe. +- DR orchestration (re-enroll in restore mode, *which* guests, identity restore) → **10D**; + populating/serving the restore directive from the hub → **10D**. ## Pending -- **Live validation** on the demo: data-bearing wipe → `pending_signature` → sign offline with a real - operator key → hub queue → agent verifies + wipes; confirm replay + a non-pinned-key signature are - rejected. (Also validates the Linux-only durable-device tests + ssh-keygen interop.) +- **Live validation** on the demo: create a fresh escrow from the live `K` → `Consume` on a key-less + scratch → restore a real encrypted backup with the consumed key; **wrong R → clean failure, nothing + installed**; the live `K` stays byte-unchanged. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 47ec783..3d6a1e8 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -42,7 +42,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.16.0" +var version = "0.17.0" func main() { var ( @@ -62,6 +62,9 @@ func main() { custDomain string custName string custEmail string + blobPath string + expectedFP string + keyDest string showVersion bool ) flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") @@ -76,6 +79,9 @@ func main() { flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)") flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))") flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub") + flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file") + flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)") + flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)") flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap") flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: the customer domain to seed") flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)") @@ -126,6 +132,8 @@ func main() { })) case "escrow-create": os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload)) + case "escrow-consume": + os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest)) } } @@ -1123,6 +1131,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo return 0 } +// runSelftestEscrowConsume exercises the slice-10C production Consume path live: recover K from an +// R-wrapped blob, gate it on the expected fingerprint, install it at -keydest. R is taken BY HAND +// from the env var FELHOM_RECOVERY_CODE (kept off the command line / ps) — never a flag, never +// logged. The other three inputs (blob/fingerprint/keydest) are flags (10D sources blob+fingerprint +// from the hub directive). This is the real Consume code, not a throwaway harness. +func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath, expectedFP, keyDest string) int { + if blobPath == "" || expectedFP == "" || keyDest == "" { + fmt.Fprintln(os.Stderr, "selftest=escrow-consume requires -blob, -fingerprint and -keydest (R via env FELHOM_RECOVERY_CODE)") + return 2 + } + R := os.Getenv("FELHOM_RECOVERY_CODE") + if R == "" { + fmt.Fprintln(os.Stderr, "selftest=escrow-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input; never a flag/arg)") + return 2 + } + blob, err := os.ReadFile(blobPath) + if err != nil { + fmt.Fprintf(os.Stderr, "selftest=escrow-consume: reading blob %s: %v\n", blobPath, err) + return 1 + } + fmt.Printf("=== felhom-agent %s selftest=escrow-consume (blob=%s → %s) ===\n", version, blobPath, keyDest) + logger.Info("escrow: consuming R-wrapped escrow (Unwrap → fingerprint-gate → install)", + "blob_bytes", len(blob), "key_dest", keyDest) // R is NOT logged + if err := escrow.Consume(ctx, blob, R, expectedFP, keyDest); err != nil { + R = "" // drop the reference + fmt.Fprintln(os.Stderr, " [FAIL] consume:", err) // the error never contains R or key bytes + return 1 + } + R = "" // drop the reference promptly + fmt.Printf(" [OK] recovered key installed at %s (fingerprint-gated, 0600) — ready for the PBS restore\n", keyDest) + fmt.Println("=== selftest=escrow-consume OK ===") + return 0 +} + // escrowUploadRequest is the agent→hub wire shape for the opaque escrow blob. MUST stay in lockstep // with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and // never decrypts them. @@ -1519,8 +1561,10 @@ func (f *selftestFlag) Set(v string) error { f.mode = "provision" case "escrow-create": f.mode = "escrow-create" + case "escrow-consume": + f.mode = "escrow-consume" default: - return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create)", v) + return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume)", v) } return nil } diff --git a/internal/escrow/consume.go b/internal/escrow/consume.go new file mode 100644 index 0000000..7179f8d --- /dev/null +++ b/internal/escrow/consume.go @@ -0,0 +1,146 @@ +package escrow + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Consume is the slice-10C production escrow-consumption path — the throwaway 10C spike harness +// turned into a real, tested function. It recovers the PBS client encryption key `K` from an +// R-wrapped escrow `blob`, GATES it on the expected key fingerprint, and installs it at `keyDest` +// for the PBS restore path. It is the inverse of Create: Create wraps `K` under `R`; Consume +// unwraps it back. The DR orchestration around it (re-enroll in restore mode, source the directive +// from the hub, decide which guests to restore) is slice 10D — Consume takes its four inputs as +// PARAMETERS so it stays standalone-testable (exactly as the spike harness was). +// +// The four inputs (the consumption contract — spike findings): +// - blob: the opaque R-wrapped escrow blob (hub-served restore directive in 10D). +// - recoveryCode (R): BY HAND from the customer — NEVER hub-sourced (zero-knowledge holds: a +// hub compromise alone cannot decrypt). A secret: never logged/persisted. +// - expectedFingerprint: hub-served — the gate target (F-C4). +// - keyDest: where the PBS restore reads the key (`--keyfile` or the default +// `$XDG_CONFIG_HOME/proxmox-backup/encryption-key.json`). +// +// Order (spike F-C2/F-C3/F-C4/F-C6): Unwrap → fingerprint-gate → install. On ANY failure there is +// NO partial install (nothing at keyDest) and no key material leaks: the recovered key lives only +// in a 0600 tempdir that is always removed; `R` and key bytes are never logged (only fingerprint +// prefixes); the input `blob` is read-only, so a failed Consume is RETRYABLE. +func Consume(ctx context.Context, blob []byte, recoveryCode, expectedFingerprint, keyDest string) error { + if len(blob) == 0 { + return fmt.Errorf("escrow: Consume needs a non-empty blob") + } + if recoveryCode == "" { + return fmt.Errorf("escrow: Consume needs the recovery code (R)") + } + if expectedFingerprint == "" { + // The fingerprint gate is mandatory — without it we'd install whatever a (wrong-but-valid) + // unwrap produced. There is no "skip the gate" path. + return fmt.Errorf("escrow: Consume needs the expected key fingerprint (the gate target)") + } + if keyDest == "" { + return fmt.Errorf("escrow: Consume needs a key destination path") + } + + // Work in a private 0700 tempdir; RemoveAll always runs, so the recovered key never lingers. + work, err := os.MkdirTemp("", "felhom-consume-") + if err != nil { + return fmt.Errorf("escrow: tempdir: %w", err) + } + defer os.RemoveAll(work) + recovered := filepath.Join(work, "recovered.key") + + // Operate on a COPY of the blob (F-C6: the input blob is read-only → a failed Consume is + // retryable). Unwrap re-keys this copy in place. + if err := os.WriteFile(recovered, blob, 0o600); err != nil { + return fmt.Errorf("escrow: staging blob: %w", err) + } + + // 1. Unwrap with R. A WRONG R fails closed at the scrypt KDF (F-C3): nonzero exit, no key + // emitted. Surface a clear, R-free error; nothing is written to keyDest. + if err := Unwrap(ctx, recovered, recoveryCode); err != nil { + return fmt.Errorf("escrow: the recovery code did not unwrap the escrow (wrong recovery code, or a corrupt blob): %w", err) + } + + // 2. Fingerprint gate (F-C4) — the cheap correctness check, BEFORE any install or multi-GB + // restore. A mismatch means a wrong escrow/datastore (or a recovery code that, against the odds, + // produced a different key): fail fast + loud, no install. + recFP, err := KeyFingerprint(ctx, recovered) + if err != nil { + return fmt.Errorf("escrow: reading recovered key fingerprint: %w", err) + } + if !fingerprintsEqual(recFP, expectedFingerprint) { + return fmt.Errorf("escrow: recovered key fingerprint %s != expected %s — wrong escrow/datastore (not installing)", + shortFP(recFP), shortFP(expectedFingerprint)) + } + + // 3. Install atomically at keyDest (0600) — the only thing Consume writes outside its tempdir. + // A failure here leaves nothing partial at keyDest (the temp sibling is removed). + if err := installKey(recovered, keyDest); err != nil { + return fmt.Errorf("escrow: installing recovered key: %w", err) + } + return nil +} + +// installKey atomically places the recovered key at dest with 0600: write a sibling temp on the +// SAME filesystem, fsync, then rename (atomic). The parent dir is created 0700 if missing. On any +// error the temp is removed so dest is never left partial. +func installKey(recovered, dest string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return fmt.Errorf("key dir: %w", err) + } + b, err := os.ReadFile(recovered) + if err != nil { + return err + } + var rnd [8]byte + _, _ = rand.Read(rnd[:]) + tmp := dest + ".tmp-" + hex.EncodeToString(rnd[:]) + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dest); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// fingerprintsEqual compares two PBS key fingerprints tolerant of formatting (case + ':'/' ' +// separators) — the hub-stored value and `key show`'s output may differ only cosmetically. +func fingerprintsEqual(a, b string) bool { + return normFP(a) == normFP(b) && normFP(a) != "" +} + +func normFP(s string) string { + return strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "", "\n", "").Replace(strings.TrimSpace(s))) +} + +// shortFP returns a log-safe truncated fingerprint prefix (a key fingerprint is an identifier, not +// the key — but we still show only a prefix in logs/errors). +func shortFP(s string) string { + s = strings.TrimSpace(s) + if len(s) > 23 { // ~ first 8 colon-separated octets + return s[:23] + "…" + } + return s +} diff --git a/internal/escrow/consume_test.go b/internal/escrow/consume_test.go new file mode 100644 index 0000000..d8486b0 --- /dev/null +++ b/internal/escrow/consume_test.go @@ -0,0 +1,186 @@ +package escrow + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// ---- pure-unit tests (no proxmox-backup-client) ---------------------------------------------- + +func TestConsume_InputValidation(t *testing.T) { + ctx := context.Background() + dest := filepath.Join(t.TempDir(), "k.json") + cases := []struct { + name string + blob []byte + r, fp, dst string + }{ + {"empty blob", nil, "R", "fp", dest}, + {"empty R", []byte("x"), "", "fp", dest}, + {"empty fingerprint", []byte("x"), "R", "", dest}, + {"empty dest", []byte("x"), "R", "fp", ""}, + } + for _, c := range cases { + if err := Consume(ctx, c.blob, c.r, c.fp, c.dst); err == nil { + t.Errorf("%s: expected an error", c.name) + } + } + // no key written on a validation failure + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("a validation failure left a key behind") + } +} + +func TestFingerprintsEqual_NormalizesFormatting(t *testing.T) { + if !fingerprintsEqual("01:36:E9:FE", "0136e9fe") { + t.Error("case/colon-insensitive compare failed") + } + if fingerprintsEqual("", "") { + t.Error("two empty fingerprints must NOT compare equal (no gate-bypass on empty)") + } + if fingerprintsEqual("01:36", "ff:ee") { + t.Error("distinct fingerprints compared equal") + } +} + +func TestInstallKey_AtomicAnd0600(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + if err := os.WriteFile(src, []byte("keymaterial"), 0o600); err != nil { + t.Fatal(err) + } + dest := filepath.Join(dir, "sub", "installed.json") // parent dir must be created + if err := installKey(src, dest); err != nil { + t.Fatalf("installKey: %v", err) + } + b, _ := os.ReadFile(dest) + if string(b) != "keymaterial" { + t.Errorf("installed content = %q", b) + } + if runtime.GOOS != "windows" { // Windows does not enforce Unix file modes + if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 { + t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm()) + } + } + // no .tmp- siblings left behind + entries, _ := os.ReadDir(filepath.Dir(dest)) + for _, e := range entries { + if filepath.Ext(e.Name()) != ".json" && len(e.Name()) > 4 && e.Name()[:4] == "inst" { + t.Errorf("temp sibling left behind: %s", e.Name()) + } + } +} + +// ---- integration tests (real proxmox-backup-client wrap/unwrap round-trip) -------------------- + +func ensurePbc(t *testing.T) { + t.Helper() + if !pbcAvailable() { + t.Skip("skipping: proxmox-backup-client + linux required (runs on the demo/build host)") + } + if _, err := os.Stat(pbcBinary); err != nil { + if p, e := exec.LookPath("proxmox-backup-client"); e == nil { + pbcBinary = p + } + } +} + +// makeBlob creates a throwaway kdf=none key Kt, wraps it under R, and returns (blobBytes, Kt-fp). +func makeBlob(t *testing.T, dir, R string) ([]byte, string) { + t.Helper() + ctx := context.Background() + Kt := filepath.Join(dir, "Kt.json") + if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil { + t.Fatalf("key create: %v: %s", err, out) + } + fp, err := KeyFingerprint(ctx, Kt) + if err != nil { + t.Fatalf("fingerprint: %v", err) + } + blobPath := filepath.Join(dir, "escrow.blob") + if err := Wrap(ctx, Kt, blobPath, R); err != nil { + t.Fatalf("Wrap: %v", err) + } + blob, err := os.ReadFile(blobPath) + if err != nil { + t.Fatal(err) + } + return blob, fp +} + +// VALID: Consume installs a key whose fingerprint matches the expected; blob is byte-unchanged. +func TestConsume_ValidInstallsGatedKey(t *testing.T) { + ensurePbc(t) + ctx := context.Background() + dir := t.TempDir() + const R = "throwaway-correct-horse-battery-staple" + blob, wantFP := makeBlob(t, dir, R) + blobBefore := append([]byte(nil), blob...) + + dest := filepath.Join(dir, "freshbox", "encryption-key.json") + if err := Consume(ctx, blob, R, wantFP, dest); err != nil { + t.Fatalf("Consume(valid): %v", err) + } + // key installed + fingerprint matches the expected gate target. + gotFP, err := KeyFingerprint(ctx, dest) + if err != nil { + t.Fatalf("fingerprint(dest): %v", err) + } + if !fingerprintsEqual(gotFP, wantFP) { + t.Errorf("installed key fingerprint %q != expected %q", gotFP, wantFP) + } + // 0600. + if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 { + t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm()) + } + // blob read-only / unchanged (retryable). + if !bytes.Equal(blob, blobBefore) { + t.Error("Consume mutated the input blob — must be read-only/retryable") + } +} + +// WRONG R: Unwrap fails closed → clear error, NO file at dest, blob unchanged. +func TestConsume_WrongRNoInstall(t *testing.T) { + ensurePbc(t) + ctx := context.Background() + dir := t.TempDir() + blob, wantFP := makeBlob(t, dir, "the-correct-code") + blobBefore := append([]byte(nil), blob...) + + dest := filepath.Join(dir, "freshbox", "encryption-key.json") + err := Consume(ctx, blob, "DEFINITELY-the-wrong-code", wantFP, dest) + if err == nil { + t.Fatal("a wrong recovery code must fail") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Errorf("a wrong-R Consume left a key at dest (%v) — must install nothing", statErr) + } + if !bytes.Equal(blob, blobBefore) { + t.Error("wrong-R Consume mutated the blob — must be retryable") + } +} + +// FINGERPRINT MISMATCH: right unwrap, wrong expected fingerprint → fail fast, NO install (proves +// the gate runs BEFORE any restore). +func TestConsume_FingerprintMismatchNoInstall(t *testing.T) { + ensurePbc(t) + ctx := context.Background() + dir := t.TempDir() + const R = "the-correct-code" + blob, _ := makeBlob(t, dir, R) + + dest := filepath.Join(dir, "freshbox", "encryption-key.json") + // Right R, but an expected fingerprint for a DIFFERENT datastore/key. + err := Consume(ctx, blob, R, "ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff", dest) + if err == nil { + t.Fatal("a fingerprint mismatch must fail") + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Errorf("a fingerprint-mismatch Consume left a key at dest — the gate must run before install") + } +}