Skip to content

feat(vault): manager, signer and unified signing facade (A4) - #389

Merged
ety001 merged 4 commits into
nextfrom
feat/vault-a4-manager-signer-facade
Nov 4, 2026
Merged

ety001 merged 4 commits into
nextfrom
feat/vault-a4-manager-signer-facade

Conversation

@ety001

@ety001 ety001 commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Part of #381

A4 — Manager, Signer 与统一 Facade

7 个新文件(4 src + 3 tests),现有文件零改动,无新依赖。

文件 内容
src/lib/vault/manager.ts A0 VaultManager 接口逐字实现(10 方法)+ 文档化扩展面
src/lib/vault/signer.ts vault 模式 VaultSigner(authority 预检 + 全量 key_auths 选择)
src/lib/auth/legacy-private-key-signer.ts privateKey 模式签名器(注入式 key getter)
src/lib/vault/signing-facade.ts 模式分发 facade(settings 持久化,无静默回退)
tests/unit/vault-manager.test.ts 17 用例
tests/unit/vault-signer.test.ts 28 用例(含 legacy signer 块)
tests/unit/signing-facade.test.ts 6 用例

Summary — 对照 kickoff A4 实现规则逐条

  1. 签名前重新获取链上账户数据,检查全部 key_auths + 权重 + weight_threshold,按 owner→active→posting 合法向下覆盖选最低权限可用密钥;不只查第一项 — selectSigningKey 扫描每个 authority level 的每个 key_auths entry(权重 ≥ threshold 单密钥即可签),walk 从 max(请求 role, op 要求 class) 起向上(请求 role 是下限,不向下越权);SignResult.role 如实报告实际签名 level。测试:key 在 key_auths[1] 命中、权重 < threshold 拒绝、posting op 用 active key 覆盖成功、owner 覆盖 active/posting。
  2. Authority 刷新流量契约(2026-11-04 八条裁决) — 每次签名经 fetchAccounts(统一 /api/query/accounts 客户端入口)做一次排序、去重、批量查询,断言 fresh: true(只绕浏览器 L1 的 cachedFetch noStore 分支;服务端 withCache(10, 300) 治理不动,JSDoc 写明);不允许组件各自查询/轮询(签名器是唯一查询点);vault 本地 payload 不可替代链上查询(只用于选 key);响应带 degraded 标记 / success !== true / 账户缺失 / authority 数据缺失(key_auths、account_auths、weight_threshold 形状校验)/ fetch 拒绝 → 一律 VAULT_AUTHORITY_UNAVAILABLE 且零签名调用(5 组负向用例逐一断言 signTransaction 未被调用)。account_update 后的缓存失效沿用既有 hashedUserCachePrefix 链路(A4 不新增失效路径)。
  3. 不用 get_required_signatures 作判据;memo 永不签名 — 分类完全基于内置 op→authority 表(Steem core 共识语义,02-auth.md);memo 角色请求 → VAULT_KEY_INVALID,payload 中 memo 角色条目在选 key 时被排除(专测:链上 posting authority 只含 memo key 公钥时拒绝签名)。
  4. dapp 单密钥限制不误伤恢复流程多签 — A4 零改动现有文件,recover_account 的 old/new owner 双签路径(client.ts signRecoverAccount)原样保留;vault signer 单密钥选择只服务于 vault 模式新路径。
  5. Facade 统一分发,不静默回退 — getSigningMode/setSigningMode 读写 settings(默认 'privateKey');setSigningMode 成功才落盘(失败抛错、原模式保留,测试注入 putSettings 失败验证);模式指向的后端未接线 → TypeError 大声失败,绝不悄悄走另一条路;VAULT_LOCKED / VAULT_OPERATION_UNSUPPORTED 及一切后端错误原样透传。
  6. 合并成功后必须锁定(蓝皮书硬契约) — 低层 mergeVaultBackup 保持不锁;Manager 编排在合并成功后立即 service.lock()。验收测试:合并后 locked: true、目标记录 lockEpoch 相对合并前 +1、revision +1、后续签名级读取需重解锁;lock() 持久化故障(注入 bumpLockEpoch 拒绝)→ 内存已清(getAccounts 报 VAULT_LOCKED 而非 STALE)且错误上抛、合并写入本身已持久。
  7. preMergeBackup 必须可达调用方 — importVaultDetailed(params) 额外导出,返回 { summary, preMergeBackup }(independent 为 null);JSDoc 契约:内存快照不落盘不写 lastBackupAt,调用方(A5 UI)在用户确认保存/下载或明确放弃前不得宣称"迁移前备份已完成";importVault 委托它仅返回 summary。

