Skip to content

feat(B-0852.1): TS crypto module — HKDF-SHA256 + AES-256-GCM for credential persistence (pure functions; 18 unit tests; smallest concrete substrate slice)#5411

Merged
AceHack merged 3 commits into
mainfrom
feat/b-0852-1-crypto-module-aes-gcm-hkdf
May 27, 2026
Merged

feat(B-0852.1): TS crypto module — HKDF-SHA256 + AES-256-GCM for credential persistence (pure functions; 18 unit tests; smallest concrete substrate slice)#5411
AceHack merged 3 commits into
mainfrom
feat/b-0852-1-crypto-module-aes-gcm-hkdf

Conversation

@AceHack
Copy link
Copy Markdown
Member

@AceHack AceHack commented May 27, 2026

Summary

Smallest concrete substrate slice of B-0852 cred-persistence. Pure crypto functions; no I/O; 18 unit tests covering the threat model.

Threat model (Phase 1 scope per B-0852 row body)

  • HKDF-SHA256 binds key to (USB-UUID || passphrase) — copying ESP contents to a different-UUID USB cannot decrypt (defeats "copy to uuid" attack named by Aaron 2026-05-27)
  • AES-256-GCM authenticated encryption rejects tampered ciphertext/tag/salt
  • Wrong passphrase → different derived key → GCM auth tag fails → returns { error } (NOT garbled plaintext)
  • Phase 3 (NOT this row): hardware-bound keys defer the "stole USB + knows pass + knows UUID" case

Files

File Lines Purpose
tools/installer/zeta-creds-crypto.ts ~135 deriveKey + encrypt + decrypt pure module
tools/installer/zeta-creds-crypto.test.ts ~190 18 acceptance tests

Test output

 18 pass
 0 fail
 23 expect() calls
Ran 18 tests across 1 file. [103.00ms]

Tests cover: round-trip (small/empty/1MiB), wrong passphrase, wrong UUID (copy-to-different-USB attack), tampered ciphertext (byte flip), tampered tag, tampered salt, malformed IV/tag sizes, HKDF determinism, HKDF independence (UUID/passphrase/salt sensitivity), salt + IV freshness across calls.

Composes with

  • B-0852 (parent row) — credential persistence on USB ESP + boot-sequence auth-method picker
  • B-0852.2 (next sub-row) — TS persist/restore CLIs consuming this module's encrypt/decrypt
  • B-0852.5 (sibling sub-row) — declarative cred-manifest schema (this module is the cipher layer; manifest is the data-shape layer)
  • node:crypto hkdfSync + createCipheriv("aes-256-gcm", ...) — standard primitives; no third-party dep

What this is NOT

  • NOT the persist/restore CLI (B-0852.2)
  • NOT the cred-manifest schema (B-0852.5)
  • NOT the NixOS module (B-0852.4)
  • NOT serialization of the envelope (B-0852.2 will length-prefix-pack salt+iv+tag+ciphertext)
  • NOT hardware-bound keys (Phase 3; explicit deferral)

Test plan

  • bun test tools/installer/zeta-creds-crypto.test.ts → 18 pass
  • prettier --check clean
  • CI passes typecheck + format + test gates
  • No third-party dep added (verified: node:crypto + bun:test only)

🤖 Generated with Claude Code

… persistence

Smallest concrete substrate slice of B-0852 (credential persistence on USB
ESP). Pure functions; no I/O. 18 unit tests covering round-trip + wrong-
passphrase + wrong-UUID + tamper-rejection + size validation + empty/large
plaintext cases.

Two files:
- tools/installer/zeta-creds-crypto.ts — deriveKey + encrypt + decrypt
  using node:crypto's hkdfSync + createCipheriv("aes-256-gcm", ...). Pure
  module; no FS, no side effects. Result type Buffer | { error } for
  decrypt (substrate-honest: failure IS a value, no try/catch at callers).
- tools/installer/zeta-creds-crypto.test.ts — 18 acceptance tests
  validating the threat model claims in the module's header.

