From fae11020a50f6375ffc41fcfbf2d362cf299613b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 10 Jun 2026 07:45:38 +0200 Subject: [PATCH] slice 7 escrow: agent contract test + CHANGELOG/REPORT/CLAUDE (v0.9.0) Agent half of slice-7 close-out finalized: cross-repo wire-contract test (escrowUploadRequest mirrors the hub), v0.9.0 CHANGELOG entry + REPORT + CLAUDE version line. No secrets. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 34 ++++++++ CLAUDE.md | 2 +- REPORT.md | 103 +++++++++-------------- cmd/felhom-agent/escrow_contract_test.go | 28 ++++++ 4 files changed, 104 insertions(+), 63 deletions(-) create mode 100644 cmd/felhom-agent/escrow_contract_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a8ff2..00f74c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.9.0 — slice 7 close-out: PBS recovery-code escrow creation (2026-06-10) + +The first code that touches the PBS client encryption key `K` and introduces the customer recovery +code `R`. Default posture is **zero-knowledge**: Felhom holds an opaque `R`-wrapped blob (cannot +open it), the customer holds `R`. Grounded by `felhom.eu/documentation/tests/slice7-escrow-spike-findings.md` +(round-trip proven on a throwaway: the `R`-recovered key restores a real encrypted snapshot). Hub +opaque storage is the `felhom.eu` half (hub v0.8.0); consumption/serving is slice 10. + +### Secret discipline (overriding) +`R` is `crypto/rand`, ≥128 bits, surfaced **exactly once** and **never** logged/persisted/committed; +the wrap pty's echo is discarded so `R` can't leak. `K` is read by location, **never modified** (the +live key file is byte-unchanged — Wrap operates on a copy), never logged. + +### Added +- **`internal/escrow`** — `Create` generates `R` (10 EFF-wordlist words ≈ 129 bits), wraps `K` under + `R` via the **PBS-native** `proxmox-backup-client key change-passphrase --kdf scrypt`, and + **self-verifies** the blob recovers `K` (fingerprint match) before shipping. The wrap is driven + over a **stdlib pty** (`x/sys/unix`; spike F-A1 — the command is TTY-only) with **output discarded** + (F-A2 — the pty echoes the passphrase). Opt-in outputs: **(b)** `R`-wrapped offline copy (two-factor, + no extra trust) and **(a)** raw paperkey (single-factor, unrevocable — loud caveat). +- **`--selftest=escrow-create`** (`-storage`, `-paperkey`, `-offline`, `-upload`): surfaces `R` once + to stdout (never the logger), prints the opaque blob's size/fingerprint/posture, and with + `-upload` PUTs the blob to the hub (`/api/v1/hosts/{host_id}/escrow`, per-host key). +- Config: `escrow` section (`posture` default `zero_knowledge`, `pbs_storage_id`); `PBSEncKeyPath` + helper (the `.enc` key K). +- Runtime dependency on the `proxmox-backup-client` CLI (the PBS key+passphrase KDF). + +### Tests +- `R` entropy ≥128 / 10-word format / uniqueness; integration round-trip (wrap→unwrap fingerprint + match, **wrong-`R` fails**, **live `K` byte-unchanged**, blob ≠ plaintext key) guarded to + linux+`proxmox-backup-client`; the agent→hub wire-contract key-set (mirrors the hub's). +- **Live-validated** (demo): `escrow-create` → `R` (10 words) surfaced once, blob 383 B opaque, + self-verify ok, **live `K` sha256 unchanged**, exact `R` absent from stderr/journal. + ## v0.8.0 — slice 7 Phase 1: unified bring-up reconcile job (provision + guest-loss DR) (2026-06-09) The shared FRONT HALF of provision and guest-loss DR, as a journaled reconcile job mirroring the diff --git a/CLAUDE.md b/CLAUDE.md index 3ae748a..9ef283a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ - Module `gitea.dooplex.hu/admin/felhom-agent`; binary `felhom-agent` (`cmd/felhom-agent/`). - **Pure Go stdlib + `golang.org/x/crypto` only** — no web frameworks. - `go.mod` directive **go 1.25.0**; dep `golang.org/x/crypto v0.52.0` (declares go 1.25, will NOT build on Go 1.24). The **build server (192.168.0.180) runs go1.26.0** (upstream Go on PATH, backward-compatible). Build/run the agent there for live tests (same LAN as the demo host). -- Version: `version` var in `cmd/felhom-agent/main.go`, overridable via `-ldflags "-X main.version="`; `--version` flag. **Current: v0.8.0** (slice 7 Phase 1: the unified bring-up reconcile job — provision + guest-loss DR front half, journaled with compensating rollback, scenario-specific identity reset, the F4 config-lock retry, `--selftest=bring-up`, and the `configs/build-golden.sh` recipe incl. the F3 first-boot host-key unit). Bump on meaningful changes + add a CHANGELOG entry. +- Version: `version` var in `cmd/felhom-agent/main.go`, overridable via `-ldflags "-X main.version="`; `--version` flag. **Current: v0.9.0** (slice 7 close-out: PBS recovery-code escrow *creation* — `internal/escrow` wraps the PBS key K under a ≥128-bit recovery code R via the PBS-native key+passphrase KDF driven over a stdlib pty, zero-knowledge default, self-verified; `--selftest=escrow-create`. Plus v0.8.0's unified bring-up reconcile job. Runtime dep: `proxmox-backup-client`). Bump on meaningful changes + add a CHANGELOG entry. ## Layout diff --git a/REPORT.md b/REPORT.md index 32400dc..17a09e9 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,78 +1,57 @@ -# REPORT — Slice 7 Phase 1: unified bring-up reconcile job (v0.8.0) (2026-06-09) +# REPORT — Slice 7 close-out: PBS recovery-code escrow creation (v0.9.0) (2026-06-10) > Overwrite-latest report (most recent significant work only). Cumulative history lives in -> [CHANGELOG.md](CHANGELOG.md). Implements `TASK — Slice 7 Phase 1: unified bring-up reconcile job`. -> **Agent-only — no hub/wire change** (the new guest auto-appears in the host-report via `ListLXC`). +> [CHANGELOG.md](CHANGELOG.md). Implements the agent half of `TASK — Slice 7 close-out: PBS +> recovery-code escrow creation`. The hub opaque-storage half is in `felhom.eu` (hub v0.8.0). ## Outcome -The shared **front half** of provision and guest-loss DR shipped as a journaled reconcile job -(`internal/reconcile/bringup.go`), mirroring the slice-6 restore-test's crash-safety but KEEPING -the guest on success and applying a **scenario-specific identity policy**. Built from the slice-7 -spike findings (commit `3342993`): F1 (MAC reset unconditional on provision), F3 (host keys via a -baked golden first-boot unit, not an agent guest-internal op), F4 (transient config-lock retry). +The first code that touches the PBS client encryption key `K` and introduces the customer recovery +code `R`. Default posture is **zero-knowledge**: Felhom holds an opaque `R`-wrapped blob it cannot +open; the customer holds `R`. The wrap→recover→restore round-trip was proven on a fenced throwaway +before any code (`felhom.eu/documentation/tests/slice7-escrow-spike-findings.md`) — the `R`-recovered +key restored a real encrypted snapshot. -## What landed +## Secret discipline (the overriding rule — held throughout) -- **`RunBringUp(BringUpSpec) BringUpResult`** — restore → identity reset → size → attach mounts → - start LINK-UP, each mutation preceded by journaling the owning entry. **Verdict is liveness** - (`waitRunning`), never the start exitstatus (reuses the v0.7.0 WARNINGS surface). **Success keeps - the guest** (the key difference from the restore-test). -- **Identity policy (doc 03 §9):** *provision* resets MAC unconditionally (`PUT net0`, hwaddr - omitted → PVE regenerates; F1) + hostname, host-side via the token; machine-id + SSH host keys - regenerate guest-side on first boot (systemd + the baked unit) — the agent never touches guest - internals. *dr_guest_loss* preserves continuity (keep hostname; keep MAC unless `KeepMAC=false`); - never resets restic/tunnel/hub identity. -- **Compensating rollback:** any mid-flight failure destroys the just-created guest - (`ClassGuestDestroy` benign via `Provenance{SameTxnCreated:true}`, gated). New journal flag - `Rollback` + `Recover.recoverBringUp` reap a half-built guest from a mid-job crash (idempotent, - via `ListLXC`) — distinct from the scratch path's audit label, same destroy machinery. -- **F4:** identity+sizing+mounts coalesced into ONE `PUT config`; rootfs grow kept separate; - `setConfigWithLockRetry` retries ONLY the transient PVE config-lock 500 (`pveConfigLock`), never - a real error. -- **`--selftest=bring-up`** (`-mode provision|dr -archive -vmid -hostname [-keep]`) — runs the real - job after a `Recover`, then tears the guest down unless `-keep`. -- **`configs/build-golden.sh`** — the validated golden recipe incl. the F3 first-boot - `felhom-regen-hostkeys.service` (Condition-gated: fires on provision, no-ops on DR). The spike's - golden archive (no unit) is superseded. +- `R`: `crypto/rand`, ≥128 bits, surfaced **exactly once** (selftest stdout), **never** logged, + persisted, committed, or in this report. The wrap pty's echoed passphrase is **discarded** (F-A2). +- `K`: read by location, **never modified** (Wrap operates on a copy; the live key file is + byte-unchanged — asserted live by sha256), never logged. +- The blob is opaque ciphertext; the hub stores bytes and never decrypts. -## Tests (assert the effect) +## What landed (`internal/escrow`) -`go test ./...` green; `-race` green on the build server. Provision happy path (fresh MAC = net0 -without hwaddr, hostname, coalesced sizing+mount, rootfs-grow separate, started, **guest NOT -destroyed**); compensating rollback injected at each step (restore / config / start-task / -waitRunning → asserts the guest **was** destroyed); DR continuity (MAC kept, hostname not reset) + -DR `KeepMAC=false` resets MAC; liveness verdict (warnings+running pass / not-running fail); F4 -(lock-500 → retry → proceed; non-lock 500 → fail without retry); owning entry journaled **before** -restore; reserved/existing VMID refused; `Recover` rolls back / clean. +- **`Create`** → generate `R` (10 EFF-wordlist words ≈ 129 bits) → **Wrap** `K` under `R` via the + PBS-native `proxmox-backup-client key change-passphrase --kdf scrypt`, driven over a **stdlib pty** + (`x/sys/unix`; the command is TTY-only, spike F-A1) with **output discarded** (F-A2) → **self-verify** + the blob unwraps back to `K` (fingerprint match) before shipping → return `R` SEPARATELY (so it + can't be logged via the result) + the opaque blob. +- **Opt-in outputs:** (b) `R`-wrapped offline copy (two-factor, no extra trust); (a) raw paperkey + (single-factor, unrevocable — loud caveat). +- **`--selftest=escrow-create`** (`-storage`/`-paperkey`/`-offline`/`-upload`): surfaces `R` once, + prints the opaque blob's size/fingerprint/posture, and with `-upload` PUTs it to the hub + (`/api/v1/hosts/{host_id}/escrow`, per-host key). +- Config: `escrow{posture, pbs_storage_id}` (zero_knowledge default); `PBSEncKeyPath` (the `.enc`). +- Runtime dependency: the `proxmox-backup-client` CLI. -## Live validation (demo-felhom) — all green; caught + fixed one real bug +## Tests -Golden built via `configs/build-golden.sh` → `local:backup/vzdump-lxc-9100-2026_06_09-21_32_58.tar.zst` -(Docker `overlayfs`, host-key unit baked + enabled). All runs via `--selftest=bring-up`. +`go test ./...` green; `-race` green on the build server (pure-Go parts). `R` entropy ≥128 / +10-word format / 200-draw uniqueness; the agent→hub wire-contract key-set (mirrors the hub). The +integration round-trip (wrap→unwrap fingerprint match, **wrong-`R` fails**, **live `K` byte-unchanged**, +blob ≠ plaintext) is guarded to linux+`proxmox-backup-client` and exercised by the live selftest. -- **Bug caught live (then fixed + re-validated):** the first golden's `felhom-regen-hostkeys` - unit used `ExecStart=/usr/sbin/ssh-keygen` (as drafted in the task) → `status=203/EXEC` ("No such - file"); on Debian 13 `ssh-keygen` is at `/usr/bin/ssh-keygen`. Fixed in `build-golden.sh`, - rebuilt the golden, re-validated. (The unit tests mock the guest, so only the live run could - surface this — exactly why the slice runs live.) -- **provision** (vmid 8100): restore → fresh MAC `BC:24:11:4A:CF:3C` (≠ golden's) → hostname - `felhom-prov-8100` → link-up, 8s. Verified: `felhom-regen-hostkeys` **active** and SSH host keys - present (`root@felhom-prov-8100`) — **regenerated by the baked unit, the agent issued no - ssh-keygen**; `ssh.service` active; machine-id unique (`103a0325…`); hostname propagated; clean - DHCP lease `192.168.0.114`; `docker run hello-world` OK → torn down. -- **dr** (vmid 8101, from a 9999 customer backup): continuity preserved — hostname stayed - `felhom-selftest-scratch` (NOT reset) and SSH host keys **preserved** (not regenerated). The - source had no net0, so MAC-keep was a no-op live (covered by the unit test instead). -- **Recover** (vmid 8102): killed a provision mid-restore (`timeout -s KILL`); the server-side - restore left an orphan guest; the re-run's `Recover` logged `rolled back half-built bring-up - guest vmid=8102` (`BringUpRolledBack:1`), then re-provisioned cleanly → torn down. Idempotent. +## Live validation (demo-felhom) -Teardown: no spike guests linger; the validated golden is retained as the artifact; the service -runs v0.8.0. +`--selftest=escrow-create -storage felhom-pbs`: `R` (10 words) surfaced **once** to stdout; blob +**383 B opaque** (key fingerprint `01:36:e9…`, posture `zero_knowledge`, ~129-bit `R`); +**self-verify** confirmed recoverability; the live `K` (`felhom-pbs.enc`) **sha256 byte-unchanged**; +the exact `R` was **absent** from stderr and the agent journal. *(Upload to the hub validated after +hub v0.8.0 deploy — see felhom.eu REPORT.)* ## Deferred (stated, not built) -Provisioning BACK HALF (controller deploy, bootstrap, per-guest token mint) → slice 8; host-loss -DR + escrow consumption → slice 10; the SOURCE of a `BringUpSpec` (hub desired-state) → slice 10 -(`GuestMount` defined minimally, no hub coupling). No secrets committed. +Escrow **consumption / restore-mode serving** → slice 10 (host-loss DR; the spike pre-validated it). +The **Felhom-holds-a-key** posture (the one dangerous topology cell) is documented (doc 03 §8a) and +implemented only on an explicit customer opt-in. No secrets committed. diff --git a/cmd/felhom-agent/escrow_contract_test.go b/cmd/felhom-agent/escrow_contract_test.go new file mode 100644 index 0000000..6f137ce --- /dev/null +++ b/cmd/felhom-agent/escrow_contract_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "reflect" + "sort" + "testing" +) + +// TestEscrowUploadContract pins the agent→hub escrow wire shape. It MUST match the hub's ingest +// struct (felhom-hub api.escrowUploadRequest). Cross-repo, no shared module — this is the agent +// half of the contract guard; the hub has the mirror in its own test. +func TestEscrowUploadContract(t *testing.T) { + b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"}) + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(m)) + for k := range m { + got = append(got, k) + } + sort.Strings(got) + want := []string{"blob_b64", "created_at", "key_fingerprint", "posture"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("escrow wire contract drift: got %v want %v (must match the hub ingest struct)", got, want) + } +}