Skip to content

feat(vault): per-key hierarchy with key management and identity login (R1-R3) - #392

Open
ety001 wants to merge 11 commits into
nextfrom
feat/vault-per-key-hierarchy
Open

ety001 wants to merge 11 commits into
nextfrom
feat/vault-per-key-hierarchy

Conversation

@ety001

@ety001 ety001 commented Nov 6, 2026

Copy link
Copy Markdown
Member

Part of #381

Summary

Replaces the A0–A5 whole-envelope vault with the per-key hierarchy (handoff R1–R3) and adds real-browser coverage. One PR by design.

R1 — hierarchical V1 envelope, per-key encryption

  • Root { version: 1, vaultName, salt, accounts }; each account has owner/active/posting/memo arrays of { iv, ciphertext, note }. Outer account names, roles and notes are public (readable while locked and in backups; all four locales warn that notes must not hold secrets).
  • One vault password + shared salt (PBKDF2-SHA256 600k), a fresh random 12-byte IV for every encryption, vault-wide IV uniqueness, AAD unchanged (steem-wallet.vault.v1). No migration layer: the whole-envelope format never shipped.
  • Signing takes keyIv + vaultPassword, decrypts only the selected key, zero-fills the KEK and clears the password/WIF in finally; SignResult.locked is always true. No global "all WIFs in memory" state exists.
  • Kept from A2–A4: revision/lockEpoch/createdAt CAS, cross-tab hard line, post-merge lock, preMergeBackup delivery, target-first dedupe on (accountName, role, publicKey), failure atomicity, authority pre-check traffic contract, recover_account exclusion, memo never signs, privateKey default.
  • Merge authenticates the target with targetPassword (no pre-unlock). Empty-vault rulings: merge into and change-password on a keyless vault are rejected with VAULT_EMPTY (no silent re-key under a typo); unlock and add-key on an empty vault stay allowed.

R2 — key management and notes

  • key-management.ts: one sorted, de-duplicated fresh: true accounts query; scans every key_auths entry and compares memo against memo_key; degraded/missing/malformed data fails closed (VAULT_AUTHORITY_UNAVAILABLE). Only account names leave the browser.
  • vault-key-manager.tsx / import-key-dialog.tsx: locked browsing of accounts, per-role key counts and notes; two-step add (local WIF check → on-chain verdict → confirm); idempotent duplicates; removal by account + role + IV with a second confirmation; note editing. Sub-threshold and not-on-chain keys may be stored with an explicit warning; the single-key signer still fails closed. WIF lives only in the dialog's transient state and is cleared on success, cancel and unmount.

R3 — identity selection without unlocking

  • LoginForm is now a signing-mode dispatcher; the classic form moved verbatim into PrivateKeyLoginForm (insert-only diff, existing login tests unchanged).
  • vault-login-form.tsx: two native selects (vault, account) fed by the public directory; default active vault; switching clears the account; stale responses are dropped; empty/no-vault/storage-error states never fall back to PrivateKey.
  • auth/setVaultIdentity sets sessionKind: 'vault' and nulls all six key fields. No unlock, no decryption, no /api/auth/challenge|login, no Legacy remembered-key access; vault logout is local-only.
  • Embedded re-auth callers (fixedUsername / requiredAuthTypes) show an explicit "not available in Vault Mode yet" notice until A6.

Review

  • R1: three-way review (spec / crypto / engineering) → fix round (65bb7d35: empty-vault anchors, restored backup coverage, dead KEK derivation removed, case-variant merge target) → three-way verification (adversarial replays, mutation checks, end-to-end journey).
  • R2: focused security/spec review, five P3s folded into R3.
  • Integration review on the full branch: spec/acceptance and engineering/e2e passed; the security lane found a P1 — the claim-rewards banner restored the Legacy remembered posting key for a keyless vault session and signed with it. Fixed in 202f7425 (gate on sessionKindOf(auth) !== 'vault', regression test mutation-checked), then a post-fix three-way round passed: fix verification, remaining security items (raw IndexedDB scan after create/add/export/import/merge/change-password shows no WIF, password or KEK; all key consumers fail closed under a vault session; DevTools sanitizer unchanged), full verify + e2e.
  • e2e surfaced and fixed one product bug: useSigningMode instances held stale local state after a switch inside the Work Mode modal (300ce8c0, re-read on modal close and tab visibility).