完成标准对照:facade 已交付(业务组件接线是 A6,A4 不动现有调用方);privateKey 默认(settings 缺省 + 测试);签名前 authority 校验与 RPC 故障路径全部有测试;SignResult 无 WIF/KEK/payload(序列化扫描 + 字段名扫描断言);恢复流程多签不受影响(零改动);合并后 locked + lockEpoch+1 有断言;preMergeBackup API 级交付有验收(UI 保存/放弃流程属 A5 完成标准)。

Design decisions

决策 说明
importVaultDetailed 通道 A0 契约 importVault → Promise<VaultSummary> 不能带 preMergeBackup;按任务书允许的"额外详细入口"实现,importVault 委托之
changePassword 组合方式 mutatePayload 只换 IV 不换 salt,不可用。读通道:readUnlockedPayload(service) —— 利用 A2 文档化的 mutator 回滚契约("throwing mutator changes nothing: no write, session untouched")捕获 draft 后抛内部标记中止,零写入零 revision 变化零额外 PBKDF2(任务书曾推测"抛标记会写入",实测否——service.ts:398-400 明文契约)。拿到 payload 后新 salt + 新 KEK + 新 IV 重加密 → swapRecord CAS(指针先与 unlock 快照核对 revision/lockEpoch,防止用旧明文回滚并发变更)→ service.lock()(安全默认,JSDoc 写明)。该 helper 仅 vault 内部使用(invariant 14 边界),manager 与 signer 共用
已解锁状态下 changePassword 直接 service.unlock() 会先 switch(A2 语义:清旧会话 + epoch bump)再解锁,故已解锁场景 epoch 共 +2——这是 A2 既有语义的自然结果,测试注释写明;未解锁场景(典型)为 +1
lockVault 双语义 无参/等于当前会话 → 本地 lock(内存 + epoch + 广播);命名其他 vault → registry.bumpLockEpoch(远程锁定:该 vault 所有会话下次操作 STALE;测试用第二个 manager 实证,且该路径无广播、走硬防线,确定性断言)
removeVault 认证 一次性 probe service(创建→unlock→destroy)验证密码:registry 无更轻的验密原语,手搓 derive+decrypt 等于重实现 unlock;probe 不扰动现有会话(无关会话存活有测试);当前会话属于被删 vault 时先 lock 清理再删
exportVault 与解锁态 会话属于被导出 vault 时先 lock(A3 注记的"导出 revision bump 僵尸会话"问题,取"先锁"分支)
legacy signer 无 authority 预检 A6 验收"默认 privateKey 行为不变"——现有 client.ts 签名本无链上预检;JSDoc 显式声明差异为 vault 模式独有;测试断言 accounts-client 零调用
legacy key 来源 构造时注入 getter (accountName, role) => string | undefined(A6 从登录态接线,JSDoc 有接线示例);missing → VAULT_KEY_INVALID(A5/A6 路由到重新认证引导);WIF 校验/公钥推导走共享 validateVaultWif
facade 后端注入而非自建 vault signer 必须共享 UI 解锁的那个 service 实例(一个 tab = 一个 manager = 一个 service);facade 内私建 service 永远无人能解锁。组合根:manager.createSigner()(manager 的文档化扩展方法)+ createLegacyPrivateKeySigner(getter)
manager 扩展面 A0 十方法逐字保留;额外 unlockVault(无它 merge/changePassword/getAccounts 永远无法成功)、importVaultDetailed、createSigner、destroy,全部 JSDoc 说明
交易 id 计算 SHA-256(normalizeTransactionForSerialize(无 signatures 字段的 unsigned tx)) 前 20 字节 hex;serializeTransaction 为 mock 透传的真实现,vitest 下实测命中 fixture 向量 07c1af89bc7bfbbe2b9261e0b6cb3bf231b98c4d(vault 与 legacy 两条路径各 pin 一次)
frozen 交易 signResolvedTransaction 原样传入签名调用(断言 ref_block/expiration 逐字段原样、header fetch 零调用);不外层再 normalize(signTransaction 内部本就 normalize 后签名,外层重做非幂等风险为零但无必要)
请求 role 是下限 walk 起点 = max(请求 role, op class):请求 posting 而 op 是 active → 从 active 起;请求 active 而 op 是 posting → 从 active 起(不降级到 posting,尊重显式请求)。文档 + 测试锁定

