slice 10C: escrow consumption — productionize the spike (v0.17.0)
Add escrow.Consume(blob, R, expectedFingerprint, keyDest): Unwrap -> fingerprint gate -> atomic 0600 install. Bakes in the spike findings — wrong R fails closed (no write), the fingerprint gate runs BEFORE any restore (no install on mismatch), the input blob is read-only (retryable), K is never mutated, R/key bytes never logged. Zero-knowledge holds: the hub serves all but R (by hand). --selftest=escrow-consume invokes the real path live. Agent-only; no hub change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user