From f0c35bb3b1110e00147eee5f6ceafaa18deb5794 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 18:49:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(b-0852):=20zeta-creds-picker=20--verify=20?= =?UTF-8?q?=E2=80=94=20re-decrypt=20+=20dry-run-restore=20the=20just-writt?= =?UTF-8?q?en=20blob=20at=20install=20time=20(operator=20catches=20bad=20b?= =?UTF-8?q?lob=20BEFORE=20reboot,=20not=20at=20first=20boot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in --verify flag to zeta-creds-picker.ts. When set, after zeta-creds-persist succeeds, the picker spawns zeta-creds-restore.ts with --dry-run + the same passphrase source + a tmpdir as --target-root. If restore-dry-run exits 0, the blob is confirmed cryptographically valid + manifest-parseable. If non-zero, the operator sees an actionable error at install time + can re-run the picker to retry. Operator-experience improvement: without --verify, a corrupt blob (wrong passphrase captured, disk write error, persist bug) only surfaces at first reboot when zeta-creds-restore.service fails its ConditionPathExists or scrypt-decrypt step. At that point the operator must reboot back into the live USB + re-run the install. With --verify, the same failure surfaces SECONDS after persist, inside the running install flow, with the live USB still mounted. New exit code 5 for verify-failed (distinct from persist-failed=4). API addition: - PickerArgs gains `verify: boolean` (default false; opt-in) - New export buildVerifyArgs(parsed, tmpTargetRoot) — pure composer of the restore-CLI argv list; testable in isolation Tests added (3 new + 2 parseArgs-extension): - --verify flag default false - --verify flag parsed when passed - buildVerifyArgs composes restore-CLI args with --dry-run + tmpdir - buildVerifyArgs propagates --passphrase-file when picker used file - buildVerifyArgs propagates --persona when set 21 pass / 0 fail (was 16; +5). Substrate-honest scope: opt-in only. Future PR can flip default-on after operator empirical testing confirms verify doesn't introduce new failure modes (e.g., tmpdir permission, restore-CLI changes). zeta-install.sh Step 6.95-picker currently does NOT pass --verify; that flip can land in a follow-up after operator tests. Composes with: - B-0852 cred-persistence cascade (#5635 + #5637 + #5639 + #5640 + #5642 + #5644 + #5645 + #5646 + #5648 + #5649 + #5650) - tools/installer/zeta-creds-restore.ts (existing --dry-run mode) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tools/installer/zeta-creds-picker.test.ts | 45 +++++++++++++- tools/installer/zeta-creds-picker.ts | 74 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/tools/installer/zeta-creds-picker.test.ts b/tools/installer/zeta-creds-picker.test.ts index 18a97dbca1..ed285c40a9 100644 --- a/tools/installer/zeta-creds-picker.test.ts +++ b/tools/installer/zeta-creds-picker.test.ts @@ -3,7 +3,7 @@ // Tests parseArgs (pure) + runPicker (against a mock readline-like interface). import { describe, expect, it } from "bun:test"; -import { parseArgs, runPicker } from "./zeta-creds-picker"; +import { parseArgs, runPicker, buildVerifyArgs } from "./zeta-creds-picker"; describe("parseArgs", () => { it("accepts well-formed args with --passphrase-env", () => { @@ -46,6 +46,49 @@ describe("parseArgs", () => { const r = parseArgs(["--bogus"]); expect("error" in r).toBe(true); }); + + it("--verify flag is opt-in (default false)", () => { + const r = parseArgs(["--usb-uuid", "u1", "--output", "/o", "--passphrase-env", "P"]); + if ("error" in r) throw new Error(r.error); + expect(r.verify).toBe(false); + }); + + it("--verify flag parsed when passed", () => { + const r = parseArgs(["--usb-uuid", "u1", "--output", "/o", "--passphrase-env", "P", "--verify"]); + if ("error" in r) throw new Error(r.error); + expect(r.verify).toBe(true); + }); +}); + +describe("buildVerifyArgs", () => { + it("composes restore CLI args with --dry-run + provided tmpdir", () => { + const parsed = parseArgs(["--usb-uuid", "u1", "--output", "/mnt/boot/zeta-creds.enc", "--passphrase-env", "ZETA_PP", "--verify"]); + if ("error" in parsed) throw new Error(parsed.error); + const args = buildVerifyArgs(parsed, "/tmp/verify-x"); + expect(args).toContain("tools/installer/zeta-creds-restore.ts"); + expect(args).toContain("--usb-uuid"); expect(args).toContain("u1"); + expect(args).toContain("--input"); expect(args).toContain("/mnt/boot/zeta-creds.enc"); + expect(args).toContain("--target-root"); expect(args).toContain("/tmp/verify-x"); + expect(args).toContain("--dry-run"); + expect(args).toContain("--passphrase-env"); expect(args).toContain("ZETA_PP"); + }); + + it("propagates --passphrase-file when picker used file source", () => { + const parsed = parseArgs(["--usb-uuid", "u2", "--output", "/o", "--passphrase-file", "/pp"]); + if ("error" in parsed) throw new Error(parsed.error); + const args = buildVerifyArgs(parsed, "/tmp/t"); + expect(args).toContain("--passphrase-file"); + expect(args).toContain("/pp"); + expect(args).not.toContain("--passphrase-env"); + }); + + it("propagates --persona when set", () => { + const parsed = parseArgs(["--usb-uuid", "u3", "--output", "/o", "--passphrase-env", "P", "--persona", "otto"]); + if ("error" in parsed) throw new Error(parsed.error); + const args = buildVerifyArgs(parsed, "/tmp/t"); + expect(args).toContain("--persona"); + expect(args).toContain("otto"); + }); }); // Mock readline-like interface for testing runPicker against scripted answers. diff --git a/tools/installer/zeta-creds-picker.ts b/tools/installer/zeta-creds-picker.ts index 311beebcbb..5332e835d8 100644 --- a/tools/installer/zeta-creds-picker.ts +++ b/tools/installer/zeta-creds-picker.ts @@ -18,9 +18,13 @@ // Usage (called from zeta-install.sh Step 6.95-picker or operator terminal): // bun tools/installer/zeta-creds-picker.ts \ // --usb-uuid \ -// --output /esp/zeta-creds.enc \ +// --output /mnt/boot/zeta-creds.enc \ // ( --passphrase-file | --passphrase-env ) \ // [--persona ] \ +// [--verify] (post-persist: re-decrypt the blob with the same +// passphrase + dry-run-restore to verify it's +// actually usable BEFORE the operator reboots and +// discovers a bad blob at first boot) // [--dry-run] (print persist invocation; don't exec) // // Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over @@ -32,8 +36,14 @@ // 2 arg parse error // 3 picker abort (operator Ctrl+C) // 4 persist invocation failure +// 5 verify failure (--verify ran post-persist + reported a problem with +// the just-written blob; the install-time recovery is to re-run the +// picker; the first-reboot recovery would otherwise be a full reflash) import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { DEFAULT_MANIFEST } from "./zeta-creds-manifest"; @@ -46,6 +56,7 @@ interface PickerArgs { readonly passphraseEnv: string | null; readonly persona: string | null; readonly dryRun: boolean; + readonly verify: boolean; } export function parseArgs(argv: readonly string[]): PickerArgs | { readonly error: string } { @@ -55,6 +66,7 @@ export function parseArgs(argv: readonly string[]): PickerArgs | { readonly erro let passphraseEnv: string | null = null; let persona: string | null = null; let dryRun = false; + let verify = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; const next = (): string => { @@ -68,6 +80,7 @@ export function parseArgs(argv: readonly string[]): PickerArgs | { readonly erro else if (arg === "--passphrase-env") passphraseEnv = next(); else if (arg === "--persona") persona = next(); else if (arg === "--dry-run") dryRun = true; + else if (arg === "--verify") verify = true; else return { error: `unknown flag: ${arg}` }; } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; @@ -78,7 +91,40 @@ export function parseArgs(argv: readonly string[]): PickerArgs | { readonly erro if (!passphraseFile && !passphraseEnv) { return { error: "passphrase source required: --passphrase-file or --passphrase-env " }; } - return { usbUuid, output, passphraseFile, passphraseEnv, persona, dryRun }; + return { usbUuid, output, passphraseFile, passphraseEnv, persona, dryRun, verify }; +} + +/** + * Build the argv list for the verify-mode invocation of + * zeta-creds-restore.ts. Pure function for testability. + * + * The verify step decrypts the just-written blob using the same + * passphrase source the picker used + invokes the restore CLI in + * --dry-run mode against a tmpdir target-root. Success = blob is + * cryptographically valid + manifest parses + at least one cred + * entry round-tripped. Failure = exit non-zero; operator sees an + * actionable error BEFORE rebooting into an unrecoverable state. + * + * Tmpdir use: --dry-run guarantees restore writes nothing, but + * --target-root must be set to something to avoid the default `/` + * (which would surface as suggested-write-target in dry-run output + * + confuse operators reading the verify log). + */ +export function buildVerifyArgs( + parsed: PickerArgs, + tmpTargetRoot: string, +): readonly string[] { + const args = [ + "tools/installer/zeta-creds-restore.ts", + "--usb-uuid", parsed.usbUuid, + "--input", parsed.output, + "--target-root", tmpTargetRoot, + "--dry-run", + ]; + if (parsed.passphraseFile) args.push("--passphrase-file", parsed.passphraseFile); + if (parsed.passphraseEnv) args.push("--passphrase-env", parsed.passphraseEnv); + if (parsed.persona) args.push("--persona", parsed.persona); + return args; } /** @@ -225,6 +271,30 @@ async function main(): Promise { console.error(`zeta-creds-picker: persist failed (exit ${result.status})`); return 4; } + if (parsed.verify) { + console.log(`\n=== Verifying just-written blob (--verify) ===`); + console.log(` Re-decrypting ${parsed.output} with the same passphrase + dry-run-restoring to`); + console.log(` a tmpdir to confirm the blob is cryptographically valid + manifest-parseable.`); + console.log(` Any failure here is recoverable AT INSTALL TIME by re-running the picker;`); + console.log(` if discovered post-reboot, recovery would require full reflash.`); + const tmpTargetRoot = mkdtempSync(join(tmpdir(), "zeta-picker-verify-")); + const verifyArgs = buildVerifyArgs(parsed, tmpTargetRoot); + // eslint-disable-next-line sonarjs/no-os-command-from-path + const verifyResult = spawnSync("bun", [...verifyArgs], { stdio: "inherit" }); + // Best-effort cleanup of tmpdir (dry-run wrote nothing, so it's empty). + try { + rmSync(tmpTargetRoot, { recursive: true, force: true }); + } catch { + // ignore — empty dir cleanup failure shouldn't affect verify verdict + } + if (verifyResult.status !== 0) { + console.error(`zeta-creds-picker: verify FAILED (restore --dry-run exit ${verifyResult.status})`); + console.error(` The blob at ${parsed.output} was written but cannot be decrypted/parsed.`); + console.error(` Re-run the picker with the same passphrase to overwrite + retry.`); + return 5; + } + console.log(`=== Verify PASS: blob decrypts cleanly + manifest parses ===`); + } return 0; }