Test plan(实际数字)

  • pnpm verify 全绿(type-check + eslint + coverage 阈值 + build),EXIT=0。
  • 全量:135 文件 / 1516 用例通过(新增 55:manager 20 + signer 29 + facade 6;含两轮复审修复新增的 4 个回归用例)。
  • 新模块覆盖率:manager 93.4%、signer 89.7%、signing-facade 100%、legacy-private-key-signer 94.4%(分支 ≥ 82%);全局 84.1% stmts / 74.9% branches(阈值 80/60)。
  • 洗牌稳定:3 个 seed(1/7/42)下三文件 51/51 通过。
  • PBKDF2 预算:signer 文件 17 次派生、manager 文件 ~34 次(头注逐项列明),全文件运行 ~17s / ~23s。
  • 两层 WIF 纪律:tests/node/vault-wif-real-check.mjs 重跑 9/9 PASS(本 PR 未改 WIF 处理,重跑确认)。
  • mock 纪律:accounts-client 用 vi.mock(响应形状从 route.ts 实际输出派生,含 degraded 体标记);steem-js 走全局 mock——signTransaction 断言调用契约(哪个 tx 对象 + 哪个 WIF),签名真实性不宣称;txid 计算路径为真实现且 fixture 钉死。

Review rounds

  • Round 1 (3-way): pass; three reviewers jointly flagged recover_account in the single-key op table (chain requires old+new owner dual signatures — 2026-10-08 incident, tx_missing_other_auth), plus the signer's hand-rolled three-field session re-check. Fixed in 458b0947: recover_account removed from the table (fail-closed VAULT_OPERATION_UNSUPPORTED with JSDoc pointing at the dedicated dual-key path; recovery must not route through the vault signer), session re-checks now go through service.getStatus() (the A2 four-field hard line incl. createdAt), createVaultSigner(service) single-arg, changePassword pre-CAS guard upgraded, JSDoc/budget-note fixes.
  • Round 2 (3-way): two pass, one found a regression introduced by that fix — changePassword's guard DISCARDED the getStatus() return value, and getStatus softly returns {locked:true} (no throw) when the session was already cleared by a cross-tab broadcast, letting stale plaintext silently roll back a concurrent edit (experimentally reproduced). Fixed in 66807265: the guard now checks fresh.locked || fresh.vaultName === null → VAULT_SESSION_STALE, with three deterministic regression tests (cross-tab mutate with real BroadcastChannel, hidden-BC hard-line variant, same-counter remove+recreate) verified red pre-fix.
  • Round 3 (3-way): pass. Adversarial replay + mutation controls (reverting the guard reproduces the silent rollback), independent pre-fix-commit red/green verification, changePassword failure-mode matrix (16 scenarios incl. dual-manager same-password race: exactly one winner), real-crypto signing across posting/active/owner byte-identical to steem.auth.signTransaction, clean room + CI green.

