Skip to content

feat(vault): backup export, independent import and merge migration (A3) - #387

Merged
ety001 merged 4 commits into
nextfrom
feat/vault-a3-backup-import
Nov 4, 2026
Merged

ety001 merged 4 commits into
nextfrom
feat/vault-a3-backup-import

Conversation

@ety001

@ety001 ety001 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Part of #381

Summary

A3 task package: src/lib/vault/backup.ts (new, 431 lines) + tests/unit/vault-backup.test.ts (new, 27 cases). Zero diff on all existing files — the seven landed vault modules and every existing test are untouched; the new code composes the A1.1 shared validators and the A2 registry/service primitives only.

Kickoff A3 implementation rules — how each is met

  1. Export copies the five-field envelope only — exportVaultBackup extracts the envelope via vaultRecordToEnvelope (fail-closed re-validation included); revision/lockEpoch/timestamps never enter a backup. Reading existing ciphertext needs no new IV (asserted byte-identical salt/iv/ciphertext). lastBackupAt is updated through swapRecord CAS; VAULT_SESSION_STALE propagates per the registry contract (tested with an injected concurrent lockEpoch bump).
  2. Independent import: authenticated decrypt → validate → new salt/IV → re-encrypt once, policy-gated, not activated — see composition note below; the new vault is created by createVault (fresh salt/IV minted inside, password policy enforced) and the payload written under a second fresh IV. settings.activeVaultName is never touched (asserted).
  3. Local WIF→public-key / account normalization / role / (accountName, role, publicKey) dedupe — all through the shared validateVaultWif / validateVaultPayloadText entry points (never re-implemented here); fully offline; backup contents are never treated as on-chain authority proof.
  4. Merge: auto-backup target first, then merge flat array under the target KEK with a fresh IV, alias unchanged, failures keep the original ciphertext — the pre-merge five-field envelope is snapshotted and returned as preMergeBackup (the internal safety net; memory-only, deliberately does NOT update lastBackupAt — that marker belongs to user-initiated exports). Merging happens inside mutatePayload (fresh IV + CAS, A2 primitive); target entries win (accountName, role, publicKey) conflicts (target-first iteration, target's raw spelling preserved); target entries are re-validated through validateVaultWif — an invalid target entry aborts with VAULT_KEY_INVALID and nothing is written.
  5. Resource limits enforced before any write via the shared validators — envelope caps in validateVaultEnvelope, payload caps in validateVaultPayloadText (inside mutatePayload for merges, so an over-limit merged result leaves the target byte-identical). No bounds are re-derived at call sites.

Kickoff A3 completion criteria — evidence

  • Five-field backup round-trip: export asserts exactly ['ciphertext','iv','salt','vaultName','version'] keys and byte-equal crypto material.
  • Independent import uses new salt/IV: asserted ≠ source salt/IV/ciphertext; the new vault decrypts under the same password (single-password contract) to exactly the normalized, deduplicated source payload.
  • Merge dedupes on (accountName, role, publicKey): union + conflict case asserts the target entry (raw Alice spelling) wins and the source-only entry is added normalized; target-internal duplicates also collapse.
  • Target alias unchanged; no automatic activation (settings asserted untouched in both import and merge paths).
  • Full rollback on failure: source-side failures (wrong password, invalid entry), write-side failures (over-limit, injected CAS staleness) all assert the target record toEqual(before); a failed import removes the just-created vault (tested — no empty vault left behind).
  • All boundary and WIF checks pass (see Test plan); nothing auto-activates.

Design decisions worth review attention

  • createVault composition (signature gap, not worked around by editing A2): the A2 createVault({vaultName, password}) takes no payload and always creates an empty vault. Independent import therefore composes createVault (atomic alias uniqueness + fresh salt/IV + policy) → in-scope service.unlock → mutatePayload (payload under a second fresh IV, revision 1, CAS). Cost: one extra PBKDF2 derivation per import. Write path still goes exclusively through the registry's atomic transaction and the service mutator — no direct IndexedDB access anywhere.
  • Invalid role → VAULT_PAYLOAD_INVALID, not VAULT_KEY_INVALID: role legality is enforced by the shared validateVaultPayloadText (the pipeline's step 2, before per-entry WIF checks), and the kickoff assigns VAULT_KEY_INVALID to WIF/public-key/account failures. An explicit per-entry role check would be unreachable dead code; the test documents the composed behavior.
  • Oversized-plaintext imports reject at the envelope cap with the same code: the payload cap (1 MiB − 16) plus the GCM tag equals the ciphertext cap (1 MiB), so a decryptable envelope can never carry an over-cap payload — the tighter envelope cap fires first with the same VAULT_LIMIT_EXCEEDED. The payload-layer branch is exercised for real on the in-memory merge path (401-key merged result) and asserted directly against validateVaultPayloadText.
  • exportVaultBackup bumps revision: swapRecord's contract mandates revision+1 per write, so recording lastBackupAt also invalidates other tabs' unlocked sessions for that vault — documented in the JSDoc as the accepted cost of the only mutating primitive.
  • Merge returns { status, preMergeBackup }: the auto-backup safety net is returned to the vault-internal caller (A4/A5) instead of dangling as an unused local; it stays memory-only and never touches lastBackupAt.
  • Raw-JSON 1 MiB gate: importVaultBackup takes the parsed envelope object per the A0 ImportVaultParams contract; the raw-text gate (parseVaultEnvelopeJson, same shared constants) is what A4/A5 callers compose before this call — covered by a test and noted for A4.

Test plan

  • tests/unit/vault-backup.test.ts: 27/27 passed, 49 PBKDF2 derivations total (2 shared in beforeAll), tests-time 15.5–17.6 s (budget <20 s), shuffle-stable across seeds 1/3/7/11/42/99.
  • Full suite: 132 files / 1461 tests passed (pnpm test).
  • pnpm type-check and pnpm lint clean (strict TS; no new dependencies).
  • Coverage (pnpm test:coverage): src/lib/vault 97.57% stmts / 92.26% branch; backup.ts 97.5% stmts / 92.85% branch / 97.06% lines (100% funcs); global 83.78% / 74.49% (thresholds 80/60). Uncovered lines 248 and 377 are defensive post-hard-line branches (the import final read-back instance guard VAULT_SESSION_STALE, and merge's VAULT_NOT_FOUND race guard).
  • node tests/node/vault-wif-real-check.mjs: 9/9 PASS (rerun discipline; this PR does not change WIF logic — expected and confirmed).

Review rounds

  • Round 1 (3-way): pass with design rulings (composition import acceptable; illegal role -> VAULT_PAYLOAD_INVALID per the A1.1 contract; lastBackupAt CAS side-effect acceptable). Follow-up d15675d1: JSDoc precision (export invalidates sessions in ANY tab incl. the caller's own; text-backup parse contract) + 3 tests (end-to-end bad-role, export->import roundtrip, rollback-failure injection).
  • Round 2 (3-way): pass; independent verification found 1 P2 — the import rollback/unlock adoption were name-bound, so a concurrent remove+same-name-recreate could be cross-deleted or cross-written (experimentally reproduced). Fixed in 3cbb8df2: instance-bound adoption check + instance-bound rollback + instance-bound final read-back, with two race regression tests verified to fail pre-fix; plus empty-source cases and budget-note fixes.
  • Round 3 (3-way): pass. Adversarial replay 6/6 (incl. the swap landing after the adoption check, caught by the service hard line); mutation controls flip the race tests red; normal-path behavior and PBKDF2 budget bit-identical pre/post fix; clean room + CI green. Known residual (P3, documented in code): sub-millisecond read->delete window inside the rollback (needs a conditional-delete registry primitive to fully close).

Not verified here (honest declarations)

  • WIF cases in vitest are mock-driven (tests/mocks/steem-js.ts: isWif overridable, fake getPublicKey) — branch/error-code coverage only; real checksum/derivation evidence is the plain-Node script above (two-layer A1.1 rule).
  • Storage runs on the hand-written fake IndexedDB (documented fidelity bounds: no abort rollback, FIFO queue); cross-tab/real-browser behavior rides on the A2 service hard line, already proven in the A2 review with real Chromium — no new e2e was added for A3 (none was in scope).
  • No Playwright e2e run for this PR (unit-level package; UI wiring is A5).

…oc precision (A3 review)

- exportVaultBackup JSDoc: the lastBackupAt CAS bump invalidates unlocked
  sessions for this vault in ANY tab, including the caller's own service
  instance (next operation -> VAULT_SESSION_STALE); A4/A5 wiring note to
  tolerate re-unlocking (or lock before exporting).
- importVaultBackup JSDoc: document the input contract — text backups go
  through parseVaultEnvelopeJson first (raw-JSON 1 MiB gate); this function
  takes an already-parsed VaultEnvelopeV1 object.
- tests: end-to-end illegal-role import case (real encrypted envelope with
  role 'superuser' -> VAULT_PAYLOAD_INVALID, nothing created); export ->
  import chained roundtrip (envelope bytes survive the CAS marker move,
  new vault decrypts to the source payload under the same password);
  removeVault rollback-failure injection (primary error propagates, the
  residual is an unlockable EMPTY vault, locked against drift).
- whole-file PBKDF2 budget comment updated (24 -> 34 derivations).
…nstance (A3 review r2)

P2: the independent import's create->unlock gap spans a ~1s PBKDF2
derivation, during which another tab may remove the just-created vault
and recreate the same name. The unlock looks the vault up by name and
VaultServiceStatus exposes no createdAt, so the session could adopt the
replacement record (writing the source payload into the other tab's
vault) and the bare-name rollback could delete it. The import path is
now instance-bound end to end:

- adoption check after unlock, before the payload write: re-read the
  record and require the created instance (same name + createdAt),
  else reject VAULT_SESSION_STALE and roll back without writing;
- rollback re-reads and deletes ONLY the created instance; a same-name
  replacement is left untouched (stand down, primary error propagates);
  absent records still get the best-effort removal (NOT_FOUND swallowed);
- the final read-back also verifies the instance before returning it.

P3 follow-ups from the same review round:
- mergeVaultBackup JSDoc: empty/no-change merges are still rewrites
  (fresh IV + revision +1), no equality short-circuit by design;
- tests: empty-source import and empty-source merge semantics locked;
- tests: header PBKDF2 budget corrected (the bad-role case was both
  listed separately and counted in 'nine') and rebudgeted for the new
  cases (49 derivations total).
- Status header: A3 landed (PR #387); A4-A6 remain
- Module map gains backup.ts and vault-backup.test.ts entries
- A3 extension checklist rewritten as wiring rules: text backups through
  parseVaultEnvelopeJson, single-password contract, instance-bound import
  adoption/rollback (with the documented sub-ms residual window),
  target-first merge semantics incl. empty-source rewrite, export's
  revision bump invalidating sessions in any tab

Part of #381
@ety001
ety001 merged commit 358951c into next Nov 4, 2026
2 of 3 checks passed
@ety001
ety001 deleted the feat/vault-a3-backup-import branch November 4, 2026 00:14
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.

1 participant