diff --git a/CHANGELOG.md b/CHANGELOG.md index 33eccf4..8daae1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.18.0 — slice 10D: DR capstone — identity escrow + restore-mode consumption (agent side) (2026-06-10) + +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 toggle, re-enroll + credential +rotation, directive serving) is hub v0.11.0. **Operator-side 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). + +### Added (`internal/escrow`) +- **Identity escrow** (`identity.go`): `WrapIdentity`/`UnwrapIdentity` (+ `…Bundle`) wrap the + `{tunnel_token, pbs_token}` bundle under the SAME recovery code `R` via **`age`** (scrypt + + ChaCha20-Poly1305 — a 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 (analogous to proxmox-backup-client for K). +- **`escrow.Create`** gains an optional `IdentityBundle` → also emits an `IdentityBlob` under the same + R (additive; the K-escrow + 10C `Consume` paths are byte-unchanged). Self-verifies the identity + round-trip before shipping. +- **`--selftest=escrow-create -identity-bundle -directive `** — also wrap + upload the + identity blob + the **non-secret** DR directive (pbs repo/ns, expected key fingerprint, tunnel id). +- **`--selftest=identity-consume -blob -keydest `** (R via `FELHOM_RECOVERY_CODE`) — + recover the identity bundle through the real code; tokens written 0600, never logged. + +### Tests +- identity bundle round-trips (wrap→unwrap byte-identical; blob is opaque ciphertext); wrong R fails + closed + the blob stays retryable; input validation. K-escrow/10C tests byte-unchanged (additive). + (age integration tests gated to a host with the `age` CLI.) + ## 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 diff --git a/REPORT.md b/REPORT.md index e7a8e68..96104dd 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 -directive ` (wrap + upload the identity blob + + non-secret directive) and `--selftest=identity-consume -blob -keydest ` (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. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 3d6a1e8..1c12226 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.17.0" +var version = "0.18.0" func main() { var ( @@ -62,10 +62,12 @@ func main() { custDomain string custName string custEmail string - blobPath string - expectedFP string - keyDest string - showVersion bool + blobPath string + expectedFP string + keyDest string + idBundlePath string + directivePath string + showVersion bool ) flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-customer-domain; keeps the guest)") @@ -82,6 +84,8 @@ func main() { 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(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)") + flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)") 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)") @@ -131,9 +135,11 @@ func main() { customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail}, })) case "escrow-create": - os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload)) + os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath)) case "escrow-consume": os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest)) + case "identity-consume": + os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest)) } } @@ -1068,7 +1074,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L // wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque // blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs // the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key). -func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool) int { +func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, directivePath string) int { if storage == "" { storage = cfg.Escrow.PBSStorageID } @@ -1082,15 +1088,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo return 1 } - fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s) ===\n", version, storage, escrow.DefaultPosture) + // Slice 10D.1: optionally ALSO wrap the identity bundle under the same R, and carry the non-secret + // directive for the hub. The bundle file is a 0600 secret (tunnel/pbs tokens); the directive is + // non-secret (pbs repo/ns, expected fingerprint, tunnel id). + var identity *escrow.IdentityBundle + var directive json.RawMessage + if identityBundlePath != "" { + raw, err := os.ReadFile(identityBundlePath) + if err != nil { + fmt.Fprintf(os.Stderr, "selftest=escrow-create: reading identity bundle %s: %v\n", identityBundlePath, err) + return 1 + } + var b escrow.IdentityBundle + if err := json.Unmarshal(raw, &b); err != nil { + fmt.Fprintf(os.Stderr, "selftest=escrow-create: identity bundle is not valid JSON {tunnel_token,pbs_token}: %v\n", err) + return 1 + } + identity = &b + if directivePath != "" { + if d, err := os.ReadFile(directivePath); err == nil && json.Valid(d) { + directive = d + } + } + } + + fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil) // NB: nothing about R is logged. The logger never sees R; only stdout does, once. - logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath) + logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath, "with_identity", identity != nil) R, res, err := escrow.Create(ctx, escrow.CreateOptions{ KeyPath: keyPath, Posture: escrow.Posture(cfg.Escrow.Posture), WantOfflineCopy: offline, WantPaperkey: paperkey, + IdentityBundle: identity, }) if err != nil { fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err) @@ -1120,12 +1151,15 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo fmt.Println(res.Paperkey) } + if len(res.IdentityBlob) > 0 { + fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob)) + } if upload { - if err := uploadEscrowBlob(ctx, cfg, res); err != nil { + if err := uploadEscrowBlob(ctx, cfg, res, directive); err != nil { fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err) return 1 } - fmt.Println(" uploaded the opaque blob to the hub (host record); the hub cannot open it") + fmt.Println(" uploaded the opaque blob(s) to the hub (host record); the hub cannot open them") } fmt.Println("=== selftest=escrow-create OK ===") return 0 @@ -1165,6 +1199,44 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath return 0 } +// runSelftestIdentityConsume recovers the IDENTITY bundle from its age blob with R (slice 10D.1/10D.3) +// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from +// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then +// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state. +func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int { + if blobPath == "" || keyDest == "" { + fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)") + return 2 + } + R := os.Getenv("FELHOM_RECOVERY_CODE") + if R == "" { + fmt.Fprintln(os.Stderr, "selftest=identity-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input)") + return 2 + } + blob, err := os.ReadFile(blobPath) + if err != nil { + fmt.Fprintf(os.Stderr, "selftest=identity-consume: reading blob %s: %v\n", blobPath, err) + return 1 + } + fmt.Printf("=== felhom-agent %s selftest=identity-consume (blob=%s → %s) ===\n", version, blobPath, keyDest) + logger.Info("escrow: recovering identity bundle from R-wrapped age blob", "blob_bytes", len(blob)) // R + tokens NOT logged + bundle, err := escrow.UnwrapIdentityBundle(ctx, blob, R) + if err != nil { + R = "" + fmt.Fprintln(os.Stderr, " [FAIL] identity consume:", err) // never contains R or token bytes + return 1 + } + R = "" + raw, _ := json.Marshal(bundle) + if err := os.WriteFile(keyDest, raw, 0o600); err != nil { + fmt.Fprintln(os.Stderr, " [FAIL] writing recovered bundle:", err) + return 1 + } + fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest) + fmt.Println("=== selftest=identity-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. @@ -1172,20 +1244,30 @@ type escrowUploadRequest struct { BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext) KeyFingerprint string `json:"key_fingerprint"` // for operator display only Posture string `json:"posture"` // e.g. "zero_knowledge" + // Slice 10D.1 — optional DR bundle (identity escrow + non-secret directive). Omitted in slice-7. + IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` + DirectiveJSON json.RawMessage `json:"directive,omitempty"` CreatedAt string `json:"created_at"` // RFC3339 } -// uploadEscrowBlob PUTs the opaque blob to the hub, authed with the per-host key. -func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult) error { +// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to +// the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable +// secret leaves the agent. +func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage) error { if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" { return fmt.Errorf("hub not configured (url/host_id/api_key)") } - body, _ := json.Marshal(escrowUploadRequest{ + upReq := escrowUploadRequest{ BlobB64: base64.StdEncoding.EncodeToString(res.Blob), KeyFingerprint: res.KeyFingerprint, Posture: string(res.Posture), CreatedAt: time.Now().UTC().Format(time.RFC3339), - }) + } + if len(res.IdentityBlob) > 0 { + upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob) + upReq.DirectiveJSON = directive + } + body, _ := json.Marshal(upReq) url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow" req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) if err != nil { @@ -1563,8 +1645,10 @@ func (f *selftestFlag) Set(v string) error { f.mode = "escrow-create" case "escrow-consume": f.mode = "escrow-consume" + case "identity-consume": + f.mode = "identity-consume" default: - 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 fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume|identity-consume)", v) } return nil } diff --git a/internal/escrow/escrow.go b/internal/escrow/escrow.go index ed852d8..443157b 100644 --- a/internal/escrow/escrow.go +++ b/internal/escrow/escrow.go @@ -39,6 +39,9 @@ type CreateOptions struct { // WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable; // the caller must surface the loud caveat. Off by default. WantPaperkey bool + // IdentityBundle (slice 10D.1), when set, is ALSO wrapped under the SAME R (via age) → an + // IdentityBlob in the result. Additive: the K-escrow path is unchanged when nil. + IdentityBundle *IdentityBundle } // CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned @@ -50,6 +53,7 @@ type CreateResult struct { EntropyBits float64 // R's approximate entropy (for display; never R itself) OfflineCopy []byte // (b) the same wrapped blob, if WantOfflineCopy (for the customer to print) Paperkey string // (a) raw paperkey text, if WantPaperkey — SECRET-adjacent (single factor) + IdentityBlob []byte // (10D.1) the age-wrapped identity bundle under the same R, if IdentityBundle set } // Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and @@ -121,6 +125,18 @@ func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res C } res.Paperkey = pk } + // Slice 10D.1: ALSO wrap the identity bundle under the SAME R (via age), so DR can recover the + // box's identity with the one recovery code. Self-verify it round-trips before shipping. + if opts.IdentityBundle != nil { + idBlob, err := WrapIdentityBundle(ctx, *opts.IdentityBundle, R) + if err != nil { + return "", CreateResult{}, fmt.Errorf("escrow: identity wrap: %w", err) + } + if _, err := UnwrapIdentityBundle(ctx, idBlob, R); err != nil { + return "", CreateResult{}, fmt.Errorf("escrow: identity self-verify (not recoverable): %w", err) + } + res.IdentityBlob = idBlob + } return R, res, nil } diff --git a/internal/escrow/identity.go b/internal/escrow/identity.go new file mode 100644 index 0000000..52cbd93 --- /dev/null +++ b/internal/escrow/identity.go @@ -0,0 +1,100 @@ +package escrow + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the +// PBS-native scrypt path. The identity bundle `{tunnel_token, pbs_token}` is arbitrary secret bytes +// (not a PBS key), so it is wrapped under the SAME recovery code `R` with **age** (`age -p`: scrypt +// + ChaCha20-Poly1305 — a vetted passphrase-AEAD, not hand-rolled). Same two-factor, zero-knowledge +// shape as the K-escrow: the blob is opaque without `R`; `R` is the only out-of-band secret. The +// K-escrow + the 10C `Consume` path are UNTOUCHED — this is purely additive. Proven by the slice-10D +// identity-restore spike (documentation/tests/slice10d-identity-restore-spike-findings.md). +// +// age is a runtime dependency for the identity path (analogous to proxmox-backup-client for K). +var ageBinary = "/usr/bin/age" + +// IdentityBundle is the box's recoverable identity — the secrets a re-enrolling box needs to come +// back "as host X". Carried only inside the R-wrapped blob; never stored or logged in the clear. +type IdentityBundle struct { + TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token + PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish) +} + +// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and +// returns the opaque blob. `R` is fed via the pty (2 prompts: passphrase + confirm); the plaintext +// and ciphertext flow as files, so only `R` touches the tty (never logged). +func WrapIdentity(ctx context.Context, bundle []byte, recoveryCode string) ([]byte, error) { + if len(bundle) == 0 { + return nil, fmt.Errorf("escrow: WrapIdentity needs a non-empty bundle") + } + if recoveryCode == "" { + return nil, fmt.Errorf("escrow: WrapIdentity needs the recovery code (R)") + } + work, err := os.MkdirTemp("", "felhom-idesc-") + if err != nil { + return nil, fmt.Errorf("escrow: tempdir: %w", err) + } + defer os.RemoveAll(work) + in, out := filepath.Join(work, "bundle"), filepath.Join(work, "blob") + if err := os.WriteFile(in, bundle, 0o600); err != nil { + return nil, fmt.Errorf("escrow: stage bundle: %w", err) + } + // `age -p -o ` prompts the passphrase + confirm (2) and writes the armored blob. + if err := runWithPassphrase(ctx, recoveryCode, 2, ageBinary, "-p", "-a", "-o", out, in); err != nil { + return nil, fmt.Errorf("escrow: identity wrap (age -p): %w", err) + } + return os.ReadFile(out) +} + +// UnwrapIdentity recovers the bundle bytes from an age blob with `R`. A WRONG R fails CLOSED at the +// scrypt KDF (`age -d` nonzero exit, no plaintext emitted) — never a plausible-but-wrong bundle. +func UnwrapIdentity(ctx context.Context, blob []byte, recoveryCode string) ([]byte, error) { + if len(blob) == 0 { + return nil, fmt.Errorf("escrow: UnwrapIdentity needs a non-empty blob") + } + if recoveryCode == "" { + return nil, fmt.Errorf("escrow: UnwrapIdentity needs the recovery code (R)") + } + work, err := os.MkdirTemp("", "felhom-idesc-") + if err != nil { + return nil, fmt.Errorf("escrow: tempdir: %w", err) + } + defer os.RemoveAll(work) + in, out := filepath.Join(work, "blob"), filepath.Join(work, "bundle") + if err := os.WriteFile(in, blob, 0o600); err != nil { + return nil, fmt.Errorf("escrow: stage blob: %w", err) + } + // `age -d -o ` prompts the passphrase (1). + if err := runWithPassphrase(ctx, recoveryCode, 1, ageBinary, "-d", "-o", out, in); err != nil { + return nil, fmt.Errorf("escrow: the recovery code did not unwrap the identity escrow (wrong recovery code, or a corrupt blob): %w", err) + } + return os.ReadFile(out) +} + +// WrapIdentityBundle marshals + wraps an IdentityBundle under R. +func WrapIdentityBundle(ctx context.Context, b IdentityBundle, recoveryCode string) ([]byte, error) { + raw, err := json.Marshal(b) + if err != nil { + return nil, fmt.Errorf("escrow: marshal identity bundle: %w", err) + } + return WrapIdentity(ctx, raw, recoveryCode) +} + +// UnwrapIdentityBundle unwraps + parses an IdentityBundle (slice 10D.3 restore-mode consumption). +func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string) (IdentityBundle, error) { + raw, err := UnwrapIdentity(ctx, blob, recoveryCode) + if err != nil { + return IdentityBundle{}, err + } + var b IdentityBundle + if err := json.Unmarshal(raw, &b); err != nil { + return IdentityBundle{}, fmt.Errorf("escrow: recovered identity bundle is malformed: %w", err) + } + return b, nil +} diff --git a/internal/escrow/identity_test.go b/internal/escrow/identity_test.go new file mode 100644 index 0000000..aa7f28e --- /dev/null +++ b/internal/escrow/identity_test.go @@ -0,0 +1,83 @@ +package escrow + +import ( + "bytes" + "context" + "os/exec" + "runtime" + "testing" +) + +func ageAvailable() bool { + if runtime.GOOS != "linux" { + return false + } + if _, err := exec.LookPath("age"); err == nil { + return true + } + return false +} + +func ensureAge(t *testing.T) { + t.Helper() + if !ageAvailable() { + t.Skip("skipping: the `age` CLI + linux required (runs on the demo/build host)") + } + if p, err := exec.LookPath("age"); err == nil { + ageBinary = p + } +} + +func TestIdentity_InputValidation(t *testing.T) { + ctx := context.Background() + if _, err := WrapIdentity(ctx, nil, "R"); err == nil { + t.Error("empty bundle must error") + } + if _, err := WrapIdentity(ctx, []byte("x"), ""); err == nil { + t.Error("empty R must error") + } + if _, err := UnwrapIdentity(ctx, nil, "R"); err == nil { + t.Error("empty blob must error") + } +} + +// Round-trip: a bundle wraps under R and recovers byte-identical (the identity analog of K-escrow). +func TestIdentity_RoundTrip(t *testing.T) { + ensureAge(t) + ctx := context.Background() + const R = "throwaway-correct-horse-battery-staple-words" + bundle := IdentityBundle{TunnelToken: "eyJhIjoidGVzdCIsInQiOiJ4In0", PBSToken: "felhom@pbs!n100:deadbeefcafe"} + + blob, err := WrapIdentityBundle(ctx, bundle, R) + if err != nil { + t.Fatalf("WrapIdentityBundle: %v", err) + } + // the blob is opaque ciphertext, not the bundle. + if bytes.Contains(blob, []byte(bundle.TunnelToken)) || bytes.Contains(blob, []byte(bundle.PBSToken)) { + t.Fatal("the blob leaks plaintext token bytes — not encrypted") + } + got, err := UnwrapIdentityBundle(ctx, blob, R) + if err != nil { + t.Fatalf("UnwrapIdentityBundle: %v", err) + } + if got != bundle { + t.Errorf("recovered bundle = %+v, want %+v", got, bundle) + } +} + +// Wrong R fails CLOSED — no bundle emitted. +func TestIdentity_WrongRFailsClosed(t *testing.T) { + ensureAge(t) + ctx := context.Background() + blob, err := WrapIdentity(ctx, []byte(`{"tunnel_token":"a","pbs_token":"b"}`), "the-correct-code") + if err != nil { + t.Fatalf("WrapIdentity: %v", err) + } + if _, err := UnwrapIdentity(ctx, blob, "DEFINITELY-the-wrong-code"); err == nil { + t.Fatal("a wrong recovery code must fail closed (no bundle)") + } + // the blob is unchanged / retryable: the RIGHT code still works after a wrong attempt. + if _, err := UnwrapIdentity(ctx, blob, "the-correct-code"); err != nil { + t.Errorf("the blob was not retryable after a wrong-R attempt: %v", err) + } +}