未验证项(如实声明)

  • steem.auth.signTransaction 的签名真实性在 vitest 下不可证(全局 mock 返回罐头值)——真签名证据在既有 tests/unit/steem-signer-real-ops.test.ts(真包 + 真crypto 往返);txid 序列化路径本 PR 已用 fixture 向量实测。
  • 多账户批量预检的管道已实现(排序去重单次调用),但当前每次请求只涉及签名账户一个(完整 per-op 账户字段映射是 B0 分类器职责,表注释已标注扩展点)。
  • 真浏览器 IndexedDB/BroadcastChannel 未在本 PR 重测(A4 未触碰事务纪律;A2/A3 已做过 Chromium 黄金校验)。
  • 未做 UI/e2e(A5);changePassword 已解锁场景的 epoch +2 行为、manager 少量防御分支(tolerantStatus 非 STALE 重抛、CAS 前漂移守卫)无专门用例(覆盖报告未覆盖行即此类守卫)。

A4 orchestration over the landed A0-A3 primitives (no existing files
modified, no new dependencies):

- manager.ts: the A0 VaultManager interface verbatim (10 methods) plus
  documented extras (unlockVault / importVaultDetailed / createSigner /
  destroy). Merge migrations lock the target after success (blueprint
  contract: locked true, lockEpoch +1 vs pre-merge; a lock persistence
  failure propagates after memory is cleared) and hand the
  preMergeBackup snapshot back to the caller. changePassword
  re-encrypts under a fresh salt + fresh KEK via registry CAS
  (mutatePayload only rotates the IV), guards against stale-plaintext
  reverts, and locks afterwards. removeVault authenticates through a
  throwaway probe service; lockVault has local and remote (epoch bump)
  semantics; exportVault locks the session first when it matches.
- signer.ts: vault-mode VaultSigner. Fixed op-to-authority table from
  the 19 relay routes + client.ts signing surface (unknown ops fail
  closed with VAULT_OPERATION_UNSUPPORTED; account_update / custom_json
  resolve per payload). One sorted, batched, deduplicated fresh
  /api/query/accounts authority precheck per request (degraded flag,
  missing accounts, malformed authority or fetch failure ->
  VAULT_AUTHORITY_UNAVAILABLE with zero signing calls). Key selection
  scans ALL key_auths entries with weight >= weight_threshold and walks
  owner->active->posting downward coverage per Steem consensus; memo
  entries never sign; the requested role is honored as a floor.
  Mid-flight session drift is re-verified before the WIF is used.
  Transaction ids are SHA-256 over the real serializer's unsigned
  output (fixture-pinned).
- legacy-private-key-signer.ts: privateKey-mode signer with an injected
  key getter (the A6 wiring point); deliberately no authority precheck
  so default privateKey behavior stays unchanged.
- signing-facade.ts: dispatch from the registry settings row (default
  privateKey); setSigningMode persists only on success; unwired
  backends fail loudly with no silent fallback; backend errors pass
  through untouched.

Tests: 51 new cases in three files (fake IndexedDB + fixed fixture
envelopes; accounts-client vi.mock'ed; steem-js global mock asserts
call contracts only) including the merge hard-contract acceptance and
the exact fixture transaction id end-to-end. Suites are
shuffle-stable; pnpm verify (type-check, lint, coverage, build) green;
tests/node/vault-wif-real-check.mjs 9/9.

Part of #381
…on checks through the hard line (A4 review)

PR #389 three-way review fixes (one P2 + P3s), all within this PR's files:

- signer.ts: remove recover_account from OPERATION_AUTHORITY_CLASSES.
  Steem chain semantics require the old (recent) owner AND the new owner
  to both sign; a single-key vault signature is rejected by steemd with
  tx_missing_other_auth (client.ts signRecoverAccount, 2026-10-08
  production incident). Absent from the table -> existing fail-closed
  VAULT_OPERATION_UNSUPPORTED path; recovery flows keep their dedicated
  dual-key path and must not route through the vault signer until
  multi-key signing lands. Regression test: recover_account request ->
  VAULT_OPERATION_UNSUPPORTED with zero authority queries and zero
  signing calls, next to the unknown-op case.