Threat model (Phase 1 scope per row body):
- HKDF binds key to (USB UUID || passphrase) — copying ESP contents to a
  different-UUID USB cannot decrypt (defeats "copy to uuid" attack named
  by Aaron 2026-05-27: "we can put a key on the usb too if wnated tied to
  the uuid so it can't be copied to uuid")
- AES-GCM authenticated encryption rejects tampered ciphertext/tag/salt
- Wrong passphrase → different derived key → GCM auth tag fails → error
  (NOT garbled plaintext)

Phase 3 (NOT this row): hardware-bound keys (TPM / YubiKey / Touch-ID-
derived) defeat "attacker stole USB AND knows passphrase AND knows UUID"
case; row body explicitly defers this to Phase 3.

Composes with:
- B-0852 parent row (this is sub-row .1; smallest concrete slice)
- B-0852.2 (TS persist/restore CLIs; consume encrypt + decrypt)
- B-0852.5 (cred-manifest schema; pure declarative; sibling module)
- node:crypto hkdfSync (standard primitive; no third-party dep)

Test output: 18 pass / 0 fail / 23 expect() calls / 103ms.
Format: prettier-clean.
Copilot AI review requested due to automatic review settings May 27, 2026 06:53
@AceHack AceHack enabled auto-merge (squash) May 27, 2026 06:53
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…HKDF — passphrase-based KDF discipline

Security review HIGH finding 2026-05-27:
> Insecure Password Hashing / Weak KDF — HKDF assumes high-entropy IKM.
> Operator passphrases are low-entropy; HKDF alone is brute-force vulnerable.

Two-layer derivation now:
  1. scrypt(passphrase, salt, 32, {N: 2^17, r: 8, p: 1, maxmem: 256MB})
     → stretched 32 bytes (memory-hard; ~128MB per guess; ~1-2s per derivation)
  2. HKDF-SHA256(usbUuid || stretched, salt, info=HKDF_INFO)
     → 32 byte AES-256 key (binds to USB UUID for copy-attack defense)

OWASP 2026 recommended scrypt parameters. ~1-2s per operator boot is
acceptable; brute-force cost on attacker is the load-bearing defense.

Test impact: suite went from 103ms → 5.86s (scrypt called ~36 times across
18 tests). Acceptable for CI (well under timeout); operationally meaningful
security improvement.

All 18 tests still pass. Round-trip + wrong-pass + wrong-UUID + tamper
rejections + size validation all hold.

Composes with:
- B-0852 row's Phase 3 deferral note (hardware-bound keys still future)
- node:crypto scryptSync (standard primitive; no third-party dep)

Per .claude/rules/methodology-hard-limits.md: substrate-honest discipline
includes upgrading security when review surfaces real attack class.
@AceHack AceHack enabled auto-merge (squash) May 27, 2026 06:54
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a small, pure TypeScript crypto substrate for B-0852 credential persistence, implementing key derivation + authenticated encryption and validating the Phase-1 threat model via Bun unit tests.

Changes:

  • Introduces deriveKey (HKDF-SHA256) and encrypt/decrypt (AES-256-GCM) as pure functions with a structured envelope shape.
  • Adds 18 Bun acceptance tests covering round-trips, wrong passphrase/UUID, and tamper rejection cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
tools/installer/zeta-creds-crypto.ts New pure crypto module: HKDF-based key derivation and AES-256-GCM encrypt/decrypt with envelope output.
tools/installer/zeta-creds-crypto.test.ts New Bun test suite validating round-trip behavior and tamper/wrong-key rejection properties.

Comment thread tools/installer/zeta-creds-crypto.ts
Comment thread tools/installer/zeta-creds-crypto.ts
Comment thread tools/installer/zeta-creds-crypto.ts
Comment thread tools/installer/zeta-creds-crypto.test.ts
Comment thread tools/installer/zeta-creds-crypto.test.ts
@AceHack AceHack disabled auto-merge May 27, 2026 07:00
…oken test fixture + wrong-length-salt test

PR #5411 Copilot review:

P1 (correctness): decrypt() called deriveKey(envelope.salt) without
validating salt length. deriveKey throws on wrong-length salt → defeats
the "no try/catch at callers" contract. Fix: validate salt.length up
front; return structured { error } on mismatch (mirrors existing IV +
tag length validation pattern).

P2 (defensive): test fixture used "ghp_fake_for_test_only" which
matches GitHub PAT regex → could trip secret-scanners with false
positives. Fix: replaced with clearly-non-token strings
"TEST-FIXTURE-NOT-A-REAL-TOKEN-deadbeef-cafebabe" + comment explaining
why.

P2 (coverage): no test asserted wrong-length-salt envelope returns
error (not throws). Fix: added test case locking in the no-throw
contract.

Two findings resolved as-is (NOT changes):

- "consider scrypt before HKDF" — ALREADY ADDRESSED in prior commit
  941106f (security-review HIGH fix); thread filed against original
  commit before scrypt addition; resolve no-op
- "typo 'wnated'" — quoting Aaron's verbatim message; preserve typos
  exactly per substrate-honest discipline (resolve no-op with note)

All 19 tests pass (added 1 new test; up from 18).
@AceHack AceHack enabled auto-merge (squash) May 27, 2026 07:01
@AceHack AceHack merged commit 0e9456a into main May 27, 2026
32 checks passed
@AceHack AceHack deleted the feat/b-0852-1-crypto-module-aes-gcm-hkdf branch May 27, 2026 07:05
AceHack added a commit that referenced this pull request May 27, 2026
… tests) (#5414)

B-0852 sub-row .5 — smallest pure-data substrate slice. No runtime deps;
pure types + validator. Composes with B-0852.1 crypto module (PR #5411)
as the data-shape layer to the cipher layer.

Aaron 2026-05-27 discipline: "the keep credentials options we should
declare each credential we need and save and restore so it's not so
imparative too." Adding a new credential type = manifest edit, not a
code change.

Two files:
- tools/installer/zeta-creds-manifest.ts — CredentialEntry + Manifest
  types; DEFAULT_MANIFEST (6 entries: gh-cli + claude + gemini + codex
  + ssh-host-keys + ssh-operator-pubkey); validateManifest() pure
  function returning { ok } | { error } (no-throws contract).
- tools/installer/zeta-creds-manifest.test.ts — 21 tests covering:
  DEFAULT_MANIFEST internal consistency + Phase 1 vendor coverage +
  personaScoped flags + happy-path validation + 12 rejection cases
  (non-object, wrong schema version, duplicate ids, empty paths,
  type mismatches, error accumulation).

Test output: 21 pass / 0 fail / 37 expect() calls / 111ms.
Format: prettier-clean. No third-party deps.

Default manifest entries:
- gh-cli (personaScoped:false; required:true; B-0847 may flip future)
- claude / gemini / codex (personaScoped:true; required:true; per-AI identity)
- ssh-host-keys (personaScoped:false; required:false; regen acceptable)
- ssh-operator-pubkey (personaScoped:false; required:true; iter-4.2 compose)

Composes with:
- B-0852 (parent) — credential persistence row
- B-0852.1 (PR #5411) — crypto module; cipher layer (this row is data layer)
- B-0852.2 (next) — persist/restore CLIs consume both .1 + .5
- B-0847 — per-AI GitHub identity; future personaScoped:true flip for gh-cli
- iter-4.2 ESP SSH pubkey injection — composes with ssh-operator-pubkey entry

Co-authored-by: Lior <lior@zeta.dev>
AceHack pushed a commit that referenced this pull request May 27, 2026
…attribution + ghp_ sweep + beforeAll/afterAll cleanup + pure-resolution wording

PR #5418 Copilot review caught 5 findings on the per-cred type handler
module. All 5 valid; fixed in single commit before re-arming auto-merge.

P0 SECURITY (parseBakeCredArg secret leak):
  Before: error messages included raw `arg` which may contain a PAT /
          JSON cred / SSH key. Operator typo → secret echoed to logs.
  After:  errors include only id (operator-controlled name); value-
          source explicitly omitted with comment naming the leak risk.

P1 codebase convention (name attribution in code):
  Before: header attributed CLI-override design to a named individual.
  After:  role-ref form "operator-named; substrate-anchor in B-0852 row
          body" per repo's no-name-in-code rule.

P1 operational (ghp_ token fixtures trip secret scanners):
  Before: 3 occurrences of "ghp_*" in test fixtures.
  After:  swept to "TEST-NOT-A-REAL-TOKEN-*" prefix (same pattern as
          PR #5411 B-0852.1 already used).

P1 maintainability (tmp-dir cleanup as teardown test):
  Before: describe block created tmpdir at evaluation time; cleanup via
          dedicated "teardown" test (skip-filterable; order-dependent;
          leaks if earlier test throws).
  After:  beforeAll() + afterAll() — afterAll always runs even on test
          failure; canonical bun:test cleanup pattern.

P2 documentation (mislabeled "pure" function):
  Before: "Pure resolution layer (file read + env access only — no
          network)." — the file/env reads are side effects.
  After:  "Local-only side effects: file read + env access. No network.
          Not pure."

All 60 tests still pass (now 59 actually — removed the dedicated
"teardown" test since beforeAll/afterAll handle cleanup correctly).

Resolves Copilot threads PRRT_kwDOSF9kNM6FBxi3 + PRRT_kwDOSF9kNM6FBxjO
+ PRRT_kwDOSF9kNM6FBxjc + PRRT_kwDOSF9kNM6FBxjp + PRRT_kwDOSF9kNM6FBxj3
on PR #5418.
AceHack added a commit that referenced this pull request May 27, 2026
…ve literal/@file/env:VAR + per-type validation (60 unit tests; pure TS) (#5418)

* feat(B-0852.10): per-cred type handlers — parse <id>=<source> + resolve literal/@file/env:VAR + per-type validation (60 unit tests)

B-0852 sub-row .10 — pure TS module composing B-0852.5 (declarative
manifest; landed PR #5414) + B-0852.1 (crypto module; landed PR #5411)
toward B-0852.9 zflash --bake-cred CLI override per Aaron 2026-05-27
CLI-override design.

Three pure layers:
1. parseBakeCredArg(arg) — splits "<id>=<source>" preserving = in value
2. resolveValueSource(source) — handles literal / @file / env:VAR
3. validateValue(buf) per cred-type handler — PAT / JSON / SSH pubkey

Per-type handlers (one per default manifest entry):
- GH_CLI_HANDLER       — literal/file/env; non-empty string
- CLAUDE_HANDLER       — literal/file; valid JSON object
- GEMINI_HANDLER       — literal/file; valid JSON object
- CODEX_HANDLER        — literal/file; valid JSON object
- SSH_OPERATOR_PUBKEY  — literal/file; OpenSSH key-type prefix check
- SSH_HOST_KEYS        — Phase 1 deferred (empty supportedSources)

resolveBakeCred() full pipeline composes the three layers + gates
unsupported source types per handler.supportedSources (e.g., claude
rejects env: source — JSON creds belong in files, not env vars).

Test output: 60 pass / 0 fail / 71 expect() calls / 106ms.
Format prettier-clean. No third-party deps; node:fs + node:os only.

Composes with:
- B-0852 parent row (CLI-override design per Aaron 2026-05-27 sharpening)
- B-0852.5 (manifest schema) — handler.id matches manifest entry id
- B-0852.1 (crypto module) — resolved bytes feed encrypt() in next slice
- B-0852.9 future — zflash --bake-cred CLI consumes this module

* fix(B-0852.10): 5 Copilot findings — P0 secret-leak redaction + name attribution + ghp_ sweep + beforeAll/afterAll cleanup + pure-resolution wording

PR #5418 Copilot review caught 5 findings on the per-cred type handler
module. All 5 valid; fixed in single commit before re-arming auto-merge.

P0 SECURITY (parseBakeCredArg secret leak):
  Before: error messages included raw `arg` which may contain a PAT /
          JSON cred / SSH key. Operator typo → secret echoed to logs.
  After:  errors include only id (operator-controlled name); value-
          source explicitly omitted with comment naming the leak risk.

P1 codebase convention (name attribution in code):
  Before: header attributed CLI-override design to a named individual.
  After:  role-ref form "operator-named; substrate-anchor in B-0852 row
          body" per repo's no-name-in-code rule.

P1 operational (ghp_ token fixtures trip secret scanners):
  Before: 3 occurrences of "ghp_*" in test fixtures.
  After:  swept to "TEST-NOT-A-REAL-TOKEN-*" prefix (same pattern as
          PR #5411 B-0852.1 already used).

P1 maintainability (tmp-dir cleanup as teardown test):
  Before: describe block created tmpdir at evaluation time; cleanup via
          dedicated "teardown" test (skip-filterable; order-dependent;
          leaks if earlier test throws).
  After:  beforeAll() + afterAll() — afterAll always runs even on test
          failure; canonical bun:test cleanup pattern.

P2 documentation (mislabeled "pure" function):
  Before: "Pure resolution layer (file read + env access only — no
          network)." — the file/env reads are side effects.
  After:  "Local-only side effects: file read + env access. No network.
          Not pure."

All 60 tests still pass (now 59 actually — removed the dedicated
"teardown" test since beforeAll/afterAll handle cleanup correctly).

Resolves Copilot threads PRRT_kwDOSF9kNM6FBxi3 + PRRT_kwDOSF9kNM6FBxjO
+ PRRT_kwDOSF9kNM6FBxjc + PRRT_kwDOSF9kNM6FBxjp + PRRT_kwDOSF9kNM6FBxj3
on PR #5418.

---------

Co-authored-by: Lior <lior@zeta.dev>
AceHack added a commit that referenced this pull request May 27, 2026
…0 substrate for Ace migration trajectory (14 sub-steps; 12 declarative-input categories; substrate-anchor for B-0852/0853/0855/0856 cross-refs) (#5420)

* docs(B-0854.1): zeta-install.sh step-state-machine inventory — Phase 0 substrate for Ace migration trajectory

B-0854 sub-row .1 (Phase 0; smallest pure-analysis slice). Documents
the EXISTING imperative bash state-machine in zeta-install.sh so the
B-0854 Phase 2 declarative-Ace-manifest schema can express the same
surface.

Inventory covers:
- Top-level entry (REPO_URL, HOST, ZETA_AUTO_CONFIRM env semantics)
- Step-by-step state machine for all 14 sub-steps (1, 2, 3, 4, 5, 6,
  6.5, 6.55, 6.6, 6.7, 6.8, 6.9, 6.95, 7) with inputs/outputs/side-
  effects/failure-modes/declarative-equivalent per step
- Cross-cutting: operator-prompt accumulation count (7 prompts today;
  B-0852 phase-split target = 1 passphrase prompt)
- Idempotency surface table — informs B-0855 architectural fix scope
- 12 distinct declarative-input categories the Ace manifest must
  capture (Phase 2 sub-row scope)
- Files-generated-during-install table mapping to B-0852.5 cred-
  manifest entries (6 mapped, 3 candidate-expansion items named)

Snapshot date: 2026-05-27 (origin/main 70596a8; PR #5417 cosign
merge). Future refreshes should re-snapshot when zeta-install.sh
changes substantially.

Composes with already-landed substrate-engineering arc:
- B-0852 + sub-rows (cred persistence) — PR #5403/#5411/#5414
- B-0853.1 (cosign signing) — PR #5417 + fix-fwd #5419
- B-0855 (self-register architectural fix) — PR #5412
- B-0856 Path A (deferred /tmp coordination) — PR #5413
- B-0854 parent (Ace migration trajectory) — PR #5405

No code change; pure documentation. Doesn't affect ISO substrate;
batches into substrate-engineering history independent of next ISO
build cycle.

* fix(B-0854.1): escape | inside code spans for MD056 table-column-count compliance

* fix(B-0854.1): 10 Copilot accuracy corrections — verified against actual zeta-install.sh content

PR #5420 Copilot review caught 10 substantive accuracy issues in the
B-0854.1 inventory doc. All 10 verified against origin/main 70596a8's
actual zeta-install.sh content + corrected.

Corrections:
- Name attribution → role-ref ("the human maintainer")
- Step 1 inputs: actual `lsblk -d -p -n -o NAME,TYPE,RM,RO,TRAN` + awk
  filter (not made-up NAME,SIZE,MODEL,TRAN,ROTA)
- Step 3 side effects: `sgdisk --zap-all` only (not `wipefs -af` too)
- Step 4: actual `sgdisk` (NOT `parted`); GPT layout via -n + -t flags;
  whole-disk longhorn partitions on DATA_DISKS too
- Step 6: `nixos-generate-config --root /mnt --force` (NOT
  --no-filesystems; --force overwrites existing config)
- Step 6.5: no MAGIC_NUMBER (didn't exist in script); INJECT_OK gate
  flag; iter-4 v1 manual-config-edit fallback path
- Step 6.9: SELF_REG_OK flag; documented graceful-skip path lines 731+
- nixos-install: actual line ~1004 (NOT 1096-1340); section renamed
  to "nixos-install (the actual build; ~line 1004)" since the prior
  range was wrong
- Step 7: actual lines 1261-1336 (NOT 1341-1352); banner driven by
  GH_AUTH_OK/GH_KEY_COUNT/INJECT_OK/SELF_REG_OK (NOT MAGIC_NUMBER);
  conditional sections listed in declarative equivalent

Resolves 10 Copilot threads on PR #5420.

Root cause of the inaccuracies: original draft was written from
`grep -E "^# ── Step"` summaries + recollection of script behavior,
not careful per-step body reads. Discipline lesson: when authoring
substrate-anchor docs claiming to inventory existing code, the read
must be careful per-line, not skim-grep summary. Composes with
.claude/rules/verify-existing-substrate-before-authoring.md at the
inventory-substrate scope (verify-content-of-thing-being-inventoried
before authoring claims about its content).

---------

Co-authored-by: Lior <lior@zeta.dev>
AceHack added a commit that referenced this pull request May 27, 2026
…xt schema (17 unit tests) (#5421)

B-0852 sub-row .2 first slice — the on-disk wire format that B-0852.2b
persist/restore CLIs will use to read/write /esp/zeta-creds.enc.

Pure functions; no I/O. Two layers:

1. Envelope serialization (binary; little-endian; v1 magic "ZCV1"):
   - 8-byte header: 4-byte magic + 4-byte reserved
   - Length-prefixed: salt (u16) + iv (u16) + tag (u16) + ciphertext (u32)
   - Trailing bytes rejected in v1 (v2 will explicit-version-bump)

2. CredBundle plaintext schema (post-decryption JSON):
   - schemaVersion: 1
   - globalCreds: { id: bytes } (personaScoped=false manifest entries)
   - personaCreds: { persona: { id: bytes } } (personaScoped=true)
   - Bytes base64-encoded inside JSON for safe transport
   - Composes with B-0852.5 manifest's personaScoped flag

Full pipeline (covered by 1 integration test):
  CredBundle → encodeBundle → encrypt (B-0852.1) → serializeEnvelope
  → [disk write/read simulation] → parseEnvelope → decrypt → decodeBundle
  → CredBundle (byte-identical)

Test output: 17 pass / 0 fail / 29 expect() calls / 1.67s (scrypt
dominates timing per B-0852.1 OWASP-recommended N=2^17).

Composes with:
- B-0852 parent (cred persistence)
- B-0852.1 crypto module (merged PR #5411) — Envelope type producer
- B-0852.5 cred-manifest schema (merged PR #5414) — personaScoped semantics
- B-0852.10 per-cred handlers (merged PR #5418) — value producers feed
  globalCreds/personaCreds maps
- B-0852.2b future — persist/restore CLIs consume this module

What this is NOT:
- NOT the persist CLI (next slice; needs FS + passphrase prompt)
- NOT the restore CLI (next slice; same)
- NOT zflash --bake-cred integration (B-0852.9)

Co-authored-by: Lior <lior@zeta.dev>
AceHack added a commit that referenced this pull request May 27, 2026
…ersistence stack (19 integration tests; replaces conflict-dirty #5422) (#5425)

* feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests)

B-0852 sub-row .2 final slice — operational CLI surface that composes
all 4 already-shipped modules:
  - B-0852.1 crypto (encrypt/decrypt with scrypt+HKDF+AES-256-GCM)
  - B-0852.5 manifest (declarative cred catalog)
  - B-0852.10 per-cred handlers (--bake-cred parse + validate)
  - B-0852.2a envelope (wire format + CredBundle plaintext schema)

THREE files:

1. tools/installer/zeta-creds-persist.ts (CLI)
   Usage: bun zeta-creds-persist.ts --usb-uuid <uuid> --output /esp/zeta-creds.enc
          --passphrase-{env VAR | file PATH} [--persona <name>]
          [--bake-cred <id>=<value-source>]...
   Composes --bake-cred args + manifest personaScoped flag → CredBundle →
   encrypt via crypto module → serialize via envelope → write to ESP.

2. tools/installer/zeta-creds-restore.ts (CLI)
   Usage: bun zeta-creds-restore.ts --usb-uuid <uuid> --input /esp/zeta-creds.enc
          --passphrase-{env VAR | file PATH} [--persona <name>]
          [--target-root /] [--dry-run]
   Reads encrypted blob → parseEnvelope → decrypt → decodeBundle → writes
   each cred to its manifest-declared paths under target-root.
   Exit codes: 0=ok / 2=arg-parse / 3=file-read / 4=envelope-parse /
              5=decrypt-fail (wrong pass/UUID/tampered) / 6=bundle-decode /
              7=manifest-mismatch.
   --dry-run prints plan without writing.

3. tools/installer/zeta-creds-persist-restore.test.ts (19 tests)
   - parsePersistArgs / parseRestoreArgs (well-formed + each error path)
   - composeBundle (global / persona / missing-persona / unknown-id)
   - resolveCredPaths (~ expansion + absolute paths under target-root)
   - persist→restore round-trips via tmpdir (gh-cli global + claude persona)
   - wrong-passphrase / wrong-UUID / tampered-blob / invalid-magic each
     surface their canonical exit code via planRestore

Note: passphrase-interactive-prompt deferred to a NixOS module wrapper
(B-0852.4); these CLIs require --passphrase-file or --passphrase-env so
the modules are scriptable + test-driveable. Interactive entry happens
at the install-script Step 6.77 (per B-0852 row body) which prompts +
exports to env before invoking these CLIs.

Test output: 19 pass / 0 fail / 28 expect() calls / 2.06s (scrypt-bound).
Format prettier-clean.

Composes with:
- B-0852 parent (cred persistence)
- B-0852.1 crypto module (merged PR #5411)
- B-0852.5 cred-manifest (merged PR #5414)
- B-0852.10 per-cred handlers (merged PR #5418)
- B-0852.2a envelope (PR #5421 — this PR chains off; will resolve cleanly
  when #5421 merges to main)
- B-0852.4 future — NixOS module wraps these CLIs with passphrase prompt
- B-0852.3 future — zeta-install.sh Step 6.77 picker invokes these CLIs
- B-0852.9 future — zflash --bake-cred at flash-time invokes persist directly

What this is NOT:
- NOT the interactive passphrase prompt (B-0852.4 NixOS-module scope)
- NOT the zeta-install.sh Step 6.77 integration (B-0852.3)
- NOT the zflash --bake-cred at flash time (B-0852.9; same persist CLI
  invoked from operator's Mac instead of target boot)

* fix(B-0852.2b): 14 Copilot findings — P0 CodeQL secret-leak + 5 error-handling bugs + applyPlan refactor (single decrypt) + 0-byte ciphertext + docs

PR #5422 Copilot review caught 14 findings; all valid. Comprehensive
fix-pass before re-arming auto-merge.

P0 CodeQL clear-text-logging (2 alerts; persist.ts L129 + restore.ts L240):
  CodeQL flagged the env-var NAME from --passphrase-env being included
  in error strings (taint tracker treats env[passphraseEnv] access as
  sensitive → var-name becomes tainted). Fix: omit env-var name from
  error message; generic "--passphrase-env target var is not set or is
  empty" instead. Same change in both files.

Error-handling bugs:
  - readFileSync(passphrase-file) in parseArgs not wrapped → could
    throw on permission failure instead of returning {error}. Fixed
    in both persist + restore.
  - readFileSync(input) in restore main() not wrapped → could throw
    on permission failure instead of returning code 3. Fixed.
  - writeFileSync(output) in persist main() not wrapped → could throw
    on unwritable path. Fixed; new exit code 4 for write failure.

applyPlan refactor (P1 design):
  Prior: planRestore + applyPlan each did full parse→decrypt→decode,
         doubling scrypt cost + extending passphrase-derived key
         lifetime in memory.
  Now: planRestore returns RestorePlan with embedded value Buffer per
       write entry; applyPlan takes the plan + just writes (no
       decrypt). Single scrypt invocation per restore.
  Also fixes the silent-skip mismatch — applyPlan now consumes the
  plan's pre-validated writes; can't accidentally bypass the manifest
  match checks.

0-byte ciphertext (P2 envelope):
  MIN_BLOB_LEN was header + lens + 1-byte ciphertext; AES-GCM allows
  empty plaintext/ciphertext. Fixed: drop the +1.

Documentation:
  - parseArgs docs: removed "Pure (no I/O)" claim (does FS reads for
    --passphrase-file); accurate description in new doc comment
  - Usage docs: removed "(interactive prompt)" mention (not
    implemented in this entry-point per design); replaced with
    "interactive prompting is the wrapping NixOS module's
    responsibility (B-0852.4)"

Unused import:
  - tools/installer/zeta-creds-restore.ts: removed unused `join`
    import from node:path

Test updates:
  - applyPlan signature changed; updated 2 test cases to call
    planRestore first + pass plan to applyPlan.
  All 19 persist/restore tests + 17 envelope tests still pass.

Resolves 14 Copilot threads on PR #5422.

---------

Co-authored-by: Lior <lior@zeta.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants