Repository navigation
feat(vault): manager, signer and unified signing facade (A4) - #389
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #381
A4 — Manager, Signer 与统一 Facade
7 个新文件(4 src + 3 tests),现有文件零改动,无新依赖。
src/lib/vault/manager.tsVaultManager接口逐字实现(10 方法)+ 文档化扩展面src/lib/vault/signer.tsVaultSigner(authority 预检 + 全量 key_auths 选择)src/lib/auth/legacy-private-key-signer.tssrc/lib/vault/signing-facade.tstests/unit/vault-manager.test.tstests/unit/vault-signer.test.tstests/unit/signing-facade.test.tsSummary — 对照 kickoff A4 实现规则逐条
selectSigningKey扫描每个 authority level 的每个key_authsentry(权重 ≥ threshold 单密钥即可签),walk 从max(请求 role, op 要求 class)起向上(请求 role 是下限,不向下越权);SignResult.role如实报告实际签名 level。测试:key 在key_auths[1]命中、权重 < threshold 拒绝、posting op 用 active key 覆盖成功、owner 覆盖 active/posting。fetchAccounts(统一/api/query/accounts客户端入口)做一次排序、去重、批量查询,断言fresh: true(只绕浏览器 L1 的cachedFetchnoStore 分支;服务端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 不新增失效路径)。get_required_signatures作判据;memo 永不签名 — 分类完全基于内置 op→authority 表(Steem core 共识语义,02-auth.md);memo 角色请求 →VAULT_KEY_INVALID,payload 中 memo 角色条目在选 key 时被排除(专测:链上 posting authority 只含 memo key 公钥时拒绝签名)。recover_account的 old/new owner 双签路径(client.tssignRecoverAccount)原样保留;vault signer 单密钥选择只服务于 vault 模式新路径。getSigningMode/setSigningMode读写 settings(默认'privateKey');setSigningMode成功才落盘(失败抛错、原模式保留,测试注入putSettings失败验证);模式指向的后端未接线 →TypeError大声失败,绝不悄悄走另一条路;VAULT_LOCKED/VAULT_OPERATION_UNSUPPORTED及一切后端错误原样透传。mergeVaultBackup保持不锁;Manager 编排在合并成功后立即service.lock()。验收测试:合并后locked: true、目标记录lockEpoch相对合并前 +1、revision +1、后续签名级读取需重解锁;lock()持久化故障(注入bumpLockEpoch拒绝)→ 内存已清(getAccounts报VAULT_LOCKED而非 STALE)且错误上抛、合并写入本身已持久。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通道importVault → Promise<VaultSummary>不能带 preMergeBackup;按任务书允许的"额外详细入口"实现,importVault委托之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 重加密 →swapRecordCAS(指针先与 unlock 快照核对 revision/lockEpoch,防止用旧明文回滚并发变更)→service.lock()(安全默认,JSDoc 写明)。该 helper 仅 vault 内部使用(invariant 14 边界),manager 与 signer 共用service.unlock()会先 switch(A2 语义:清旧会话 + epoch bump)再解锁,故已解锁场景 epoch 共 +2——这是 A2 既有语义的自然结果,测试注释写明;未解锁场景(典型)为 +1registry.bumpLockEpoch(远程锁定:该 vault 所有会话下次操作 STALE;测试用第二个 manager 实证,且该路径无广播、走硬防线,确定性断言)(accountName, role) => string | undefined(A6 从登录态接线,JSDoc 有接线示例);missing →VAULT_KEY_INVALID(A5/A6 路由到重新认证引导);WIF 校验/公钥推导走共享validateVaultWifmanager.createSigner()(manager 的文档化扩展方法)+createLegacyPrivateKeySigner(getter)unlockVault(无它 merge/changePassword/getAccounts 永远无法成功)、importVaultDetailed、createSigner、destroy,全部 JSDoc 说明SHA-256(normalizeTransactionForSerialize(无 signatures 字段的 unsigned tx))前 20 字节 hex;serializeTransaction为 mock 透传的真实现,vitest 下实测命中 fixture 向量07c1af89bc7bfbbe2b9261e0b6cb3bf231b98c4d(vault 与 legacy 两条路径各 pin 一次)signResolvedTransaction原样传入签名调用(断言 ref_block/expiration 逐字段原样、header fetch 零调用);不外层再 normalize(signTransaction内部本就 normalize 后签名,外层重做非幂等风险为零但无必要)max(请求 role, op class):请求 posting 而 op 是 active → 从 active 起;请求 active 而 op 是 posting → 从 active 起(不降级到 posting,尊重显式请求)。文档 + 测试锁定Test plan(实际数字)
pnpm verify全绿(type-check + eslint + coverage 阈值 + build),EXIT=0。tests/node/vault-wif-real-check.mjs重跑 9/9 PASS(本 PR 未改 WIF 处理,重跑确认)。vi.mock(响应形状从route.ts实际输出派生,含degraded体标记);steem-js 走全局 mock——signTransaction断言调用契约(哪个 tx 对象 + 哪个 WIF),签名真实性不宣称;txid 计算路径为真实现且 fixture 钉死。Review rounds
recover_accountin 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 in458b0947:recover_accountremoved from the table (fail-closedVAULT_OPERATION_UNSUPPORTEDwith JSDoc pointing at the dedicated dual-key path; recovery must not route through the vault signer), session re-checks now go throughservice.getStatus()(the A2 four-field hard line incl.createdAt),createVaultSigner(service)single-arg, changePassword pre-CAS guard upgraded, JSDoc/budget-note fixes.getStatus()return value, andgetStatussoftly 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 in66807265: the guard now checksfresh.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.steem.auth.signTransaction, clean room + CI green.未验证项(如实声明)
steem.auth.signTransaction的签名真实性在 vitest 下不可证(全局 mock 返回罐头值)——真签名证据在既有tests/unit/steem-signer-real-ops.test.ts(真包 + 真crypto 往返);txid 序列化路径本 PR 已用 fixture 向量实测。changePassword已解锁场景的 epoch +2 行为、manager 少量防御分支(tolerantStatus 非 STALE 重抛、CAS 前漂移守卫)无专门用例(覆盖报告未覆盖行即此类守卫)。