- signer.ts: assertSessionStillCurrent now runs service.getStatus() (the
  A2 requireFreshSession four-field hard line incl. createdAt) instead of
  a hand-rolled three-field registry compare that had a natural
  remove+recreate window at session counters 0/0. The signer holds no
  registry access anymore (createVaultSigner(service)); a cleared
  mid-flight session maps to VAULT_SESSION_STALE, drift surfaces from the
  hard line itself.
- signer.ts: document the custom_json case where required_auths and
  required_posting_auths are both non-empty (chain requires both
  authorities; single-key signing insufficient — B0 owns the complete
  mixed-authority mapping).
- manager.ts: changePassword replaces the hand-rolled pre-CAS guard with
  service.getStatus() (same four-field hard line; catches a same-name
  remove+recreate the old compare could not see) run after requireRecord;
  the swapRecord CAS pointer (incl. createdAt) still backstops the
  read->write window. JSDoc adds: net lockEpoch +2 when the same vault
  was already unlocked here (unlock switch-clears +1, closing lock +1);
  a wrong old password still locks the previously unlocked session
  (retry needs a fresh unlock — A5 UI note).
- manager.ts: importVault JSDoc points merge callers at
  importVaultDetailed (this method drops the preMergeBackup handoff).
- Test headers: PBKDF2 budgets corrected to measured counts —
  vault-signer 17 -> 20 (19 in-test unlock executions + beforeAll 1);
  vault-manager per-flow actuals and whole-file ~34 -> 37 (a successful
  independent import pays 3 derivations: backup-envelope open +
  registry createVault + the import's internal session unlock, verified
  empirically with a counting harness).

pnpm type-check / lint / test green (135 files, 1513 tests).
…A4 review r2)

changePassword's pre-CAS guard discarded the value of
service.getStatus(). getStatus answers a CLEARED session softly —
{ locked: true } — instead of throwing, so when a cross-tab
lock/mutate acceleration event (BroadcastChannel) cleared this tab's
session between the payload capture and the guard, the guard was a
no-op: changePassword then re-encrypted the STALE captured plaintext
over the concurrent write, and the swapRecord CAS could not catch it
(its pointer came from the post-mutation requireRecord read, whose
counters match). Same shape as the signer's assertSessionStillCurrent:
a session that vanished mid-flight rejects with VAULT_SESSION_STALE.

Regression tests reproduce both C2 interleavings deterministically via
a gated registry that runs the concurrent tab's real
service/manager calls inside changePassword's requireRecord read
(between the capture and the guard):

- cross-tab mutatePayload (revision +1, BC clears the session):
  changePassword must reject STALE; the concurrent key survives, the
  record stays byte-identical to the concurrent write (original salt,
  no new-password envelope).
- same-count remove+recreate (counters restart at 0/0, removal's lock
  broadcast clears the session): changePassword must reject STALE and
  the recreated instance must not be overwritten by the old plaintext.
- a hidden-BroadcastChannel variant pins the hard-line drift path as
  the deterministic backstop.

Verified both ways: the two BC-cleared-session tests fail on the
previous commit and pass with the fix.
- Status header: A4 landed (PR #389); A5-A6 remain
- Module map gains manager.ts / signer.ts / signing-facade.ts /
  legacy-private-key-signer.ts and the three new test suites
- A3 wiring: the post-merge lock contract is now marked LANDED with its
  assertion requirements; preMergeBackup flows through importVaultDetailed
- New 'A4 has landed' wiring block for A5/A6: single facade entry point,
  per-request authority pre-check semantics, recover_account exclusion and
  its dedicated dual-key path, changePassword epoch/+2 and wrong-password
  session-lock UX notes, known residuals

Part of #381
@ety001
ety001 merged commit 475b49a into next Nov 4, 2026
2 of 3 checks passed
@ety001
ety001 deleted the feat/vault-a4-manager-signer-facade branch November 4, 2026 05:19
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