slice 10D (agent): DR capstone — identity escrow + restore-mode consumption (v0.18.0)
Identity escrow wraps {tunnel_token,pbs_token} under the SAME R via age
(scrypt+ChaCha20-Poly1305), reusing the K-escrow pty; wrong R fails closed.
escrow.Create optionally emits the identity blob; escrow-create uploads it +
the non-secret directive; identity-consume recovers it (R by hand, never
logged). K-escrow + 10C Consume untouched. Closes slice 10 with hub v0.11.0;
operator-side rotation model (hub holds no Cloudflare write-power).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,50 +1,48 @@
|
||||
# REPORT — slice 10C: escrow consumption — productionize the spike (v0.17.0) (2026-06-10)
|
||||
# REPORT — slice 10D (agent half): DR capstone — identity escrow + restore-mode consumption (v0.18.0) (2026-06-10)
|
||||
|
||||
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## What was implemented
|
||||
|
||||
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 agent half of the slice-10 DR capstone (closes slice 10), grounded by both 10-series spikes
|
||||
(escrow-consumption + identity-restore). The hub half (recovery-mode, re-enroll, directive serving)
|
||||
is hub v0.11.0. **Rotation model (locked):** the hub holds no Cloudflare write-power; the destructive
|
||||
tunnel/PBS rotation is the operator's step from a trusted environment (same spirit as 10B).
|
||||
|
||||
### `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.
|
||||
### Identity escrow (`internal/escrow/identity.go`)
|
||||
- `WrapIdentity`/`UnwrapIdentity` (+ `…Bundle`) wrap the `{tunnel_token, pbs_token}` bundle under the
|
||||
SAME recovery code `R` via **`age`** (scrypt + ChaCha20-Poly1305 — vetted passphrase-AEAD, not
|
||||
hand-rolled), **reusing the K-escrow pty mechanism** (passphrase via the tty, data via files;
|
||||
`R`/tokens never logged). Same two-factor, zero-knowledge shape as the K-escrow; a **wrong R fails
|
||||
closed** (no bundle). `age` is a runtime dep for the identity path (like proxmox-backup-client for K).
|
||||
- `escrow.Create` gains an optional `IdentityBundle` → emits `IdentityBlob` under the same R
|
||||
(additive; the K-escrow + 10C `Consume` are byte-unchanged; self-verifies the identity round-trip).
|
||||
- `--selftest=escrow-create -identity-bundle <f> -directive <f>` (wrap + upload the identity blob +
|
||||
non-secret directive) and `--selftest=identity-consume -blob <f> -keydest <f>` (R via
|
||||
`FELHOM_RECOVERY_CODE`; tokens written 0600, never logged) — the real code, drillable.
|
||||
|
||||
**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.
|
||||
### Restore-mode orchestration (10D.3)
|
||||
Composed from proven pieces: re-enroll (hub) → fetch directive (10A) → `Consume` K (10C, fingerprint
|
||||
-gated) + `identity-consume` (R by hand) → restore guests from PBS (restore-overwrite gated by 10B) →
|
||||
re-establish the tunnel (recovered token + reconstituted origin — proven in the 10D spike). The
|
||||
destructive cred rotation is the operator's step.
|
||||
|
||||
### `--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.
|
||||
|
||||
## 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`).
|
||||
## Tests
|
||||
- identity round-trips (wrap→unwrap byte-identical; blob is opaque ciphertext); wrong R fails closed +
|
||||
blob stays retryable; input validation. K-escrow/10C unchanged. `go test ./...` green (age
|
||||
integration tests gated to a host with the `age` CLI).
|
||||
|
||||
## Versioning / docs
|
||||
- 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).
|
||||
- Version `0.17.0 → 0.18.0`; `CHANGELOG.md`. Doc 03 §9 (10D done → **slice 10 CLOSED**) + the
|
||||
host-loss DR flow with the operator-side rotation model.
|
||||
|
||||
## Out of scope (per the task)
|
||||
- DR orchestration (re-enroll in restore mode, *which* guests, identity restore) → **10D**;
|
||||
populating/serving the restore directive from the hub → **10D**.
|
||||
## Deferred (non-blocking, per the locked model)
|
||||
- The hub Config DR/Recovery **web UI** (functional via the recovery-mode admin API today) + a small
|
||||
operator rotation CLI (the tunnel/PBS rotation + stale-connector delete is a documented operator
|
||||
procedure from the trusted environment — the hub never holds Cloudflare write-power).
|
||||
|
||||
## Pending
|
||||
- **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.
|
||||
- The operator-in-the-loop **DR drill** (live, throwaway identity): host-loss → recovery mode →
|
||||
re-enroll (old key revoked) → R → consume → restore → re-establish → operator rotates/retires the
|
||||
old connector → only the new box serves. CC drives the `[CC]` steps; the operator does the trusted
|
||||
steps.
|
||||
|
||||
Reference in New Issue
Block a user