Test plan

  • pnpm verify: exit 0 — type-check clean; lint 0 errors (3 pre-existing warnings in untouched files); 145 files / 1528 tests; coverage 84.52% stmts / 75.68% branches / 83.23% funcs / 86.78% lines; production build OK.
  • pnpm e2e: 14/14 (smoke 3, wallet-main-flows 4, vault-mode 2, new vault-per-key 5) against real Chromium IndexedDB + Web Crypto.
  • node tests/node/vault-wif-real-check.mjs: 9/9.
  • Fixture tests/fixtures/vault-development-test-vectors.json is byte-identical to the reviewed source.

Not verified / known limits

  • e2e intercepts /api/query/accounts with controlled responses (fixture accounts do not exist on chain); real-RPC verification is left to a dev deployment.
  • A6 is out of scope: a vault session cannot sign yet (useActiveSigningKey() is null), and the facade's privateKey backend is not wired. "Decrypt only the selected key" and "scrub before broadcast" are pinned by vault-signer.test.ts, not by e2e.
  • Pre-A6 UX gaps (no key exposure): market/proposals login dialogs pass no requiredAuthTypes, so in Vault Mode they offer identity selection and a later order/vote reopens the dialog; transfer-form / delegate-form keep their hard-coded "Not authenticated" text for a vault session.
  • Documented residuals: rename leaves a session pointer that self-heals on the next unlock; a failed merge/change-password can leave a non-sensitive session pointer; same-millisecond remove+recreate can still collide until records get a random id; a single corrupted ciphertext makes unlock report VAULT_PASSWORD_INVALID.

docs/AI-driver/09-vault.md is rewritten for the landed design (21 invariants, module map, pitfalls); 02-auth.md and 06-frontend.md are updated for the dispatcher and the vault-session exclusions.

ety001 added 11 commits November 5, 2026 14:23
…ct, unify merge semantics

First step of the per-key hierarchy handoff plan:

- vault-backup.test.ts: fix two fixture defects (export-name expectation
  now demo; mergeSource re-encrypted under sourceKek so SOURCE_PASSWORD
  auth reaches the real paths) and replace the vacuous tamper test with
  a real byte-flip on the stored target ciphertext (auth failure, target
  record byte-identical). Production code re-audited after green: no bugs
  found — all three failures were fixtures.
- vault-manager.test.ts (20 cases) and vault-signer.test.ts (31 cases)
  rewritten for the hierarchical contract (keyIv + vaultPassword signing,
  selected-key-only decryption, SignResult.locked true, password cleared
  after use, activateVault instead of unlock, locked-readable account
  directory) while preserving every A4-era security assertion (post-merge
  lock hard contract, C2 interleavings, changePassword races, authority
  pre-check branches, chain-semantics key selection, recover_account
  exclusion, frozen-tx integrity + real txid vector).
- signing-facade / panel / envelope / registry / service tests updated
  to the V1 literal and four-field envelope shapes; type-check to zero.
- vault-manager.tsx merge form: drop the !locked submit gate and
  pre-unlock block; manager.importVaultDetailed now authenticates the
  target itself via targetPassword (probe unlock when no session),
  keeping the post-merge lock blueprint contract unchanged.

Known items deferred to R1 review: empty-vault password semantics (any
non-empty password unlocks a keyless vault — unchanged, untested),
rename session wrinkle (pinned as KNOWN WRINKLE), failed-merge session
retention (equivalent to old-world behavior).

Part of #381
…ranch-only)

Rewrite the vault module doc from the retired whole-envelope implementation
to the per-key V1 hierarchy that landed on feat/vault-per-key-hierarchy
(three commits on top of origin/next@cee626a2; the branch is NOT merged
into next and no PR exists yet — everything documented is branch-only).

- header: R1 marked LANDED on this branch with truthful baseline/PR status
- module map: per-file responsibilities for the hierarchical envelope
  validation, single-key crypto primitives, pointer-session service,
  targetPassword merge probe, IV-selected-key signer, four-field backup
- invariants: rewritten around per-key {iv, ciphertext, note}, public outer
  metadata, single password/salt, no global WIF-unlocked state, signing
  scrub-before-broadcast, merge/authority/recover_account contracts
- pitfalls: added R1-era items (undecided empty-vault unlock semantics,
  rename lingering session, failed-merge session retention, locked-readable
  public directory); retired whole-envelope-era items
- extension checklist: R2/R3 pointers to the handoff task package

Only the doc changed; no code touched (pnpm type-check clean).
…rage, review cleanups (R1)

R1 three-way review follow-ups (2 P1 + 2 P2 + P3 batch):

- Empty-vault password anchors (2026-11-05 ruling): mergeVaultBackup
  rejects a target with zero key ciphertext with VAULT_EMPTY BEFORE any
  password is used — an empty vault has no password anchor, and the
  per-key target decryption loop would iterate zero times, silently
  re-keying the vault under whatever targetPassword was typed.
  changePassword likewise rejects an empty vault (unlock accepts any
  password for one, so the old password cannot be authenticated; the
  next addKey's password is the real anchor). Unlock pass-through,
  addKey-to-empty and independent import of an empty source stay as
  implemented (pinned).
- Restored backup test coverage, rewritten for the per-key hierarchy:
  the import rollback stands down on a same-name remove+recreate (the
  rebuild survives byte-intact), an injected removal failure during
  rollback propagates the PRIMARY CAS error with the created empty
  vault as the residual, export propagates VAULT_SESSION_STALE when
  the record drifts between read and CAS (lastBackupAt untouched), and
  an empty-source import creates a fresh empty vault (pinned).
- registry.createVault no longer derives a KEK it immediately zeroizes
  (dead code since the per-key rework); registry interface/header
  comments updated to the four-field, zero-crypto-at-create reality.
- mergeVaultBackup matches the target via vaultNameComparisonKey, so a
  case-variant target name resolves the session vault exactly like the
  manager does instead of failing VAULT_LOCKED.
- docs/AI-driver/09-vault.md: invariant 18 no longer claims fresh IVs
  for no-change sources (only revision+1 via CAS; every target
  ciphertext byte preserved), the header no longer pins commit
  counts/HEAD, invariant 3 records that the AAD deliberately does not
  bind key identity (relocation is the authority pre-check's job —
  never extend VAULT_AAD), invariants 4/18 spell out the deliberate
  addKey/merge per-key dedupe decryption surface, and the empty-vault
  pitfalls entry records the ruling (was UNDECIDED).
- Merge form targetPassword autoComplete switched to off (vault
  passwords are not site credentials); five-field comment residue
  fixed (vault-manager.tsx, panel test, e2e spec); signing-facade
  pass-through example corrected to VAULT_EMPTY.

Verified: pnpm type-check && pnpm lint && pnpm test (139 files / 1443
tests) && node tests/node/vault-wif-real-check.mjs.
R2 of the per-key vault arc (handoff §5 step 3):

- src/lib/vault/key-management.ts: read-only on-chain verification
  orchestration. verifyKeysOnChain takes WIF-FREE candidates
  (accountName + role + publicKey), runs ONE sorted deduplicated
  batched fetchAccounts fresh:true query (signer pre-check
  discipline: degraded/missing/malformed/fetch failure ->
  VAULT_AUTHORITY_UNAVAILABLE, zero side effects) and reports per
  candidate {matched, authority, weight, threshold, suffices}:
  full key_auths scan of owner/active/posting (most privileged
  match wins, malformed pairs skipped) plus memo_key comparison
  (memo match = 1/1/true). Results are ADVICE, not gates:
  sub-threshold and not-on-chain keys stay storable with UI
  warnings; the single-key signer keeps failing closed.
- import-key-dialog.tsx: two-phase add flow (local validateVaultWif
  -> chain verification -> explicit confirm -> manager.addKey).
  Duplicate detection rides the service's idempotent no-op via a
  before/after public-directory count -> keyAlreadyExists banner.
  WIF lives only in the dialog's transient state, scrubbed on
  success/cancel/unmount; never Redux, logs, analytics, network.
- vault-key-manager.tsx: locked-state browsing of the public
  directory (accounts, per-role key counts, outer notes) with NO
  unlock; note editing (updateKeyNote) and removal (account + role
  + IV pointer behind an explicit confirm, no password) ride the
  R1 service primitives unchanged. Wired into vault-manager.tsx as
  a 'keys' panel view behind the row menu's Manage keys action.
- i18n: 47 new vault-namespace keys per locale (en/zh/es/ko) for
  the key manager, add-key dialog, verification verdicts and the
  scene-level mergeTargetEmpty / changePasswordEmpty VAULT_EMPTY
  messages; the errors table stays locked to VAULT_ERROR_CODES.
- tests: 19 key-management unit cases (batching, full-key_auths
  scan, memo, fail-closed matrix), 10 component cases (locked
  browsing, add branches, duplicate banner, remove confirm, note
  edit, leak assertions), one panel integration case; PBKDF2 count
  header corrected in vault-manager.test.ts (create 0 / import 2 /
  merge 3 / changePassword 3 / unlock 1); 09-vault.md synced
  (module map, R2 checklist, surviving-session pitfall).

Verified: pnpm type-check, pnpm lint (pre-existing warnings only),
targeted suites, full pnpm test (1473/1473) and
node tests/node/vault-wif-real-check.mjs (9/9).
R3 of the per-key vault arc (handoff §5 step 4):

- login-form.tsx: LoginForm becomes a signing-mode DISPATCHER. The
  classic challenge-signed flow moves verbatim into the module-private
  PrivateKeyLoginForm (the file diff is insert-only: behavior, copy,
  remember-me and the #338 re-auth gate are byte-identical). Only a
  positively read 'vault' mode renders vault surfaces; loading, failed
  reads, an unavailable runtime and a missing provider keep the classic
  form (persisted default; login never hard-depends on IndexedDB).
  Vault Mode + embedded re-auth (fixedUsername / requiredAuthTypes)
  renders an explicit "not available in Vault Mode yet" notice with a
  Work Mode shortcut - identity selection cannot satisfy an authority
  requirement; A6 wires the per-key password + signing path.
- vault-login-form.tsx: two native selects over the locked-readable
  public directory (manager.listVaults / manager.getAccounts; account
  names only, keyless accounts not offered). Default = active vault,
  else the first; selections are derived in render, a vault switch
  drops the account choice, directory loads are sequence-guarded and
  keyed by (vaultName, retry generation) so a slow stale response never
  lands. Empty states: no vault -> create/import entry, empty vault ->
  add-a-key hint (both open the Work Mode modal); storage / runtime /
  directory errors -> vault.errors.* + Retry, never a PrivateKey
  fallback. The list and directory refresh stale-while-revalidate when
  the Work Mode modal closes and when the tab becomes visible again.
  Submit dispatches setVaultIdentity and navigates (or onLoginSuccess):
  no unlock, no decryption, no challenge/login, no network.
- auth slice: optional sessionKind ('privateKey' | 'vault'; absent =
  privateKey, so pre-R3 AuthState values stay valid; sessionKindOf).
  setVaultIdentity({ username }) sets sessionKind 'vault' and forces
  all six key fields null; setCredentials writes 'privateKey'; logout
  resets. The action carries no key material (sanitizers unchanged).
- use-auth.ts: logging out a vault session clears only Redux - no
  /api/auth/logout, Legacy remembered username / posting key neither
  read nor removed. home-redirect-or-login.tsx waits for the mode and
  skips the Legacy remembered-username jump in Vault Mode.
- work-mode-events.ts: leaf module for VAULT_WORK_MODE_OPEN_EVENT
  (still re-exported by app-layout) plus the new
  VAULT_WORK_MODE_CLOSED_EVENT, dispatched by app-layout when the modal
  closes; avoids a layout -> header -> login form -> layout import cycle.
- R2 quick-review batch: import-key-dialog verdicts rank findings
  (not-on-chain > sub-threshold > authority differs from the selected
  role [new warn verdict verifyAuthorityDiffers] > memo > matched);
  countRoleKeys compares names through normalizeSteemUsername; tests
  for the VAULT_EMPTY scene messages (changePasswordEmpty /
  mergeTargetEmpty), WIF scrubbing on cancel / Esc / unmount, the
  not-on-chain leak check, and the non-string memo_key branch.
- i18n: 16 auth.vault* keys + vault.verifyAuthorityDiffers in all four
  locales.
- tests: new vault-login-form (19), login-form-dispatch (9, real
  useSigningMode), home-redirect-or-login (6), app-layout-work-mode (2);
  auth-slice / use-auth / key-manager / panel / key-management
  additions. login-form-reauth.test.tsx is unchanged and green. Guards
  (sequence, refresh wiring, redirect gate, verdict order, name
  normalization, scene routing) were mutation-checked. Full suite
  145 files / 1526 tests green; real WIF check 9/9.
- docs: 09-vault.md synced (status, module map, invariant 21 "a vault
  identity is a selection, never an authentication", R3 checklist,
  pitfalls); 02-auth.md notes the dispatcher and the vault logout path.
…and identity selection

Handoff section 5 step 5: extend real-browser e2e over the R1-R3 per-key
vault stack and run the full acceptance suite.

New tests/e2e/vault-per-key.spec.ts (5 tests, real IndexedDB + real Web
Crypto; /api/query/accounts intercepted with controlled responses so every
chain-dependent branch is deterministic):

- main journey: create vault -> add fixture WIF with note (chain-verified
  green) -> browse the directory LOCKED (no unlock; only the per-key
  encryption password is ever typed) -> identity login through the two
  selects -> header session at /@alice/transfers -> directory refresh when
  the Work Mode modal closes;
- no /api/auth/challenge, /api/auth/login or /api/auth/logout request
  during the whole identity flow (page.on request tracking);
- verdict matrix: matched (green), authority-differs (amber, stored under
  the selected role), not-on-chain (amber, fail-open storage with warning),
  plus the fail-closed VAULT_AUTHORITY_UNAVAILABLE path with in-form retry;
- same-role multiple keys, duplicate (account, role, publicKey) idempotent
  banner, removal located by the account+role+IV row pointer;
- multi-vault / multi-account login page, rapid vault switching with no
  cross-vault account leakage, identity submitted from the second vault;
- login-page empty states (no vault / empty vault) with their Work Mode
  entry buttons, and the PrivateKey regression (switching the device back
  renders the classic username/private-key form);
- UI checks: no horizontal overflow (page + scoped element + owning dialog
  box; the shared DialogFooter's deliberate -mx-4 full-bleed band and
  closed-popup <option> rects are excluded as non-overflow by design),
  secondary dialogs at z-60 on top of the Work Mode modal and interacted
  with (click-through proves they are not obscured).

Decrypt-only-the-selected-key and pre-broadcast scrubbing have no signing
UI to drive until A6 (vault sessions carry no keys); they stay pinned by
tests/unit/vault-signer.test.ts and are NOT faked here.

Product bug found and fixed (e2e-caught): useSigningMode kept per-instance
local state, so a mode switch inside the Work Mode modal only updated the
switcher's own instance — a login surface already mounted under the modal
kept rendering the stale mode after closing it (switching back to
PrivateKey left the vault identity selects with no way to log in without a
manual reload). The hook now re-reads the persisted mode on
VAULT_WORK_MODE_CLOSED_EVENT and on tab visibility (mirroring the R3
vault-login-form refresh pattern); unit-pinned in
login-form-dispatch.test.tsx, docs/AI-driver/09-vault.md synced.

Acceptance: pnpm verify green (type-check 0 errors; lint 0 errors, 3
pre-existing warnings in untouched files; coverage 84.5% lines / 75.7%
branches, thresholds met; production build ok); pnpm e2e 14/14
(31s; existing smoke / wallet-main-flows / vault-mode specs unchanged and
green — the PrivateKey regression evidence); node
tests/node/vault-wif-real-check.mjs 9/9.
- 02-auth.md: use-auth's login/useRequireAuth/usePrivateKey were deleted
  in #343 (the doc still called them dead code); the remember-me setItem
  reference now names PrivateKeyLoginForm instead of stale line numbers
- 09-vault.md: login-form-dispatch suite count 9 -> 10

Doc-only.

Part of #381
…ult sessions

ClaimRewardsBanner.handleClaim fell back to tryRestoreRememberedPostingKey()
whenever the session had no posting key. A Vault Mode identity session
(sessionKind 'vault', all six key fields null) took the same path: it read
the Legacy plaintext REMEMBERED_POSTING_KEY_KEY from localStorage, verified
it against the chain, then signed and relayed claim_reward_balance with it.
That breaks the R3 contract (handoff section 5 step 4: never read, write or
remove the Legacy remembered posting key) and vault invariant 21 (a vault
identity neither holds nor borrows key material).

handleClaim now reads sessionKindOf(store.getState().auth) at click time and
only attempts the remembered-key restore for non-vault sessions. A keyless
vault session falls through to the existing re-auth dialog, which the
LoginForm dispatcher answers with the "not available in Vault Mode" notice.
privateKey sessions are unchanged.

Regression: a vault-session case in claim-rewards-banner.test.tsx (built via
the real setVaultIdentity reducer, same-account remembered key that would
chain-verify) asserts no getItem of the remembered key (spy on
Object.getPrototypeOf(localStorage)), no getAccounts, no signing, no
broadcast and an open re-auth dialog. The existing case 'restores the
device-remembered posting key and claims without re-auth' stays the
privateKey control. 09-vault.md invariant 21 gains the matching consequence.

Verification: with the gate removed the new case fails with "expected
[ [ 'wallet:rememberedPostingKey' ] ] to have a length of +0 but got 1";
restored it passes (also isolated and 3x shuffled). pnpm type-check clean;
pnpm lint 0 errors (3 pre-existing warnings in untouched files); banner +
reauth-flow suites 19/19; pnpm test 145 files / 1528 tests passed.

Part of #381
…docs

Review A/B follow-ups: 06-frontend's reference key-resolution chain and
02-auth's least-privilege note now state the device-remembered posting
restore is privateKey-sessions-only; 09-vault invariant 21 pins the exact
dispatcher behavior, records the two deliberate non-key Legacy touches
(username prefill during mode load, change-password clearing a now-invalid
key) and the known pre-A6 UX gaps (market/proposals login dialogs, the
hard-coded Not-authenticated text) for A6 to pick up.

Part of #381
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