diff --git a/docs/AI-driver/09-vault.md b/docs/AI-driver/09-vault.md index bc49602a..b8082738 100644 --- a/docs/AI-driver/09-vault.md +++ b/docs/AI-driver/09-vault.md @@ -1,14 +1,15 @@ # 09 — Vault (encrypted multi-account wallet) -> Status date: 2026-11-04, baseline `next@4def3cc4` + A3 (PR #382 A0, PR #383 A1, +> Status date: 2026-11-04, baseline `next@cf76b353` + A4 (PR #382 A0, PR #383 A1, > A1.1 shared validation / canonical base64url / resource limits, PR #386 A2 > IndexedDB registry + unlock-session service, PR #387 A3 backup export / -> independent import / merge migration). +> independent import / merge migration, PR #389 A4 manager / signer / signing +> facade). > Covers only what has landed: type contracts, error taxonomy, envelope validation, > Web Crypto whole-envelope encryption, shared input validation with resource -> boundaries, the registry/service layer and the backup/import/merge layer. -> Manager/UI/migration (A4–A6) are not implemented yet — this doc grows with each -> task package. Tracking issue: #381. +> boundaries, the registry/service/backup layers and the manager + signer + +> unified facade. Work-mode UI and caller migration (A5–A6) are not implemented +> yet — this doc grows with each task package. Tracking issue: #381. ## Module map @@ -28,6 +29,25 @@ src/lib/vault/registry.ts IndexedDB layer (A2, PR #386): `steem-wallet-vault` v atomic alias uniqueness inside one readwrite transaction (comparison key scanned via getAll — never persisted, never in the AAD); revision/lockEpoch CAS; abort-on-callback-throw +src/lib/vault/manager.ts VaultManager orchestration (A4, PR #389): the A0 ten + methods plus unlockVault/importVaultDetailed (preMergeBackup + handoff)/createSigner/destroy; merge -> service.lock() (the + blueprint post-merge lock contract); changePassword re-encrypts + with a fresh salt via swapRecord CAS (mutatePayload cannot + change salt) and locks after +src/lib/vault/signer.ts Vault-mode signer (A4): op->authority table (19 relay ops + + account_update/custom_json payload-based, unknown ops rejected, + recover_account deliberately EXCLUDED — needs old+new owner + dual signatures, see client.ts signRecoverAccount), one sorted + batched authority fetch via accounts-client with fresh:true, + lowest-privilege key selection over ALL key_auths with + owner>active>posting downward coverage, memo never signs +src/lib/vault/signing-facade.ts Mode dispatch (A4): settings-backed signingMode + (default privateKey), no silent fallback, backend errors + propagate verbatim +src/lib/auth/legacy-private-key-signer.ts privateKey-mode signer (A4): injected WIF + getter (A6 wiring point), deliberately NO authority pre-check + (A6 acceptance: default privateKey behavior unchanged) src/lib/vault/backup.ts Backup layer (A3, PR #387): exportVaultBackup (five-field envelope + lastBackupAt CAS), importVaultBackup (single-password contract, instance-bound adoption/rollback), mergeVaultBackup (target-first @@ -51,6 +71,12 @@ tests/unit/vault-service.test.ts 13 cases: unlock/lock lifecycle, cross-tab shuffle-stable per-case state (A2) tests/unit/vault-backup.test.ts 27 cases: export round-trip, import boundaries and rollback races, merge dedupe/failure-atomicity (A3) +tests/unit/vault-manager.test.ts 20 cases: the ten methods, post-merge lock hard + contract, changePassword incl. cross-tab interleavings (A4) +tests/unit/vault-signer.test.ts 29 cases: authority pre-check branches, key + selection/coverage, frozen-tx integrity, recover_account + exclusion (A4) +tests/unit/signing-facade.test.ts 6 cases: mode dispatch, no fallback, persistence (A4) tests/mocks/fake-indexeddb.ts hand-written in-memory IndexedDB fake (A2) — see fidelity notes under Pitfalls tests/node/vault-wif-real-check.mjs plain-`node` real-crypto WIF verification (A1.1; @@ -256,20 +282,45 @@ goes through i18n) and never echo names, passwords, WIFs, ciphertext or payload. leaves the target ciphertext byte-identical. An empty/no-change source still rewrites the record (fresh IV, revision+1) — deliberate, do not add an equality short-circuit. - - **Post-merge lock is the Manager layer's job (blueprint contract).** The - low-level `mergeVaultBackup` deliberately does NOT lock (the A3 tests pin - `locked: false` at the primitive level), but the blueprint requires the - target to be locked after a successful merge, with revision and lockEpoch - advanced. A4's orchestration MUST call `service.lock()` right after a - successful merge and assert `locked: true` + `lockEpoch + 1`; a `lock()` - failure must still have cleared memory and must propagate. Likewise the - `preMergeBackup` must actually be delivered to the user (file save/download - or an explicit discard) before the UI claims the pre-migration backup is - done — the memory-only snapshot alone is not a completed backup. + - **Post-merge lock is the Manager layer's job (blueprint contract; LANDED + in A4).** The low-level `mergeVaultBackup` deliberately does NOT lock (the + A3 tests pin `locked: false` at the primitive level), but the blueprint + requires the target to be locked after a successful merge, with revision + and lockEpoch advanced. `manager.importVaultDetailed` calls `service.lock()` + right after a successful merge and its tests assert `locked: true` + + `lockEpoch + 1`; a `lock()` failure still has cleared memory and propagates. + The `preMergeBackup` flows out through `importVaultDetailed` — the UI (A5) + must deliver it to the user (file save/download or an explicit discard) + before claiming the pre-migration backup is done; the memory-only snapshot + alone is not a completed backup. - `exportVaultBackup` CAS-writes `lastBackupAt` — the revision bump invalidates unlocked sessions for that vault in ANY tab (including the caller's own service instance). UI flows should lock first or tolerate re-unlock after export. +- **A4 has landed (manager / signer / facade)** — wiring rules for A5/A6: + - Components get ONE entry point: the `SigningFacade` (compose via + `createVaultManager(registry)` + `manager.createSigner()` + the legacy + signer with a WIF getter from the login state). Never build a signer at a + component, never query `/api/query/accounts` outside the vault signer. + - The vault signer's authority pre-check is per-signing-request: one sorted + batched `fetchAccounts(..., { fresh: true })` (bypasses browser L1 only — + the server keeps its 10s/300s cache governance); `degraded`/missing + accounts/malformed authority/fetch failure → `VAULT_AUTHORITY_UNAVAILABLE` + with ZERO signing calls. The legacy signer deliberately has NO pre-check + (A6 acceptance: default privateKey behavior unchanged). + - `recover_account` is deliberately unsupported through the facade/signers + (`VAULT_OPERATION_UNSUPPORTED`): it needs old+new owner dual signatures + (2026-10-08 incident, `tx_missing_other_auth`); recovery flows keep the + dedicated dual-key path in `client.ts` — do not route them into the vault + signer until multi-key signing exists. + - `changePassword` on an already-unlocked vault bumps lockEpoch by +2 (unlock + switch-clears +1, final lock +1) and a WRONG old password locks the + previously-unlocked session (retry needs a fresh unlock) — A5 UX should + account for both. `importVault` (the A0-shaped method) drops the + `preMergeBackup`; merge flows must use `importVaultDetailed`. + - Known residual (documented in code): the same-millisecond remove+recreate + `createdAt` collision (A2 invariant 11) and the sub-ms rollback window in + A3 import; both need a future schema-level record id to fully close. - **New vault tests**: follow the fixture-recompute discipline; never copy WIFs or passwords into `src/`. Anything touching WIF handling needs BOTH layers: mock-driven vitest cases plus a rerun of `tests/node/vault-wif-real-check.mjs` recorded in the diff --git a/src/lib/auth/legacy-private-key-signer.ts b/src/lib/auth/legacy-private-key-signer.ts new file mode 100644 index 00000000..0c712518 --- /dev/null +++ b/src/lib/auth/legacy-private-key-signer.ts @@ -0,0 +1,188 @@ +// Legacy privateKey-mode signer (A4) — implements the same `VaultSigner` +// interface as the vault signer but sources its WIF from an injected getter +// instead of a vault session, PRESERVING today's privateKey-mode behavior. +// +// A6 WIRING CONTRACT: the caller injects the key source at creation time — +// +// const legacySigner = createLegacyPrivateKeySigner((accountName, role) => { +// // read from the login state (Redux auth slice / remembered posting key), +// // return undefined when this session cannot sign for that role +// }); +// const facade = createSigningFacade(registry, { +// vaultSigner: manager.createSigner(), +// legacySigner, +// }); +// +// The getter is invoked per signing request with the normalized account name +// and the requested role, and may return `undefined` (no key held — mapped to +// `VAULT_KEY_INVALID` so the A5/A6 UI can route to re-auth guidance). It must +// never be handed a component callback that captures anything beyond the +// login state. +// +// DELIBERATE DIFFERENCE from the vault signer (A6 acceptance: "default +// privateKey behavior unchanged"): NO pre-signing on-chain authority check. +// Today's `src/lib/steem/client.ts` signing path consults no authority data, +// and the facade must not make privateKey mode slower or fail where it works +// today; the fresh `/api/query/accounts` verification is a vault-mode-only +// guarantee. Consequences, accepted on purpose: a posting-key request for an +// active-class operation is signed locally and rejected by the chain (same +// as the current client), and a degraded RPC does not block privateKey-mode +// signing. +// +// Shared with the vault signer: the A4 operation table (`memo` never signs, +// unknown operations fail closed with `VAULT_OPERATION_UNSUPPORTED`). +// +// SENSITIVE-DATA RULES: the WIF exists only as a getter result inside the +// signing call and goes straight into `steem.auth.signTransaction` +// (client-side only). It never appears in `SignResult`, error messages or +// logs. +import { steem } from '@steemit/steem-js'; + +import { apiClient } from '@/lib/steem/client'; +import type { + SignOperationsRequest, + SignResolvedTransactionRequest, + SignResult, + SigningRole, + UnsignedTransaction, +} from '@/lib/vault/types'; +import { VaultError } from '@/lib/vault/errors'; +import { + normalizeRequestAccount, + requireSigningRole, + resolveRequiredAuthorityClass, +} from '@/lib/vault/signer'; +import { validateVaultWif } from '@/lib/vault/validation'; + +/** + * Resolves the WIF for an account/role pair at signing time. Return + * `undefined` when no key is available (re-auth territory). A4 tests inject + * mocks; A6 wires this to the login state. + */ +export type LegacyPrivateKeyGetter = ( + accountName: string, + role: SigningRole, +) => string | undefined; + +function keyUnavailable(reason: string): VaultError { + return new VaultError('VAULT_KEY_INVALID', reason); +} + +/** Resolve + validate the WIF for a request; returns WIF and public key. */ +function resolveKey( + getPrivateKey: LegacyPrivateKeyGetter, + accountName: string, + role: SigningRole, +): { wif: string; publicKey: string } { + const wif = getPrivateKey(accountName, role); + if (typeof wif !== 'string' || wif.length === 0) { + throw keyUnavailable('no private key is available for the requested account and role'); + } + // Shared entry point: real isWif checksum + public-key derivation + + // normalizeSteemUsername account normalization (mock-driven under vitest; + // real-crypto evidence lives in tests/node/vault-wif-real-check.mjs). + const check = validateVaultWif(wif, accountName); + return { wif, publicKey: check.publicKey }; +} + +/** Transaction id = first 20 bytes of SHA-256 over the unsigned serialization. */ +async function computeTransactionId(unsigned: UnsignedTransaction): Promise { + const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle; + if (!subtle) { + throw new VaultError( + 'VAULT_CRYPTO_UNAVAILABLE', + 'Web Crypto subtle API is unavailable in this environment', + ); + } + const normalized = steem.auth.normalizeTransactionForBroadcast( + unsigned as unknown as Record, + ); + const bytes = new Uint8Array(steem.auth.serializeTransaction(normalized)); + const digest = new Uint8Array(await subtle.digest('SHA-256', bytes)); + let hex = ''; + for (let i = 0; i < 20; i += 1) { + hex += (digest[i] ?? 0).toString(16).padStart(2, '0'); + } + return hex; +} + +/** + * Build the privateKey-mode signer. `getPrivateKey` is the ONLY key source — + * the signer itself stores nothing, so a stolen signer object holds no key + * material. + */ +export function createLegacyPrivateKeySigner(getPrivateKey: LegacyPrivateKeyGetter): { + signOperations(request: SignOperationsRequest): Promise; + signResolvedTransaction(request: SignResolvedTransactionRequest): Promise; +} { + return { + /** + * Sign freshly built operations with the login-state key for the + * requested role. No authority precheck (see the module header); the + * caller's role choice is trusted exactly as `src/lib/steem/client.ts` + * trusts its callers today — the chain remains the validator. + */ + async signOperations(request: SignOperationsRequest): Promise { + resolveRequiredAuthorityClass(request?.operations); + const role = requireSigningRole(request?.role); + const accountName = normalizeRequestAccount(request); + const key = resolveKey(getPrivateKey, accountName, role); + const header = await apiClient.getTransactionHeader(); + const unsigned: UnsignedTransaction = { + ref_block_num: header.ref_block_num, + ref_block_prefix: header.ref_block_prefix, + expiration: header.expiration, + operations: request.operations, + extensions: [], + }; + const signed = steem.auth.signTransaction(unsigned, [key.wif]); + const signedTx = steem.auth.normalizeTransactionForBroadcast( + signed as unknown as Record, + ) as unknown as SignResult['signedTx']; + return { + signedTx, + transactionId: await computeTransactionId(unsigned), + accountName, + role, // the requested role is the role used — no chain-based selection here + publicKey: key.publicKey, + locked: false, // no vault session exists in privateKey mode + }; + }, + + /** + * Sign a frozen transaction verbatim — no header refresh, no + * reconstruction, no normalization of non-signature fields. The + * transaction object passes as-is into the signing call. + */ + async signResolvedTransaction(request: SignResolvedTransactionRequest): Promise { + const transaction = request?.transaction; + if ( + typeof transaction !== 'object' || + transaction === null || + !Number.isFinite(transaction.ref_block_num) || + !Number.isFinite(transaction.ref_block_prefix) || + typeof transaction.expiration !== 'string' || + transaction.expiration.length === 0 || + !Array.isArray(transaction.operations) + ) { + throw new VaultError( + 'VAULT_OPERATION_UNSUPPORTED', + 'transaction is not a frozen unsigned transaction', + ); + } + resolveRequiredAuthorityClass(transaction.operations); + const role = requireSigningRole(request?.role); + const accountName = normalizeRequestAccount(request); + const key = resolveKey(getPrivateKey, accountName, role); + const signed = steem.auth.signTransaction(transaction, [key.wif]); + return { + signedTx: signed as SignResult['signedTx'], + transactionId: await computeTransactionId({ ...transaction }), + accountName, + role, + publicKey: key.publicKey, + locked: false, + }; + }, + }; +} diff --git a/src/lib/vault/manager.ts b/src/lib/vault/manager.ts new file mode 100644 index 00000000..8e4b9ebf --- /dev/null +++ b/src/lib/vault/manager.ts @@ -0,0 +1,414 @@ +// Vault manager (A4) — the orchestration layer implementing the A0 +// `VaultManager` interface over the landed primitives: +// +// - persistence via the A2 registry (atomic alias uniqueness, CAS, settings); +// - the unlock session via the A2 service (one manager = one service +// instance = one tab's session — sessions are pure memory); +// - backup/import/merge via the A3 backup layer. +// +// BLUEPRINT HARD CONTRACTS implemented here (kickoff A4 rules, 2026-11-04 +// ruling; see docs/AI-driver/09-vault.md "Post-merge lock is the Manager +// layer's job"): +// 1. A successful MERGE migration locks the target: `importVault` calls +// `service.lock()` right after `mergeVaultBackup` succeeds (the low-level +// primitive deliberately does not lock), so the orchestration leaves +// `locked: true` with the target record's `lockEpoch` +1 versus +// pre-merge. A `lock()` persistence failure still has cleared memory and +// still propagates — no "merged but still unlocked" path exists. +// 2. The merge's `preMergeBackup` snapshot is handed back to the caller +// (`importVaultDetailed`): it is a MEMORY-ONLY snapshot that is neither +// persisted nor reflected in `lastBackupAt`. Callers (A5 UI) must +// deliver it to the user (file save/download or an explicit discard) +// and must NOT claim the pre-migration backup is complete before that. +// +// SENSITIVE-DATA RULES: the manager holds the service instance; KEK and +// payload stay inside that closure. `changePassword` composes +// `readUnlockedPayload` (vault-internal, invariant-14 boundary) to re-encrypt +// the payload under a fresh salt — `mutatePayload` cannot be used for a +// password change because it only rotates the IV and deliberately keeps the +// salt (new salts belong to password changes). Error messages never echo +// names, passwords, salts, IVs, ciphertext or key material. +import { encodeBase64Url } from './envelope'; +import { deriveVaultKek, encryptVaultText, generateVaultIv, generateVaultSalt } from './crypto'; +import { VaultError } from './errors'; +import { + exportVaultBackup, + importVaultBackup, + mergeVaultBackup, +} from './backup'; +import { type VaultRegistry } from './registry'; +import { createVaultService, type VaultService, type VaultServiceStatus } from './service'; +import { createVaultSigner, readUnlockedPayload } from './signer'; +import type { + ImportVaultParams, + SignOperationsRequest, + SignResolvedTransactionRequest, + SignResult, + VaultAccountSummary, + VaultEnvelopeV1, + VaultManager, + VaultRecordV1, + VaultSummary, +} from './types'; +import { validateVaultPassword, vaultNameComparisonKey } from './validation'; + +/** The full import result: the A0 summary plus the merge safety net. */ +export interface ImportVaultDetailedResult { + summary: VaultSummary; + /** + * The pre-merge five-field envelope of the merge TARGET (memory-only + * snapshot; not persisted, does not touch `lastBackupAt`). `null` for + * independent imports (nothing was merged over). The caller MUST deliver + * this to the user — saving/downloading the file or an explicit discard — + * before claiming the pre-migration backup is done. + */ + preMergeBackup: VaultEnvelopeV1 | null; +} + +function notFound(): VaultError { + return new VaultError('VAULT_NOT_FOUND', 'no vault exists under that name'); +} + +/** + * The manager's extended surface: the A0 `VaultManager` interface VERBATIM + * plus the orchestration extras A4/A5 need (the A0 interface alone has no way + * to unlock, which merge/changePassword/getAccounts all require). + */ +export interface VaultManagerExtended extends VaultManager { + /** + * Unlock a vault in this manager's session service (delegates to + * `VaultService.unlock`: existing-password authentication, no strength + * policy; switching vaults clears the previous session completely). + * Exposed because merge migrations, password changes and account reads all + * operate on the unlocked session — without it the A0 method set could + * never succeed outside tests. + */ + unlockVault(vaultName: string, password: string): Promise; + /** + * Import with the merge safety net attached — `importVault` is exactly this + * minus the `preMergeBackup` handoff. See {@link ImportVaultDetailedResult}. + */ + importVaultDetailed(params: ImportVaultParams): Promise; + /** + * Build the vault-mode signer SHARING this manager's session service. The + * vault signer is only useful wired to the service the UI unlocks — one + * tab must never build signers over private service instances. + */ + createSigner(): { + signOperations(request: SignOperationsRequest): Promise; + signResolvedTransaction(request: SignResolvedTransactionRequest): Promise; + }; + /** Tear down the session service (clears any session, closes the channel). */ + destroy(): void; +} + +/** + * Session status that treats STALE as "locked": a stale session has already + * been cleared by the service's own hard line when the error surfaced, so + * every vault is locked from this tab's perspective. Used by flows that must + * not fail merely because the session died concurrently (locking another + * vault, listing). + */ +async function tolerantStatus(service: VaultService): Promise { + try { + return await service.getStatus(); + } catch (error) { + if (error instanceof VaultError && error.code === 'VAULT_SESSION_STALE') { + return { locked: true, vaultName: null, revision: null, lockEpoch: null }; + } + throw error; + } +} + +/** Whether a session status (unlocked) points at the given vault name. */ +function statusMatchesVault(status: VaultServiceStatus, vaultName: string): boolean { + return ( + !status.locked && + status.vaultName !== null && + vaultNameComparisonKey(status.vaultName) === vaultNameComparisonKey(vaultName) + ); +} + +/** + * Build the A4 vault manager over a registry. The manager owns exactly one + * unlock-session service (created here, destroyed by {@link + * VaultManagerExtended.destroy}) — pass the SAME registry to the signing + * facade and any other vault consumer so they share one record view. + */ +export function createVaultManager(registry: VaultRegistry): VaultManagerExtended { + const service = createVaultService(registry); + + /** Summarize one record: `active` from settings, `locked` from the session. */ + async function summarize(record: VaultRecordV1, status: VaultServiceStatus): Promise { + const settings = await registry.getSettings(); + const active = + settings.activeVaultName !== null && + vaultNameComparisonKey(settings.activeVaultName) === vaultNameComparisonKey(record.vaultName); + return { + vaultName: record.vaultName, + active, + locked: !statusMatchesVault(status, record.vaultName), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + lastBackupAt: record.lastBackupAt, + }; + } + + /** Read a record or fail with the manager's uniform NOT_FOUND. */ + async function requireRecord(vaultName: string): Promise { + const record = await registry.getRecord(vaultName); + if (record === null) { + throw notFound(); + } + return record; + } + + async function importVaultDetailed(params: ImportVaultParams): Promise { + if (params.mode === 'independent') { + // No activation, no session impact (the import's internal service is + // scoped inside the A3 primitive); no pre-merge snapshot exists. + const record = await importVaultBackup(registry, params); + const status = await tolerantStatus(service); + return { summary: await summarize(record, status), preMergeBackup: null }; + } + // MERGE: the target must already be unlocked in THIS manager's service + // (unlockVault first — the A5 flow's job). The primitive snapshots the + // pre-merge envelope, merges with target-first dedupe under CAS, and + // leaves the target unlocked — the post-merge LOCK is this layer's + // blueprint contract. + const merged = await mergeVaultBackup(registry, service, params); + // lock() clears memory first, then persists lockEpoch +1 and broadcasts; + // a persistence failure leaves memory cleared and propagates — there is + // no successful merge path that stays unlocked. + await service.lock(); + const record = await requireRecord(params.targetVaultName); + return { + summary: await summarize(record, { + locked: true, + vaultName: null, + revision: null, + lockEpoch: null, + }), + preMergeBackup: merged.preMergeBackup, + }; + } + + return { + async listVaults(): Promise { + const records = await registry.listRecords(); + const status = await tolerantStatus(service); + return Promise.all(records.map((record) => summarize(record, status))); + }, + + async createVault(params: { vaultName: string; password: string }): Promise { + // Validation (alias rules + password policy) and atomic alias + // uniqueness live inside the registry primitive. The new vault starts + // with the empty payload and LOCKED — creation does not auto-unlock. + const record = await registry.createVault(params); + const status = await tolerantStatus(service); + return summarize(record, status); + }, + + /** + * A0-shaped import (summary only). For a MERGE migration prefer + * {@link VaultManagerExtended.importVaultDetailed}: this method drops + * the `preMergeBackup` snapshot, and a merge caller who ignores the + * pre-migration backup handoff loses the merge's safety net. + */ + async importVault(params: ImportVaultParams): Promise { + const { summary } = await importVaultDetailed(params); + return summary; + }, + + importVaultDetailed, + + async activateVault(vaultName: string): Promise { + const record = await requireRecord(vaultName); + const settings = await registry.getSettings(); + await registry.putSettings({ ...settings, activeVaultName: record.vaultName }); + }, + + async renameVault(oldName: string, newName: string): Promise { + // Registry transaction: validated new name, alias uniqueness, settings + // pointer follow. An unlocked session on the OLD name is invalidated + // implicitly — its next session-bound operation finds no record under + // the old name and rejects with VAULT_SESSION_STALE (A2 semantics, + // self-healing; the manager deliberately does not resurrect it under + // the new name). + await registry.renameVault(oldName, newName); + }, + + async removeVault(vaultName: string, password: string): Promise { + // Authenticate with the vault's EXISTING password via a THROWAWAY + // service: deletion must not disturb an unrelated live session, and a + // probe unlock is the registry-faithful password check (no lighter + // verify primitive exists; re-deriving + decryptVaultText by hand would + // re-implement unlock). One PBKDF2 is the price of a destructive op. + // The strength policy never runs here — deletion keeps accepting the + // password the vault was created with. + const probe = createVaultService(registry); + try { + await probe.unlock(vaultName, password); + } finally { + probe.destroy(); + } + // If the manager's own session belongs to the doomed vault, clear it + // BEFORE the delete (lock() zeroes the KEK, bumps the epoch and + // broadcasts — the subsequent delete makes the bump moot but harmless). + const status = await tolerantStatus(service); + if (statusMatchesVault(status, vaultName)) { + await service.lock(); + } + await registry.removeVault(vaultName); + }, + + async exportVault(vaultName: string): Promise { + // exportVaultBackup bumps revision via its lastBackupAt CAS, which + // invalidates THIS tab's unlocked session for the vault too (A3 note). + // Lock first when the session belongs to the exported vault — the + // alternative is a zombie session that goes stale on its next use. + const status = await tolerantStatus(service); + if (statusMatchesVault(status, vaultName)) { + await service.lock(); + } + return exportVaultBackup(registry, vaultName); + }, + + async lockVault(vaultName?: string): Promise { + if (vaultName === undefined) { + // Lock the current session (idempotent; no-op when already locked). + await service.lock(); + return; + } + const status = await tolerantStatus(service); + if (statusMatchesVault(status, vaultName)) { + // Local lock: memory cleared, lockEpoch +1, acceleration broadcast. + await service.lock(); + return; + } + // REMOTE-lock semantics for a vault this session does NOT hold: bump + // its lockEpoch so every unlocked session for it (other tabs) fails its + // next session-bound operation with VAULT_SESSION_STALE. Equivalent in + // outcome to a local lock, minus the local memory (there is none). + // Missing records surface VAULT_NOT_FOUND from the registry. + await registry.bumpLockEpoch(vaultName); + }, + + /** + * Re-encrypt the vault under a new password: fresh salt + fresh KEK via + * registry CAS, then lock (the session holds the OLD KEK; the closing + * lock's epoch bump also stales other tabs' sessions for this vault). + * + * Lock accounting when THIS manager already holds the same vault + * unlocked: the net `lockEpoch` movement is +2 — the internal `unlock` + * switches sessions first (clearing the previous one persists its own + * +1), and the final `lock()` adds another +1. Both bumps are safe in + * either direction: an epoch only ever moves forward, and every bump + * just invalidates other tabs' sessions for this vault. + * + * A WRONG old password still runs the unlock's switch semantics first: + * any session this service held (the same vault included) is locked and + * NOT restored when authentication then fails — after a failed attempt + * the caller must unlock again (the A5 UI should say so, not just + * prompt for a retry). + */ + async changePassword( + vaultName: string, + oldPassword: string, + newPassword: string, + ): Promise { + // Order (kickoff A4): old-password authentication FIRST (unlocking + // keeps accepting the existing password — no policy), THEN the new + // password's strength policy, THEN the re-encryption. mutatePayload is + // unusable here by design: it rotates the IV but keeps the salt, and a + // password change MUST mint a fresh salt + fresh KEK. + await service.unlock(vaultName, oldPassword); + validateVaultPassword(newPassword); + // Read the decrypted payload through the just-established session + // (vault-internal capture; see readUnlockedPayload's JSDoc — its + // mutatePayload ride already runs the service's four-field hard line). + const payload = await readUnlockedPayload(service); + // The CAS pointer must describe the record the SESSION unlocked — + // rewriting from a record that moved underneath would revert a + // concurrent mutation with stale plaintext. `service.getStatus()` IS + // that check, in its strongest form: it re-runs the service's + // four-field hard line (createdAt + revision + lockEpoch against the + // record) where the previous hand-rolled status compare could not see + // `createdAt` and thus missed a same-name remove+recreate landing on + // matching counters. Running it AFTER requireRecord pins the record + // whose createdAt the swap pointer below uses; `swapRecord`'s own CAS + // backstops the remaining read→write window. + const record = await requireRecord(vaultName); + // The guard only bites if the RETURN VALUE is checked: `getStatus` + // answers a CLEARED session softly — `{ locked: true }` — instead of + // throwing, because the hard line inside it never runs when there is + // no session left to compare (service.ts getStatus early-returns). A + // cross-tab lock/mutate acceleration event (BroadcastChannel) can + // clear this tab's session between the payload capture and this + // point; an unchecked call (review r2's C2) sails through exactly + // then, and the swap below would re-encrypt the STALE captured + // plaintext over the concurrent write — its CAS pointer comes from + // the post-mutation record above, so the CAS cannot catch it. Same + // shape as the signer's `assertSessionStillCurrent`: a session that + // vanished mid-flight is STALE, never "fine". + const fresh = await service.getStatus(); + if (fresh.locked || fresh.vaultName === null) { + throw new VaultError( + 'VAULT_SESSION_STALE', + 'the unlocked vault changed outside this session; unlock again', + ); + } + const salt = generateVaultSalt(); + const iv = generateVaultIv(); + const kek = await deriveVaultKek(newPassword, salt); + let ciphertext: string; + try { + ciphertext = encodeBase64Url(await encryptVaultText(kek, iv, JSON.stringify(payload))); + } finally { + // The new KEK lives only inside this scope — zeroize like every + // other primitive does. + kek.fill(0); + } + await registry.swapRecord( + { + vaultName: record.vaultName, + createdAt: record.createdAt, + revision: record.revision, + lockEpoch: record.lockEpoch, + }, + { + ...record, + salt: encodeBase64Url(salt), + iv: encodeBase64Url(iv), + ciphertext, + revision: record.revision + 1, + updatedAt: Date.now(), + }, + ); + // Locking after a password change is the security default: the session + // holds the OLD KEK (useless for future writes under the new salt, but + // cleared now regardless), and the epoch bump stales any other tab's + // unlocked session for this vault. + await service.lock(); + }, + + async getAccounts(): Promise { + // Session-bound (VAULT_LOCKED when nothing is unlocked; STALE when the + // vault changed underneath). Balances stay null in A4 — loaded by + // later stages. + return service.getAccounts(); + }, + + unlockVault(vaultName: string, password: string): Promise { + return service.unlock(vaultName, password); + }, + + createSigner() { + return createVaultSigner(service); + }, + + destroy(): void { + service.destroy(); + }, + }; +} diff --git a/src/lib/vault/signer.ts b/src/lib/vault/signer.ts new file mode 100644 index 00000000..bf7faa23 --- /dev/null +++ b/src/lib/vault/signer.ts @@ -0,0 +1,634 @@ +// Vault-mode transaction signer (A4) — signs with WIFs held by an unlocked +// vault service session, behind a mandatory fresh on-chain authority check. +// +// LAYERING: this module is vault-internal feature code on top of the A2/A3 +// primitives (registry + unlock-session service). It never talks to IndexedDB +// directly and never re-implements validation — account-name normalization +// comes from the shared `normalizeSteemUsername`, WIF checking/public-key +// derivation from the shared `validateVaultWif` entry point. +// +// STEEM AUTHORITY SEMANTICS (docs/AI-driver/02-auth.md, verified against Steem +// core v0.23.0 `transaction_util.hpp` + live mainnet): consensus +// `verify_authority` falls back DOWNWARD — a posting-class op accepts a key +// from the account's posting, active OR owner authority; an active-class op +// accepts active or owner; owner-class ops are owner-only. A higher key can +// stand in for a lower authority, never the reverse. Key selection here walks +// the authority levels from the least privileged one the request allows +// upward, and `SignResult.role` reports the level actually used. +// `database_api.get_required_signatures` is deliberately NOT consulted — it +// computes a strict per-class minimal set and is not an acceptance oracle +// (the #375/#378 lesson). +// +// PRE-SIGNING AUTHORITY REFRESH — traffic contract (kickoff A4, 2026-11-04 +// ruling, aligned with the relay's "protect api.steemit.com" goal): +// - the final signing path queries `/api/query/accounts` through the single +// client entry point (`fetchAccounts` in @/lib/steem/accounts-client) with +// `fresh: true` — ONE sorted, batched, deduplicated call per signing +// request, never per component, never polling; +// - `fresh: true` bypasses only the browser L1 cache (the `cachedFetch` +// noStore branch); the server-side `withCache(cacheKey, 10, 300, …)` +// governance on the route is untouched; +// - a response carrying `degraded`, a missing/short `accounts` list, +// malformed authority data or a failed fetch rejects with +// `VAULT_AUTHORITY_UNAVAILABLE` BEFORE anything is signed — the vault's +// local payload is never accepted as a substitute for this query; +// - the local vault snapshot is consulted only to SELECT which key to use. +// +// SENSITIVE-DATA RULES: the WIF leaves the captured session payload only as +// an argument to `steem.auth.signTransaction` (client-side signing, exactly +// like src/lib/steem/client.ts). It never enters the returned `SignResult`, +// error messages, logs or the network. A successful signing does NOT lock the +// vault — signing is high-frequency; locking stays a user/orchestration act +// (`SignResult.locked` is therefore always `false`). +import { steem } from '@steemit/steem-js'; + +import { apiClient } from '@/lib/steem/client'; +import { fetchAccounts, type AccountsResponse } from '@/lib/steem/accounts-client'; +import { isValidSteemUsernameFormat, normalizeSteemUsername } from '@/lib/steem/username'; +import type { KeyAuthority, Operation, SteemAccount } from '@/lib/steem/types'; +import { VaultError } from './errors'; +import type { VaultService } from './service'; +import type { + SignOperationsRequest, + SignResolvedTransactionRequest, + SignResult, + SigningRole, + UnsignedTransaction, + VaultKeyEntry, + VaultPayloadV1, +} from './types'; +import { validateVaultWif } from './validation'; + +/** Authority classes as Steem core assigns them to operations. */ +export type AuthorityClass = 'posting' | 'active' | 'owner'; + +/** + * Fixed authority-class table for every operation type the wallet's broadcast + * surface can produce today. Source: the 19 relay routes under + * `src/app/api/broadcast/` plus the client-side signing methods in + * `src/lib/steem/client.ts` (transfer_to_savings / transfer_from_savings / + * transfer_to_vesting ride the `transfer` relay route). Two operations are + * payload-dependent and resolved dynamically in + * {@link resolveRequiredAuthorityClass}: + * - `account_update` — owner when the payload carries an `owner` authority, + * active otherwise; + * - `custom_json` — active when `required_auths` is non-empty, posting when + * only `required_posting_auths` is present. When BOTH lists are non-empty + * the chain requires BOTH authorities and a single-key signature does not + * suffice — the complete mixed-authority mapping is B0's job. + * `recover_account` is DELIBERATELY EXCLUDED: Steem chain semantics require + * the old (recent) owner AND the new owner to BOTH sign the transaction — + * signing with a single owner key is rejected by steemd with + * `tx_missing_other_auth` (see `signRecoverAccount` in + * `src/lib/steem/client.ts` and the 2026-10-08 production incident). The + * vault signer's single-key model cannot express that, so a recover_account + * request fails closed with `VAULT_OPERATION_UNSUPPORTED` (unknown-to-this- + * table path). Recovery flows must keep their dedicated dual-key path and + * must NOT be routed through the vault signer until multi-key signing lands. + * UNKNOWN OPERATION TYPES ARE REJECTED (`VAULT_OPERATION_UNSUPPORTED`, + * fail closed). This table is the A4 interim classifier: the complete + * per-operation authority/account-field mapping (incl. mixed-authority and + * multi-account requirements) is B0's job — extend this table, do not loosen + * the default. + */ +const OPERATION_AUTHORITY_CLASSES: Readonly> = { + // active-class operations (relayed by their own or the transfer route) + transfer: 'active', + transfer_to_savings: 'active', + transfer_from_savings: 'active', + cancel_transfer_from_savings: 'active', + transfer_to_vesting: 'active', + withdraw_vesting: 'active', + delegate_vesting_shares: 'active', + convert: 'active', + limit_order_create: 'active', + limit_order_cancel: 'active', + account_witness_vote: 'active', + account_witness_proxy: 'active', + update_proposal_votes: 'active', + create_proposal: 'active', + remove_proposal: 'active', + set_withdraw_vesting_route: 'active', + account_create: 'active', + // posting-class operations + claim_reward_balance: 'posting', + // owner-class operations + change_recovery_account: 'owner', +}; + +/** Authority levels in ascending privilege — the chain's coverage direction. */ +const PRIVILEGE_ORDER: readonly SigningRole[] = ['posting', 'active', 'owner']; + +/** Everything a key-selection pass needs to know about one vault key entry. */ +interface VaultKeyCandidate { + entry: VaultKeyEntry; + publicKey: string; +} + +/** The selected signing material — SENSITIVE: `wif` must not leave this module. */ +interface SelectedKey { + wif: string; + publicKey: string; + role: SigningRole; +} + +function operationUnsupported(reason: string): VaultError { + return new VaultError('VAULT_OPERATION_UNSUPPORTED', `operation rejected: ${reason}`); +} + +function authorityUnavailable(reason: string): VaultError { + return new VaultError( + 'VAULT_AUTHORITY_UNAVAILABLE', + `chain authority data is unavailable or insufficient: ${reason}`, + ); +} + +function sessionStale(): VaultError { + return new VaultError( + 'VAULT_SESSION_STALE', + 'the unlocked vault changed outside this session; unlock again', + ); +} + +/** Privilege rank of a signing role (posting < active < owner). */ +function privilegeRank(role: SigningRole): number { + const rank = PRIVILEGE_ORDER.indexOf(role); + if (rank < 0) { + throw new TypeError('privilegeRank: role must be one of owner, active, posting'); + } + return rank; +} + +/** + * Whether an operation tuple has the minimal shape the classifier needs: + * `[string, plain-object]`. + */ +function isOperationTuple(op: unknown): op is Operation { + return ( + Array.isArray(op) && + op.length === 2 && + typeof op[0] === 'string' && + typeof op[1] === 'object' && + op[1] !== null && + !Array.isArray(op[1]) + ); +} + +/** + * Resolve the single authority class a set of operations jointly requires: + * the MOST privileged class among them (a transaction must satisfy every + * operation it carries, and a higher key covers lower authorities for the + * same account, so the maximum is both necessary and sufficient). + * + * Fail closed: an empty list, a malformed tuple or an operation type missing + * from the A4 table rejects with `VAULT_OPERATION_UNSUPPORTED` — never a + * default guess. `account_update`/`custom_json` resolve per payload (see the + * table JSDoc). + */ +export function resolveRequiredAuthorityClass(operations: readonly unknown[]): AuthorityClass { + if (!Array.isArray(operations) || operations.length === 0) { + throw operationUnsupported('no operations were provided to classify'); + } + let required: AuthorityClass | null = null; + for (const op of operations) { + if (!isOperationTuple(op)) { + throw operationUnsupported('every operation must be a [type, payload] tuple'); + } + const [type, payload] = op; + let cls: AuthorityClass; + if (type === 'account_update') { + cls = payload['owner'] !== undefined && payload['owner'] !== null ? 'owner' : 'active'; + } else if (type === 'custom_json') { + const requiredAuths = payload['required_auths']; + const requiredPostingAuths = payload['required_posting_auths']; + if (Array.isArray(requiredAuths) && requiredAuths.length > 0) { + cls = 'active'; + } else if (Array.isArray(requiredPostingAuths) && requiredPostingAuths.length > 0) { + cls = 'posting'; + } else { + throw operationUnsupported('custom_json carries no required auths'); + } + } else { + const known = OPERATION_AUTHORITY_CLASSES[type]; + if (known === undefined) { + throw operationUnsupported('operation type is not in the supported signing table'); + } + cls = known; + } + if (required === null || privilegeRank(cls) > privilegeRank(required)) { + required = cls; + } + } + if (required === null) { + throw operationUnsupported('no operations were provided to classify'); + } + return required; +} + +/** Internal marker used by {@link readUnlockedPayload} to abort a no-write read. */ +class PayloadCaptureSignal extends Error { + constructor() { + super('vault payload capture abort'); + this.name = 'PayloadCaptureSignal'; + } +} + +/** + * Read a deep copy of the currently unlocked session's payload WITHOUT + * writing anything. VAULT-INTERNAL ONLY (the docs/AI-driver/09-vault.md + * invariant 14 mutator boundary applies to the mechanism): the service offers + * no read-only payload export by design, and this helper deliberately reuses + * `mutatePayload`'s documented rollback contract — "a throwing mutator + * changes nothing: no write, session untouched" — to capture the draft and + * abort. Nothing is persisted, no revision is bumped, no IV is rotated. + * + * The returned `VaultPayloadV1` contains WIFs: it must never cross into + * components, Redux, logs or the network. Callers today: this module's key + * selection and the manager's changePassword composition. + * + * Failures: `VAULT_LOCKED` (nothing unlocked), `VAULT_SESSION_STALE` (the + * vault changed outside this session) — both surface from the service's own + * session-bound hard line, which runs inside every `mutatePayload` call. + */ +export async function readUnlockedPayload(service: VaultService): Promise { + let captured: VaultPayloadV1 | undefined; + try { + await service.mutatePayload((draft) => { + captured = JSON.parse(JSON.stringify(draft)) as VaultPayloadV1; + throw new PayloadCaptureSignal(); + }); + } catch (error) { + if (!(error instanceof PayloadCaptureSignal)) { + throw error; + } + } + if (captured === undefined) { + // Unreachable (the mutator always runs or the call rejects) — kept as a + // fail-closed guard so a future service change cannot silently yield + // "no payload" as success. + throw new VaultError('VAULT_LOCKED', 'no vault is unlocked in this session'); + } + return captured; +} + +/** + * Validate and normalize the request's account name through the shared + * `normalizeSteemUsername` entry point (format failures → `VAULT_KEY_INVALID`). + * Shared with the legacy privateKey signer (same interface, same rules). + */ +export function normalizeRequestAccount(request: { accountName: unknown }): string { + if (typeof request.accountName !== 'string' || request.accountName.length === 0) { + throw new VaultError('VAULT_KEY_INVALID', 'account name must be a non-empty string'); + } + const normalized = normalizeSteemUsername(request.accountName); + if (!isValidSteemUsernameFormat(normalized)) { + throw new VaultError('VAULT_KEY_INVALID', 'account name is not a valid Steem account name'); + } + return normalized; +} + +/** + * Validate the requested signing role. `memo` is storable but NEVER signs + * (`VAULT_KEY_INVALID`); values outside the fixed vocabulary are a programmer + * error (`TypeError`). + */ +export function requireSigningRole(role: unknown): SigningRole { + if (role === 'owner' || role === 'active' || role === 'posting') { + return role; + } + if (role === 'memo') { + throw new VaultError('VAULT_KEY_INVALID', 'the memo key can be stored but never signs'); + } + throw new TypeError('signing role must be one of owner, active, posting'); +} + +/** + * The authority levels the key selection may walk, from least to most + * privileged: starts at the MORE privileged of the requested role (the + * caller's explicit floor — callers ask for what they need) and the class the + * operations require (the chain's hard floor), then climbs per the consensus + * downward-coverage rule. The selection never goes BELOW either floor. + */ +function authorityWalkLevels(requestedRole: SigningRole, requiredClass: AuthorityClass): SigningRole[] { + const start = Math.max(privilegeRank(requestedRole), privilegeRank(requiredClass)); + return PRIVILEGE_ORDER.slice(start); +} + +/** + * Select the least-privileged vault key the chain will accept for + * `accountName` at authority class `requiredClass`. + * + * For each authority level in walk order (see {@link authorityWalkLevels}), + * every `key_auths` entry of the ON-CHAIN authority is checked — never just + * `key_auths[0]` (the known login-flow limitation; a vault key sitting at + * index 1+ must be found). A key is USABLE when its weight alone meets the + * authority's `weight_threshold` (single-key satisfaction) AND its public key + * is derived from a vault entry of this account with a non-memo role. Memo + * entries are excluded outright: the chain's `memo_key` satisfies no + * operation authority. + * + * Returns `null` when nothing satisfies — thresholds requiring accumulated + * weights, `account_auths`-graph authorities or key sets the vault simply + * does not hold all land here and the caller reports + * `VAULT_AUTHORITY_UNAVAILABLE` (the JSDoc distinction: "chain says no + * single key of ours is enough" versus "we could not read the chain"). + */ +function selectSigningKey( + payload: VaultPayloadV1, + accountName: string, + requestedRole: SigningRole, + requiredClass: AuthorityClass, + account: SteemAccount, +): SelectedKey | null { + const candidates: VaultKeyCandidate[] = []; + for (const entry of payload.keys) { + if (entry.role === 'memo') { + continue; // memo keys never sign + } + if (normalizeSteemUsername(entry.accountName) !== accountName) { + continue; + } + // Shared entry point: real isWif + public-key derivation (mock-driven + // under vitest; real-crypto evidence lives in tests/node/vault-wif-real-check.mjs). + const check = validateVaultWif(entry.privateKey, entry.accountName); + candidates.push({ entry, publicKey: check.publicKey }); + } + for (const level of authorityWalkLevels(requestedRole, requiredClass)) { + const authority: KeyAuthority | undefined = account[level]; + if ( + typeof authority !== 'object' || + authority === null || + !Number.isFinite(authority.weight_threshold) || + !Array.isArray(authority.key_auths) + ) { + // Authority data missing/malformed for this level — the precheck + // treats that as unavailable; selection skips the level defensively. + continue; + } + const threshold = authority.weight_threshold; + for (const pair of authority.key_auths) { + if (!Array.isArray(pair)) { + continue; + } + const publicKey = pair[0]; + const weight = Number(pair[1]); + if (typeof publicKey !== 'string' || !Number.isFinite(weight)) { + continue; + } + if (weight < threshold) { + continue; // below threshold even alone — not usable + } + const candidate = candidates.find((c) => c.publicKey === publicKey); + if (candidate) { + return { wif: candidate.entry.privateKey, publicKey, role: level }; + } + } + } + return null; +} + +/** + * One sorted, batched, deduplicated `/api/query/accounts` query with + * `fresh: true` (browser-L1 bypass; server cache governance untouched) for + * the accounts the signing decision depends on. Any degradation — fetch + * failure, `success !== true`, a `degraded` flag in the body, a missing + * account, or authority fields that are not usable — rejects with + * `VAULT_AUTHORITY_UNAVAILABLE`. + */ +async function fetchAuthorityAccounts(names: readonly string[]): Promise> { + const wanted = [...new Set(names.map((n) => normalizeSteemUsername(n)).filter(Boolean))].sort(); + if (wanted.length === 0) { + throw authorityUnavailable('no account names were provided to verify'); + } + let response: AccountsResponse; + try { + response = await fetchAccounts(wanted, { fresh: true }); + } catch { + throw authorityUnavailable('the accounts query could not be completed'); + } + if (response.success !== true || !Array.isArray(response.accounts)) { + throw authorityUnavailable('the accounts query did not return account data'); + } + // The route stamps `degraded: true` into the JSON body whenever it serves + // degraded/stale upstream data (and on its 503 path) — the type omits the + // optional flag, so read it through a widened view. + const flagged = response as AccountsResponse & { degraded?: unknown }; + if (flagged.degraded === true) { + throw authorityUnavailable('the accounts query reported degraded data'); + } + const byName = new Map(); + for (const account of response.accounts) { + if (typeof account !== 'object' || account === null || typeof account.name !== 'string') { + continue; + } + byName.set(normalizeSteemUsername(account.name), account); + } + for (const name of wanted) { + const account = byName.get(name); + if (account === undefined) { + throw authorityUnavailable('an involved account is missing from the response'); + } + for (const level of ['owner', 'active', 'posting'] as const) { + const authority = account[level]; + if ( + typeof authority !== 'object' || + authority === null || + !Number.isFinite(authority.weight_threshold) || + !Array.isArray(authority.key_auths) || + !Array.isArray(authority.account_auths) + ) { + throw authorityUnavailable('authority data is missing for an involved account'); + } + } + } + return byName; +} + +/** + * Defense-in-depth mid-flight re-verification: the service's own hard line + * ran at entry (`getStatus`), but the authority query and the transaction + * header fetch leave await gaps during which the vault could be locked, + * mutated, renamed or removed (by this tab or another). Before the WIF is + * used, `service.getStatus()` re-runs the service's four-field hard line + * (`requireFreshSession`: name lookup + `createdAt` + `revision` + + * `lockEpoch`). This is deliberately the SERVICE's check, not a hand-rolled + * registry re-read: `createdAt` — the field that defeats a same-name + * remove+recreate whose counters happen to match (the session 0/0 natural + * recreate window found in the A4 review) — is not observable outside the + * service, so any local re-implementation would be a strictly weaker + * three-field variant. Failures surface as `VAULT_SESSION_STALE` (registry + * drift, including remove+recreate) or, when the session itself was cleared + * or locked mid-flight, `VAULT_LOCKED` from the service / `sessionStale()` + * here — all mean "unlock again", and nothing is signed. + */ +async function assertSessionStillCurrent(service: VaultService): Promise { + const fresh = await service.getStatus(); + if (fresh.locked) { + // No session left where the entry check saw one (cleared mid-flight by + // this tab's lock/destroy or an acceleration event). + throw sessionStale(); + } +} + +/** Hex-encode the first `bytes` of a SHA-256 digest — Steem's transaction id. */ +async function computeTransactionId(unsigned: UnsignedTransaction): Promise { + const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle; + if (!subtle) { + throw new VaultError( + 'VAULT_CRYPTO_UNAVAILABLE', + 'Web Crypto subtle API is unavailable in this environment', + ); + } + // Serialize the NORMALIZED unsigned form — the same bytes a node hashes for + // the transaction id (missing vs empty `signatures` serialize differently; + // the unsigned object carries no signatures field at all). + const normalized = steem.auth.normalizeTransactionForBroadcast( + unsigned as unknown as Record, + ); + const serialized = steem.auth.serializeTransaction(normalized); + const bytes = new Uint8Array(serialized); + const digest = new Uint8Array(await subtle.digest('SHA-256', bytes)); + let hex = ''; + for (let i = 0; i < 20; i += 1) { + hex += (digest[i] ?? 0).toString(16).padStart(2, '0'); + } + return hex; +} + +/** Sign a built transaction with the selected key (client-side only). */ +async function signWithKey( + unsigned: UnsignedTransaction, + key: SelectedKey, + accountName: string, +): Promise { + // steem.auth.signTransaction internally normalizes and signs the digest; + // the follow-up normalize mirrors src/lib/steem/client.ts and is idempotent. + const signed = steem.auth.signTransaction(unsigned, [key.wif]); + const signedTx = steem.auth.normalizeTransactionForBroadcast( + signed as unknown as Record, + ) as unknown as SignResult['signedTx']; + return { + signedTx, + transactionId: await computeTransactionId(unsigned), + accountName, + role: key.role, + publicKey: key.publicKey, + locked: false, // signing never auto-locks; see the module header + }; +} + +/** + * Build the vault-mode signer over an unlock-session service. The service + * instance MUST be the one whose session the surrounding UI unlocks (one tab + * = one manager = one service; see `VaultManager.createSigner` for the + * sanctioned composition). Every session question — entry check and the + * mid-flight re-verification before the WIF is used — goes through the + * service's own hard line; the signer keeps no registry access of its own. + */ +export function createVaultSigner(service: VaultService) { + return { + /** + * Sign freshly built operations with a key from the unlocked vault. + * + * Flow: classify + shape-check the operations (fail closed) → session + * entry check (`VAULT_LOCKED` when nothing is unlocked; the service's + * registry hard line runs inside) → ONE fresh batched authority query → + * key selection over the captured payload (`VAULT_AUTHORITY_UNAVAILABLE` + * when no single held key satisfies the on-chain authority — never sign, + * never touch the network for a header) → transaction header fetch → + * mid-flight session re-verification → client-side signing. + * + * `SignResult.role` reports the authority level actually used (downward + * coverage: a posting-class op may legitimately be signed with an active + * or owner key when that is what the vault holds). + */ + async signOperations(request: SignOperationsRequest): Promise { + const requiredClass = resolveRequiredAuthorityClass(request?.operations); + const role = requireSigningRole(request?.role); + const accountName = normalizeRequestAccount(request); + const status = await service.getStatus(); + if (status.locked) { + throw new VaultError('VAULT_LOCKED', 'no vault is unlocked in this session'); + } + const accounts = await fetchAuthorityAccounts([accountName]); + const account = accounts.get(accountName); + if (account === undefined) { + throw authorityUnavailable('an involved account is missing from the response'); + } + const payload = await readUnlockedPayload(service); + const selected = selectSigningKey(payload, accountName, role, requiredClass, account); + if (selected === null) { + throw authorityUnavailable( + 'no vault-held key satisfies the account authority for the requested operations', + ); + } + const header = await apiClient.getTransactionHeader(); + await assertSessionStillCurrent(service); + const unsigned: UnsignedTransaction = { + ref_block_num: header.ref_block_num, + ref_block_prefix: header.ref_block_prefix, + expiration: header.expiration, + operations: request.operations, + extensions: [], + }; + return signWithKey(unsigned, selected, accountName); + }, + + /** + * Sign an already-frozen transaction EXACTLY as received: no header + * refresh, no re-construction, no re-normalization of any non-signature + * field (`ref_block_num`/`ref_block_prefix`/`expiration`/operations pass + * verbatim into the signing call). The same session hard line and the + * same fresh authority precheck apply — only the transaction source + * differs. + */ + async signResolvedTransaction(request: SignResolvedTransactionRequest): Promise { + const transaction = request?.transaction; + if ( + typeof transaction !== 'object' || + transaction === null || + !Number.isFinite(transaction.ref_block_num) || + !Number.isFinite(transaction.ref_block_prefix) || + typeof transaction.expiration !== 'string' || + transaction.expiration.length === 0 || + !Array.isArray(transaction.operations) + ) { + throw operationUnsupported('transaction is not a frozen unsigned transaction'); + } + const requiredClass = resolveRequiredAuthorityClass(transaction.operations); + const role = requireSigningRole(request?.role); + const accountName = normalizeRequestAccount(request); + const status = await service.getStatus(); + if (status.locked) { + throw new VaultError('VAULT_LOCKED', 'no vault is unlocked in this session'); + } + const accounts = await fetchAuthorityAccounts([accountName]); + const account = accounts.get(accountName); + if (account === undefined) { + throw authorityUnavailable('an involved account is missing from the response'); + } + const payload = await readUnlockedPayload(service); + const selected = selectSigningKey(payload, accountName, role, requiredClass, account); + if (selected === null) { + throw authorityUnavailable( + 'no vault-held key satisfies the account authority for the requested operations', + ); + } + await assertSessionStillCurrent(service); + // Frozen semantics: the object is handed to the signer as-is. The txid + // is computed over a shallow copy's normalized serialization so the + // caller's frozen object is never mutated. + const signed = steem.auth.signTransaction(transaction, [selected.wif]); + const signedTx = signed as SignResult['signedTx']; + const transactionId = await computeTransactionId({ ...transaction }); + return { + signedTx, + transactionId, + accountName, + role: selected.role, + publicKey: selected.publicKey, + locked: false, + }; + }, + }; +} diff --git a/src/lib/vault/signing-facade.ts b/src/lib/vault/signing-facade.ts new file mode 100644 index 00000000..a314a1ae --- /dev/null +++ b/src/lib/vault/signing-facade.ts @@ -0,0 +1,106 @@ +// Signing facade (A4) — the ONE dispatch surface business components call. +// It routes each signing request to the backend the persisted signing mode +// selects and nothing else: privateKey mode → the legacy signer, vault mode → +// the vault signer. NO SILENT FALLBACK in either direction — when the mode's +// backend is not wired, the call fails loudly (`TypeError`, a wiring bug) +// rather than quietly signing through the other path. +// +// MODE STATE lives in the registry's settings row (`signingMode`, default +// `'privateKey'` — the A6 acceptance "default privateKey behavior unchanged" +// starts here). `setSigningMode` persists only on success: `putSettings` +// resolves after the write, and any failure propagates with the stored mode +// untouched, so the A5 UI can keep showing the original selection. +// +// COMPOSITION (the reason backends are injected, not created here): the vault +// signer is only meaningful when it shares the unlock-session service the +// user's UI unlocks — one tab = one manager = one service. Building private +// instances inside the facade would produce a signer whose session can never +// be unlocked. Wire it at the app's composition root: +// +// const manager = createVaultManager(registry); +// const facade = createSigningFacade(registry, { +// vaultSigner: manager.createSigner(), +// legacySigner: createLegacyPrivateKeySigner(readKeyFromLoginState), +// }); +// +// Error pass-through contract: `VAULT_LOCKED` (vault mode, nothing unlocked), +// `VAULT_OPERATION_UNSUPPORTED` and every other backend error reach the +// caller untouched — the facade adds no interpretation of its own. +import type { VaultRegistry } from './registry'; +import type { + SignOperationsRequest, + SignResolvedTransactionRequest, + SignResult, + SigningFacade, + SigningMode, + VaultSigner, +} from './types'; + +/** Injected signing backends; both optional so tests (and A6) wire their own. */ +export interface SigningFacadeBackends { + /** Vault-mode backend — normally `manager.createSigner()`. */ + vaultSigner?: VaultSigner; + /** privateKey-mode backend — normally `createLegacyPrivateKeySigner(getter)`. */ + legacySigner?: VaultSigner; +} + +/** + * Build the signing facade over a registry's settings row. Without backends + * the facade still reads/writes the mode but every signing call fails with a + * `TypeError` naming the missing backend — that is deliberate (fail loud, + * never fall back); the composition root is expected to wire both. + */ +export function createSigningFacade( + registry: VaultRegistry, + backends?: SigningFacadeBackends, +): SigningFacade { + const vaultSigner = backends?.vaultSigner; + const legacySigner = backends?.legacySigner; + + async function getSigningMode(): Promise { + return (await registry.getSettings()).signingMode; + } + + async function setSigningMode(mode: SigningMode): Promise { + if (mode !== 'privateKey' && mode !== 'vault') { + throw new TypeError('setSigningMode: mode must be privateKey or vault'); + } + const settings = await registry.getSettings(); + // Persists only on success: a putSettings failure propagates and the + // stored row (and therefore getSigningMode) still reports the old mode. + await registry.putSettings({ ...settings, signingMode: mode }); + } + + function backendFor(mode: SigningMode): VaultSigner { + if (mode === 'vault') { + if (!vaultSigner) { + throw new TypeError( + 'createSigningFacade: the vault signing backend was not provided (wire manager.createSigner())', + ); + } + return vaultSigner; + } + if (!legacySigner) { + throw new TypeError( + 'createSigningFacade: the privateKey signing backend was not provided (wire createLegacyPrivateKeySigner(...))', + ); + } + return legacySigner; + } + + return { + getSigningMode, + + setSigningMode, + + async signOperations(request: SignOperationsRequest): Promise { + const backend = backendFor(await getSigningMode()); + return backend.signOperations(request); + }, + + async signResolvedTransaction(request: SignResolvedTransactionRequest): Promise { + const backend = backendFor(await getSigningMode()); + return backend.signResolvedTransaction(request); + }, + }; +} diff --git a/tests/unit/signing-facade.test.ts b/tests/unit/signing-facade.test.ts new file mode 100644 index 00000000..0e03940a --- /dev/null +++ b/tests/unit/signing-facade.test.ts @@ -0,0 +1,177 @@ +// Signing facade tests (A4) — pure dispatch semantics over the registry's +// settings row. No PBKDF2 is paid anywhere in this file: both signing +// backends are injected mocks and the only real storage is the fake +// IndexedDB settings store (tests/mocks/fake-indexeddb.ts). +// +// The contracts under test: default mode privateKey; setSigningMode persists +// only on success (a failing putSettings leaves the stored mode untouched — +// the A5 "keep the original selection on failure" behavior); dispatch goes +// EXACTLY to the mode's backend with no silent fallback in either direction; +// backend errors (VAULT_LOCKED, VAULT_OPERATION_UNSUPPORTED, …) pass through +// untouched. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installFakeIndexedDB, type FakeIndexedDBHandle } from '../mocks/fake-indexeddb'; +import { VaultError } from '@/lib/vault/errors'; +import { createSigningFacade } from '@/lib/vault/signing-facade'; +import { openVaultRegistry, type VaultRegistry } from '@/lib/vault/registry'; +import type { + SignOperationsRequest, + SignResolvedTransactionRequest, + SigningFacade, + VaultSigner, +} from '@/lib/vault/types'; + +const REQUEST: SignOperationsRequest = { + accountName: 'alice', + role: 'posting', + operations: [ + ['custom_json', { required_posting_auths: ['alice'], id: 'x', json: '[]' }], + ], +}; +const RESOLVED_REQUEST: SignResolvedTransactionRequest = { + accountName: 'alice', + role: 'active', + transaction: { + ref_block_num: 1, + ref_block_prefix: 2, + expiration: '2030-01-01T00:00:00', + operations: REQUEST.operations, + extensions: [], + }, +}; + +function mockBackend(): { backend: VaultSigner; signOperations: ReturnType; signResolvedTransaction: ReturnType } { + const signOperations = vi.fn(); + const signResolvedTransaction = vi.fn(); + return { backend: { signOperations, signResolvedTransaction }, signOperations, signResolvedTransaction }; +} + +describe('signing facade (A4)', () => { + let fake: FakeIndexedDBHandle; + let registry: VaultRegistry; + + beforeEach(async () => { + fake = installFakeIndexedDB(); + registry = await openVaultRegistry(); + // Touch the settings store once so the fake database exists for wrappers. + await registry.getSettings(); + }); + + afterEach(() => { + registry.close(); + fake.uninstall(); + fake.reset(); + }); + + it('defaults to privateKey mode and round-trips setSigningMode', async () => { + const legacy = mockBackend(); + const vault = mockBackend(); + const facade = createSigningFacade(registry, { + vaultSigner: vault.backend, + legacySigner: legacy.backend, + }); + await expect(facade.getSigningMode()).resolves.toBe('privateKey'); + await facade.setSigningMode('vault'); + await expect(facade.getSigningMode()).resolves.toBe('vault'); + await facade.setSigningMode('privateKey'); + await expect(facade.getSigningMode()).resolves.toBe('privateKey'); + // Runtime garbage is a programmer error, and the stored mode survives. + await expect(facade.setSigningMode('memo' as 'vault')).rejects.toThrow(TypeError); + await expect(facade.getSigningMode()).resolves.toBe('privateKey'); + // The persisted row is the source of truth — a fresh facade sees it too. + await expect( + createSigningFacade(registry).getSigningMode(), + ).resolves.toBe('privateKey'); + }); + + it('dispatches privateKey mode to the legacy backend only', async () => { + const legacy = mockBackend(); + const vault = mockBackend(); + legacy.signOperations.mockResolvedValue({ ok: 'legacy-operations' }); + legacy.signResolvedTransaction.mockResolvedValue({ ok: 'legacy-resolved' }); + const facade: SigningFacade = createSigningFacade(registry, { + vaultSigner: vault.backend, + legacySigner: legacy.backend, + }); + await expect(facade.signOperations(REQUEST)).resolves.toEqual({ ok: 'legacy-operations' }); + await expect(facade.signResolvedTransaction(RESOLVED_REQUEST)).resolves.toEqual({ + ok: 'legacy-resolved', + }); + expect(legacy.signOperations).toHaveBeenCalledWith(REQUEST); + expect(legacy.signResolvedTransaction).toHaveBeenCalledWith(RESOLVED_REQUEST); + expect(vault.signOperations).not.toHaveBeenCalled(); + expect(vault.signResolvedTransaction).not.toHaveBeenCalled(); + }); + + it('dispatches vault mode to the vault backend only', async () => { + const legacy = mockBackend(); + const vault = mockBackend(); + vault.signOperations.mockResolvedValue({ ok: 'vault-operations' }); + vault.signResolvedTransaction.mockResolvedValue({ ok: 'vault-resolved' }); + const facade = createSigningFacade(registry, { + vaultSigner: vault.backend, + legacySigner: legacy.backend, + }); + await facade.setSigningMode('vault'); + await expect(facade.signOperations(REQUEST)).resolves.toEqual({ ok: 'vault-operations' }); + await expect(facade.signResolvedTransaction(RESOLVED_REQUEST)).resolves.toEqual({ + ok: 'vault-resolved', + }); + expect(vault.signOperations).toHaveBeenCalledWith(REQUEST); + expect(legacy.signOperations).not.toHaveBeenCalled(); + expect(legacy.signResolvedTransaction).not.toHaveBeenCalled(); + }); + + it('passes VAULT_LOCKED and VAULT_OPERATION_UNSUPPORTED through untouched', async () => { + const vault = mockBackend(); + vault.signOperations.mockRejectedValue( + new VaultError('VAULT_LOCKED', 'no vault is unlocked in this session'), + ); + vault.signResolvedTransaction.mockRejectedValue( + new VaultError('VAULT_OPERATION_UNSUPPORTED', 'operation rejected: unknown type'), + ); + const facade = createSigningFacade(registry, { vaultSigner: vault.backend }); + await facade.setSigningMode('vault'); + await expect(facade.signOperations(REQUEST)).rejects.toMatchObject({ code: 'VAULT_LOCKED' }); + await expect(facade.signResolvedTransaction(RESOLVED_REQUEST)).rejects.toMatchObject({ + code: 'VAULT_OPERATION_UNSUPPORTED', + }); + }); + + it('fails loudly when the mode points at an unwired backend (no silent fallback)', async () => { + const legacy = mockBackend(); + legacy.signOperations.mockResolvedValue({ ok: 'legacy' }); + // privateKey wired, vault NOT wired. + const halfWired = createSigningFacade(registry, { legacySigner: legacy.backend }); + await halfWired.setSigningMode('vault'); + await expect(halfWired.signOperations(REQUEST)).rejects.toThrow( + /vault signing backend was not provided/, + ); + expect(legacy.signOperations).not.toHaveBeenCalled(); + // Vault wired, privateKey NOT wired (flip the shared mode back first — + // the settings row is the single source of truth across facades). + const otherWay = createSigningFacade(registry, { vaultSigner: mockBackend().backend }); + await otherWay.setSigningMode('privateKey'); + await expect(otherWay.signOperations(REQUEST)).rejects.toThrow( + /privateKey signing backend was not provided/, + ); + }); + + it('setSigningMode failure keeps the stored mode (nothing persisted)', async () => { + const failing: VaultRegistry = { + ...registry, + putSettings: () => + Promise.reject(new VaultError('VAULT_STORAGE_UNAVAILABLE', 'injected put failure')), + }; + const facade = createSigningFacade(failing, { + vaultSigner: mockBackend().backend, + legacySigner: mockBackend().backend, + }); + await expect(facade.setSigningMode('vault')).rejects.toMatchObject({ + code: 'VAULT_STORAGE_UNAVAILABLE', + }); + // The real registry still reports the untouched default. + await expect(registry.getSettings()).resolves.toMatchObject({ signingMode: 'privateKey' }); + await expect(facade.getSigningMode()).resolves.toBe('privateKey'); + }); +}); diff --git a/tests/unit/vault-manager.test.ts b/tests/unit/vault-manager.test.ts new file mode 100644 index 00000000..38b521d7 --- /dev/null +++ b/tests/unit/vault-manager.test.ts @@ -0,0 +1,719 @@ +// Vault manager tests (A4). Storage runs on the hand-written fake IndexedDB +// (tests/mocks/fake-indexeddb.ts — see its FIDELITY BOUNDARY header); the +// seeded vaults decrypt REAL fixture ciphertexts under the fixture password. +// +// ORDER INDEPENDENCE: every test rebuilds its own fake database, registry and +// manager in beforeEach and tears them down in afterEach — shuffle- and +// single-test safe (the A2/A3 standard). +// +// Performance: PBKDF2 600k costs ~0.5-0.8s per derivation. Exactly ONE is +// paid in beforeAll (the KEK that seals the shared import/merge source +// envelope); each test then pays only for the derivations its own calls +// perform (measured counts: create 1; unlock 1 — also 1 when the password +// is wrong, derivation precedes authentication; independent import 3 per +// success — backup-envelope open 1 + registry createVault 1 + the import's +// internal session unlock 1 — or 1 on a wrong source password; merge 1 +// after its pre-unlock (the post-merge lock derives nothing) plus the +// re-unlock; changePassword 2 plus verification re-unlocks; probe +// authentications 1). The review-r2 C2 race tests inject via a REAL second +// service/manager (its unlock counts 1, a removeVault adds its probe 1 and +// a recreate's createVault 1) and verify through one re-unlock each. +// Whole-file budget: 48 derivations (1 beforeAll + 47 in-test); per-test +// budgets are set as timeouts. +// Do not add unlock-happy tests without pruning. +// +// The BLUEPRINT HARD CONTRACT acceptance lives here: a successful merge +// orchestration must leave the target locked with lockEpoch +1 versus +// pre-merge, and a lock() persistence failure must still have cleared memory +// and propagate. +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import vectors from '../fixtures/vault-development-test-vectors.json'; +import { installFakeIndexedDB, type FakeIndexedDBHandle } from '../mocks/fake-indexeddb'; +import { deriveVaultKek, encryptVaultText, generateVaultIv } from '@/lib/vault/crypto'; +import { decodeBase64Url, encodeBase64Url, validateVaultPayloadText } from '@/lib/vault/envelope'; +import { VaultError } from '@/lib/vault/errors'; +import { + createVaultManager, + type VaultManagerExtended, +} from '@/lib/vault/manager'; +import { VAULT_DB_NAME, openVaultRegistry, type VaultRegistry } from '@/lib/vault/registry'; +import { createVaultService } from '@/lib/vault/service'; +import type { VaultEnvelopeV1, VaultKeyEntry, VaultRecordV1 } from '@/lib/vault/types'; +import { vaultNameComparisonKey } from '@/lib/vault/validation'; + +const v = vectors.vault; + +const fixtureKeys = validateVaultPayloadText(v.payloadText).keys; +const aliceEntry: VaultKeyEntry | undefined = fixtureKeys[0]; +if (!aliceEntry) { + throw new Error('fixture payload has no key entry'); +} +const aliceWif = aliceEntry.privateKey; +const alicePublicKey = `STM${aliceWif.slice(-8)}`; // mock getPublicKey: 'STM' + last 8 +const BOB_ENTRY: VaultKeyEntry = { accountName: 'bob', role: 'active', privateKey: '5Ktest-wif-bob-0001' }; +const bobPublicKey = `STM${BOB_ENTRY.privateKey.slice(-8)}`; + +const NEW_PASSWORD = 'a-perfectly-fine-new-passphrase'; + +/** Async rejection-code assertion helper (accepts a promise or a thunk). */ +async function expectRejectedBy( + promiseOrThunk: Promise | (() => Promise), + code: string, +): Promise { + const promise = + typeof promiseOrThunk === 'function' ? promiseOrThunk() : promiseOrThunk; + let caught: unknown; + try { + await promise; + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as { code?: unknown }).code).toBe(code); +} + +let fakeClock = 1_700_300_000_000; + +// BroadcastChannel plumbing shared by the cross-tab race tests: vitest jsdom +// exposes Node's own BroadcastChannel (real delivery; an instance never +// receives its own messages), so acceleration events can be hidden for +// hard-line-only determinism and settled for delivery — the vault-service +// suite's established pattern. +const broadcastDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'BroadcastChannel'); + +function hideBroadcastChannel(): void { + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: undefined, + writable: true, + configurable: true, + }); +} + +function restoreBroadcastChannel(): void { + if (broadcastDescriptor) { + Object.defineProperty(globalThis, 'BroadcastChannel', broadcastDescriptor); + } else { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + } +} + +/** Let Node deliver queued BroadcastChannel acceleration events. */ +const waitBroadcast = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 25); + }); + +// changePassword's reads of the target record through a gated registry, in +// order: unlock's initial read + born-stale recheck (2), the payload +// capture's requireFreshSession read (1) — so the 4th read is +// requireRecord's, the deterministic point BETWEEN the capture and the +// freshness guard where review-r2's C2 interleavings land. +const REQUIRE_RECORD_READ_INDEX = 4; + +/** + * Wrap a registry so the `getRecord` call changePassword's `requireRecord` + * makes for the target vault first runs `interleave()` to completion and + * settles BroadcastChannel delivery, then re-reads and returns the + * POST-interleave record (what the real flow would observe). The concurrent + * "other tab" must write through the RAW registry so the gate never + * re-enters. Pinned to the current changePassword read sequence — if the + * implementation changes the read order, these tests fail loudly rather + * than silently stop covering the window. + */ +function gateRequireRecord( + base: VaultRegistry, + targetVaultName: string, + interleave: () => Promise, +): VaultRegistry { + const targetKey = vaultNameComparisonKey(targetVaultName); + let targetReads = 0; + return { + ...base, + async getRecord(vaultName: string) { + if (vaultNameComparisonKey(vaultName) === targetKey) { + targetReads += 1; + if (targetReads === REQUIRE_RECORD_READ_INDEX) { + await interleave(); + await waitBroadcast(); + } + } + return base.getRecord(vaultName); + }, + }; +} + +function makeRecord( + vaultName: string, + envelope: { salt: string; iv: string; ciphertext: string }, +): VaultRecordV1 { + return { + version: 1, + vaultName, + salt: envelope.salt, + iv: envelope.iv, + ciphertext: envelope.ciphertext, + revision: 0, + lockEpoch: 0, + createdAt: fakeClock++, + updatedAt: fakeClock++, + lastBackupAt: null, + }; +} + +describe('vault manager (A4)', () => { + let fake: FakeIndexedDBHandle; + let registry: VaultRegistry; + let manager: VaultManagerExtended; + // Shared source backup for the independent import AND the merge migration + // (one payload — bob's active key — sealed once under the fixture password). + let sourceEnvelope: VaultEnvelopeV1; + + beforeAll(async () => { + const kek = await deriveVaultKek(v.password, decodeBase64Url(v.salt)); + const iv = generateVaultIv(); + sourceEnvelope = { + version: 1, + vaultName: 'source-backup', + salt: v.salt, + iv: encodeBase64Url(iv), + ciphertext: encodeBase64Url( + await encryptVaultText(kek, iv, JSON.stringify({ keys: [BOB_ENTRY] })), + ), + }; + }, 30000); + + beforeEach(async () => { + fake = installFakeIndexedDB(); + registry = await openVaultRegistry(); + fake.seed(VAULT_DB_NAME, 'vaults', makeRecord('demo', { salt: v.salt, iv: v.iv, ciphertext: v.ciphertext })); + fake.seed( + VAULT_DB_NAME, + 'vaults', + makeRecord('alpha', { salt: v.salt, iv: v.reencryption.iv, ciphertext: v.reencryption.ciphertext }), + ); + fake.seed( + VAULT_DB_NAME, + 'vaults', + makeRecord('beta', { salt: v.salt, iv: v.emptyPayload.iv, ciphertext: v.emptyPayload.ciphertext }), + ); + manager = createVaultManager(registry); + }); + + afterEach(() => { + manager.destroy(); + registry.close(); + fake.uninstall(); + fake.reset(); + }); + + const currentRecord = async (name: string): Promise => { + const record = await registry.getRecord(name); + if (!record) { + throw new Error(`record ${name} disappeared`); + } + return record; + }; + + describe('createVault / listVaults / activateVault', () => { + it('creates a locked, inactive vault with the empty payload', async () => { + const summary = await manager.createVault({ vaultName: 'Work', password: v.password }); + expect(summary).toMatchObject({ vaultName: 'Work', active: false, locked: true }); + const record = await currentRecord('Work'); + expect(record.revision).toBe(0); + // Decrypting the stored ciphertext is NOT needed to prove emptiness — + // unlocking the new vault yields zero accounts (1 derivation). + await manager.unlockVault('Work', v.password); + await expect(manager.getAccounts()).resolves.toEqual([]); + }, 30000); + + it('rejects weak passwords and duplicate aliases without deriving', async () => { + await expectRejectedBy( + manager.createVault({ vaultName: 'Weak', password: 'password' }), + 'VAULT_PASSWORD_WEAK', + ); + await expectRejectedBy( + manager.createVault({ vaultName: 'demo', password: v.password }), + 'VAULT_NAME_EXISTS', + ); + await expectRejectedBy( + manager.createVault({ vaultName: 'DEMO', password: v.password }), + 'VAULT_NAME_EXISTS', + ); + expect((await registry.listRecords()).map((r) => r.vaultName).sort()).toEqual([ + 'alpha', + 'beta', + 'demo', + ]); + }); + + it('listVaults maps active (settings) and locked (session) per record', async () => { + await manager.activateVault('alpha'); + let list = await manager.listVaults(); + expect(list.map((s) => [s.vaultName, s.active, s.locked])).toEqual([ + ['alpha', true, true], + ['beta', false, true], + ['demo', false, true], + ]); + await manager.unlockVault('demo', v.password); + list = await manager.listVaults(); + expect(list.map((s) => [s.vaultName, s.active, s.locked])).toEqual([ + ['alpha', true, true], + ['beta', false, true], + ['demo', false, false], + ]); + await expectRejectedBy(manager.activateVault('ghost'), 'VAULT_NOT_FOUND'); + }, 30000); + }); + + describe('getAccounts / unlockVault', () => { + it('exposes session-bound account summaries; locked → VAULT_LOCKED', async () => { + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + await manager.unlockVault('demo', v.password); + await expect(manager.getAccounts()).resolves.toEqual([ + { + accountName: 'alice', + keys: [{ accountName: 'alice', role: 'posting', publicKey: alicePublicKey }], + balances: null, + }, + ]); + await expectRejectedBy(manager.unlockVault('demo', 'wrong-password-123456'), 'VAULT_PASSWORD_INVALID'); + await expectRejectedBy(manager.unlockVault('ghost', v.password), 'VAULT_NOT_FOUND'); + }, 30000); + }); + + describe('importVault (independent)', () => { + it('creates a new locked vault from a backup; no pre-merge snapshot; no activation', async () => { + const detailed = await manager.importVaultDetailed({ + mode: 'independent', + envelope: sourceEnvelope, + password: v.password, + vaultName: 'Imported', + }); + expect(detailed.preMergeBackup).toBeNull(); + expect(detailed.summary).toMatchObject({ vaultName: 'Imported', active: false, locked: true }); + // The A0-shaped importVault returns just the summary. + const summary = await manager.importVault({ + mode: 'independent', + envelope: sourceEnvelope, + password: v.password, + vaultName: 'Second', + }); + expect(summary).toMatchObject({ vaultName: 'Second', locked: true }); + // A password that cannot authenticate the source backup is refused + // (the weak-source-policy branch is the A3 primitive's own contract). + await expectRejectedBy( + manager.importVault({ + mode: 'independent', + envelope: sourceEnvelope, + password: 'wrong-password-123456', + vaultName: 'Third', + }), + 'VAULT_PASSWORD_INVALID', + ); + expect((await registry.listRecords()).map((r) => r.vaultName).sort()).toEqual([ + 'Imported', + 'Second', + 'alpha', + 'beta', + 'demo', + ]); + }, 60000); + }); + + describe('importVault (merge) — blueprint hard contract', () => { + it('locks the target after a successful merge: locked:true, lockEpoch +1, preMergeBackup delivered', async () => { + await manager.unlockVault('demo', v.password); + const before = await currentRecord('demo'); + const detailed = await manager.importVaultDetailed({ + mode: 'merge', + envelope: sourceEnvelope, + password: v.password, + targetVaultName: 'demo', + }); + // The hard-contract acceptance: locked after merge, epoch advanced. + expect(detailed.summary).toMatchObject({ vaultName: 'demo', locked: true }); + const after = await currentRecord('demo'); + expect(after.lockEpoch).toBe(before.lockEpoch + 1); + expect(after.revision).toBe(before.revision + 1); + // The pre-merge snapshot is exactly the pre-merge five-field envelope. + expect(detailed.preMergeBackup).not.toBeNull(); + expect(detailed.preMergeBackup).toEqual({ + version: before.version, + vaultName: before.vaultName, + salt: before.salt, + iv: before.iv, + ciphertext: before.ciphertext, + }); + // The session is really gone: signing-grade reads require a re-unlock. + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + // And the merged content is intact under the re-unlock (bob merged in). + await manager.unlockVault('demo', v.password); + const accounts = await manager.getAccounts(); + expect(accounts.map((a) => a.accountName).sort()).toEqual(['alice', 'bob']); + expect(accounts[1]).toMatchObject({ + accountName: 'bob', + keys: [{ accountName: 'bob', role: 'active', publicKey: bobPublicKey }], + }); + }, 60000); + + it('requires the target unlocked in this manager (VAULT_LOCKED otherwise)', async () => { + await expectRejectedBy( + manager.importVault({ + mode: 'merge', + envelope: sourceEnvelope, + password: v.password, + targetVaultName: 'demo', + }), + 'VAULT_LOCKED', + ); + // Nothing was written. + const record = await currentRecord('demo'); + expect(record.revision).toBe(0); + }); + + it('a lock() persistence failure after the merge still clears memory and propagates', async () => { + // bumpLockEpoch — the registry primitive only service.lock() uses — is + // made to fail AFTER the merge itself succeeded. + const failing: VaultRegistry = { + ...registry, + bumpLockEpoch: () => + Promise.reject(new VaultError('VAULT_STORAGE_UNAVAILABLE', 'injected bump failure')), + }; + const doomed = createVaultManager(failing); + try { + await doomed.unlockVault('demo', v.password); + await expectRejectedBy( + doomed.importVault({ + mode: 'merge', + envelope: sourceEnvelope, + password: v.password, + targetVaultName: 'demo', + }), + 'VAULT_STORAGE_UNAVAILABLE', + ); + // Memory was cleared BEFORE the failed persist: the manager reports + // plain VAULT_LOCKED, not a stale session. + await expectRejectedBy(doomed.getAccounts(), 'VAULT_LOCKED'); + // The merge write itself was durable (CAS succeeded pre-lock). + const record = await currentRecord('demo'); + expect(record.revision).toBe(1); + } finally { + doomed.destroy(); + } + }, 60000); + }); + + describe('changePassword', () => { + it('re-encrypts under a new salt; new password unlocks, old fails; ends locked', async () => { + // Hide BroadcastChannel BEFORE creating the second manager so its + // invalidation is proven through the HARD LINE only (the acceleration + // layer would turn the final assertion into LOCKED — valid, but + // timing-dependent; see the A2 service suite's no-BC pattern). + const broadcastDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'BroadcastChannel'); + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: undefined, + writable: true, + configurable: true, + }); + const registryB = await openVaultRegistry(); + const other = createVaultManager(registryB); + try { + await other.unlockVault('demo', v.password); + // The manager starts WITHOUT a session: changePassword authenticates + // on its own (unlocking an already-unlocked vault would first switch + // — lock + re-unlock — and bump the epoch twice; the switch-clears + // semantics are the A2 service contract, not this flow's target). + const before = await currentRecord('demo'); + await manager.changePassword('demo', v.password, NEW_PASSWORD); + const after = await currentRecord('demo'); + // Fresh salt + fresh ciphertext, revision +1, lockEpoch +1 (post-change lock). + expect(after.salt).not.toBe(before.salt); + expect(after.ciphertext).not.toBe(before.ciphertext); + expect(after.revision).toBe(before.revision + 1); + expect(after.lockEpoch).toBe(before.lockEpoch + 1); + // Locked after the change… + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + // …the new password unlocks and the payload survived… + await manager.unlockVault('demo', NEW_PASSWORD); + await expect(manager.getAccounts()).resolves.toBeTruthy(); + // …and the old password no longer authenticates. + await expectRejectedBy(manager.unlockVault('demo', v.password), 'VAULT_PASSWORD_INVALID'); + // Another tab's pre-change session is stale (the revision moved). + await expectRejectedBy(other.getAccounts(), 'VAULT_SESSION_STALE'); + } finally { + other.destroy(); + registryB.close(); + if (broadcastDescriptor) { + Object.defineProperty(globalThis, 'BroadcastChannel', broadcastDescriptor); + } else { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + } + } + }, 60000); + + it('authentication first, policy second: wrong old / weak new are rejected untouched', async () => { + // Order matters for the counters: each failing call below must leave + // the stored record at 0/0. (A FAILED changePassword may legitimately + // leave the session unlocked — the old password WAS correctly entered — + // but it never writes: no revision bump, no epoch bump.) + await expectRejectedBy( + manager.changePassword('ghost', v.password, NEW_PASSWORD), + 'VAULT_NOT_FOUND', + ); + await expectRejectedBy( + manager.changePassword('demo', 'wrong-password-123456', NEW_PASSWORD), + 'VAULT_PASSWORD_INVALID', + ); + await expectRejectedBy( + manager.changePassword('demo', v.password, 'password'), + 'VAULT_PASSWORD_WEAK', + ); + const record = await currentRecord('demo'); + expect(record.revision).toBe(0); + expect(record.lockEpoch).toBe(0); + }, 30000); + }); + + describe('changePassword cross-tab races in the capture→guard window (review r2 C2)', () => { + // C2 regression: a cross-tab write landing between the payload capture + // and the pre-CAS freshness guard. changePassword must CHECK the value + // getStatus returns — the service answers a CLEARED session with + // `{ locked: true }` and does NOT throw (the hard line inside it never + // runs without a session to compare) — otherwise the stale captured + // plaintext gets re-encrypted over the concurrent write: the swap's CAS + // pointer comes from the POST-mutation requireRecord read, so the CAS + // itself cannot catch it. + + it('a cross-tab mutation clearing the session mid-flow → STALE; the concurrent key survives, no new envelope', async () => { + // Tab B (a real second service over the same storage) bumps the + // revision and its vault-mutate acceleration event clears tab A's + // freshly-unlocked session — the exact interleaving where an unchecked + // getStatus guard is a no-op. + let injected = false; + let concurrentRecord: VaultRecordV1 | null = null; + const tabB = createVaultService(registry); + const gated = gateRequireRecord(registry, 'demo', async () => { + injected = true; + await tabB.unlock('demo', v.password); + await tabB.mutatePayload((draft) => { + draft.keys.push({ ...BOB_ENTRY }); + }); + concurrentRecord = await registry.getRecord('demo'); + }); + const tabA = createVaultManager(gated); + try { + await expectRejectedBy( + tabA.changePassword('demo', v.password, NEW_PASSWORD), + 'VAULT_SESSION_STALE', + ); + expect(injected).toBe(true); + // Storage keeps the concurrent write byte-for-byte: bob's mutation at + // revision 1, epoch 0, the ORIGINAL salt (no fresh-salt re-encryption + // — a password change mints one) and B's IV/ciphertext untouched. + const after = await currentRecord('demo'); + expect(after).toEqual(concurrentRecord); + expect(after.revision).toBe(1); + expect(after.lockEpoch).toBe(0); + expect(after.salt).toBe(v.salt); + // And the payload really still carries both keys: the rollback shape + // would leave only alice. + await tabA.unlockVault('demo', v.password); + const accounts = await tabA.getAccounts(); + expect(accounts.map((a) => a.accountName).sort()).toEqual(['alice', 'bob']); + const bob = accounts.find((a) => a.accountName === 'bob'); + expect(bob?.keys).toEqual([ + { accountName: 'bob', role: 'active', publicKey: bobPublicKey }, + ]); + } finally { + tabA.destroy(); + tabB.destroy(); + } + }, 60000); + + it('same interleave with BroadcastChannel hidden → the hard line catches the revision drift (deterministic backstop)', async () => { + // Belt-and-suspenders for the broadcast-timing-dependent test above: + // with no acceleration layer at all, the session survives in memory + // and the guard's getStatus runs the four-field hard line, sees the + // revision drift and clears + rejects. STALE either way. + hideBroadcastChannel(); + let injected = false; + const tabB = createVaultService(registry); + const gated = gateRequireRecord(registry, 'demo', async () => { + injected = true; + await tabB.unlock('demo', v.password); + await tabB.mutatePayload((draft) => { + draft.keys.push({ ...BOB_ENTRY }); + }); + }); + const tabA = createVaultManager(gated); + try { + await expectRejectedBy( + tabA.changePassword('demo', v.password, NEW_PASSWORD), + 'VAULT_SESSION_STALE', + ); + expect(injected).toBe(true); + const after = await currentRecord('demo'); + expect(after.revision).toBe(1); + expect(after.lockEpoch).toBe(0); + expect(after.salt).toBe(v.salt); + await tabA.unlockVault('demo', v.password); + const accounts = await tabA.getAccounts(); + expect(accounts.map((a) => a.accountName).sort()).toEqual(['alice', 'bob']); + } finally { + tabA.destroy(); + tabB.destroy(); + restoreBroadcastChannel(); + } + }, 60000); + + it('a same-count remove+recreate in the same window → STALE; the recreated instance is not overwritten', async () => { + // Second C2 shape: another tab removes the vault and recreates the + // SAME name. The recreated record's counters restart at 0/0 — exactly + // what the seeded original had — and the removal flow's lock broadcast + // clears tab A's session, so only the guard's return-value check + // stands between the stale captured plaintext and the recreated + // instance. + let injected = false; + let recreatedSnapshot: VaultRecordV1 | null = null; + const tabB = createVaultManager(registry); + const gated = gateRequireRecord(registry, 'demo', async () => { + injected = true; + // B holds the session, so its removeVault locks first (broadcast — + // clears tab A's session) after the probe authentication. + await tabB.unlockVault('demo', v.password); + await tabB.removeVault('demo', v.password); + await tabB.createVault({ vaultName: 'demo', password: v.password }); + recreatedSnapshot = await registry.getRecord('demo'); + }); + const tabA = createVaultManager(gated); + try { + expect(recreatedSnapshot).toBeNull(); // sanity: injected only mid-flow + await expectRejectedBy( + tabA.changePassword('demo', v.password, NEW_PASSWORD), + 'VAULT_SESSION_STALE', + ); + expect(injected).toBe(true); + // The recreated instance survived byte-for-byte: fresh-salt empty + // vault at 0/0 — not the old plaintext under a new-password envelope. + const after = await currentRecord('demo'); + expect(after).toEqual(recreatedSnapshot); + expect(after.revision).toBe(0); + expect(after.lockEpoch).toBe(0); + expect(after.salt).not.toBe(v.salt); + // Functionally: the vault still opens with the recreate password and + // is EMPTY — alice must not be resurrected by the old plaintext. + await tabA.unlockVault('demo', v.password); + await expect(tabA.getAccounts()).resolves.toEqual([]); + } finally { + tabA.destroy(); + tabB.destroy(); + } + }, 60000); + }); + + describe('lockVault — two semantics', () => { + it('no argument locks the current session (epoch +1)', async () => { + await manager.unlockVault('demo', v.password); + const before = await currentRecord('demo'); + await manager.lockVault(); + expect((await currentRecord('demo')).lockEpoch).toBe(before.lockEpoch + 1); + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + }, 30000); + + it('naming the current session vault is a local lock', async () => { + await manager.unlockVault('demo', v.password); + const before = await currentRecord('demo'); + await manager.lockVault('demo'); + expect((await currentRecord('demo')).lockEpoch).toBe(before.lockEpoch + 1); + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + }, 30000); + + it('naming another vault remote-locks it: epoch bump, other sessions go STALE, local session untouched', async () => { + const registryB = await openVaultRegistry(); + const other = createVaultManager(registryB); + try { + await other.unlockVault('alpha', v.password); + await manager.unlockVault('demo', v.password); // an unrelated live session + const before = await currentRecord('alpha'); + await manager.lockVault('alpha'); + expect((await currentRecord('alpha')).lockEpoch).toBe(before.lockEpoch + 1); + // The tab holding alpha unlocked is stale on its next operation — + // deterministic: the remote path bumps the registry directly and + // never broadcasts, so only the hard line can catch it. + await expectRejectedBy(other.getAccounts(), 'VAULT_SESSION_STALE'); + // …while the locking tab's own session (demo) is untouched. + await expect(manager.getAccounts()).resolves.toBeTruthy(); + // Unknown names still surface VAULT_NOT_FOUND. + await expectRejectedBy(manager.lockVault('ghost'), 'VAULT_NOT_FOUND'); + } finally { + other.destroy(); + registryB.close(); + } + }, 60000); + }); + + describe('renameVault / removeVault / exportVault', () => { + it('renames; an unlocked session on the old name goes stale; the settings pointer follows', async () => { + await manager.activateVault('demo'); + await manager.unlockVault('demo', v.password); + await manager.renameVault('demo', 'Renamed'); + expect(await registry.getRecord('demo')).toBeNull(); + const record = await currentRecord('Renamed'); + expect(record.revision).toBe(0); + // The active pointer followed the rename. + expect((await registry.getSettings()).activeVaultName).toBe('Renamed'); + // The old session cannot operate — the record is gone from its name. + await expectRejectedBy(manager.getAccounts(), 'VAULT_SESSION_STALE'); + await expect(manager.unlockVault('Renamed', v.password)).resolves.toMatchObject({ + locked: false, + vaultName: 'Renamed', + }); + await expectRejectedBy(manager.renameVault('Renamed', 'alpha'), 'VAULT_NAME_EXISTS'); + }, 30000); + + it('removes only after password authentication; dangling active pointer is cleared', async () => { + await manager.activateVault('demo'); + await expectRejectedBy( + manager.removeVault('demo', 'wrong-password-123456'), + 'VAULT_PASSWORD_INVALID', + ); + expect(await registry.getRecord('demo')).not.toBeNull(); + // An unrelated live session (alpha) must survive the removal flow. + await manager.unlockVault('alpha', v.password); + await manager.removeVault('demo', v.password); + expect(await registry.getRecord('demo')).toBeNull(); + expect((await registry.getSettings()).activeVaultName).toBeNull(); + await expect(manager.getAccounts()).resolves.toBeTruthy(); // alpha session intact + await expectRejectedBy(manager.removeVault('demo', v.password), 'VAULT_NOT_FOUND'); + }, 40000); + + it('removing the currently-unlocked vault clears the session first', async () => { + await manager.unlockVault('demo', v.password); + await manager.removeVault('demo', v.password); + expect(await registry.getRecord('demo')).toBeNull(); + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + }, 30000); + + it('export returns the five-field envelope and stamps lastBackupAt; the session is locked first', async () => { + await manager.unlockVault('demo', v.password); + const before = await currentRecord('demo'); + const envelope = await manager.exportVault('demo'); + expect(envelope).toEqual({ + version: 1, + vaultName: 'demo', + salt: before.salt, + iv: before.iv, + ciphertext: before.ciphertext, + }); + const after = await currentRecord('demo'); + expect(after.lastBackupAt).not.toBeNull(); + // lock() bumps only the epoch; the export CAS bumps the revision. + expect(after.revision).toBe(before.revision + 1); + expect(after.lockEpoch).toBe(before.lockEpoch + 1); + // The pre-export lock cleared the session (no zombie left behind by the + // export's revision bump). + await expectRejectedBy(manager.getAccounts(), 'VAULT_LOCKED'); + await expectRejectedBy(manager.exportVault('ghost'), 'VAULT_NOT_FOUND'); + }, 30000); + }); +}); diff --git a/tests/unit/vault-signer.test.ts b/tests/unit/vault-signer.test.ts new file mode 100644 index 00000000..eaaf257b --- /dev/null +++ b/tests/unit/vault-signer.test.ts @@ -0,0 +1,755 @@ +// Vault signer tests (A4). Storage runs on the hand-written fake IndexedDB +// (tests/mocks/fake-indexeddb.ts — see its FIDELITY BOUNDARY header); the +// unlocked vault is the fixed fixture envelope, so one unlock per test pays +// one PBKDF2 derivation (~0.5-0.8s). Whole-file budget: 20 derivations +// (beforeAll 1 for the memo-vault envelope + 19 in-test unlock executions: +// the authority-precheck it.each unlocks 4x, the frozen-transaction +// beforeEach unlocks once per its 4 tests, eleven single unlocks elsewhere; +// the unsupported-operation and legacy privateKey-signer blocks derive +// nothing). Per-test timeouts accordingly. +// +// MOCK DISCIPLINE (two layers, per docs/AI-driver/08-testing.md and the A1.1 +// ruling): +// - '@/lib/steem/accounts-client' is vi.mock'ed: the authority precheck +// branches (degraded / missing account / malformed authority / fetch +// rejection) are contract tests of the signer against the response shape +// the real route produces (`{ success, accounts, degraded? }` — shape +// derived from src/app/api/query/accounts/route.ts, not invented). +// - '@steemit/steem-js' is the global mock: `signTransaction` is a vi.fn +// returning a canned payload — assertions on it prove the CALL CONTRACT +// (which transaction object and which WIF reached the signer), never +// signature authenticity. The transaction id, however, is computed via +// the REAL proxied `serializeTransaction` + WebCrypto SHA-256, and the +// frozen-transaction test pins the exact fixture vector +// `07c1af89bc7bfbbe2b9261e0b6cb3bf231b98c4d` end-to-end. +// - The transaction header is served by a stubbed global fetch (the +// established pattern from tests/unit/steem-signer-real-ops.test.ts). +// +// WIF→public-key derivation runs through the shared validateVaultWif entry +// point (mock getPublicKey: 'STM' + last 8 chars of the WIF) — branch +// coverage only; real crypto evidence lives in tests/node/vault-wif-real-check.mjs. +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { steem } from '@steemit/steem-js'; +import vectors from '../fixtures/vault-development-test-vectors.json'; +import { installFakeIndexedDB, type FakeIndexedDBHandle } from '../mocks/fake-indexeddb'; +import { + deriveVaultKek, + encryptVaultText, + generateVaultIv, +} from '@/lib/vault/crypto'; +import { decodeBase64Url, encodeBase64Url, validateVaultPayloadText } from '@/lib/vault/envelope'; +import { createVaultManager, type VaultManagerExtended } from '@/lib/vault/manager'; +import { createLegacyPrivateKeySigner } from '@/lib/auth/legacy-private-key-signer'; +import { VAULT_DB_NAME, openVaultRegistry, type VaultRegistry } from '@/lib/vault/registry'; +import { resolveRequiredAuthorityClass } from '@/lib/vault/signer'; +import type { VaultKeyEntry, VaultRecordV1 } from '@/lib/vault/types'; +import type { SteemAccount } from '@/lib/steem/types'; + +// Established accounts-client mock pattern (see tests/unit/transfer-form-amount.test.tsx): +// the factory only closes over the fn — the reference resolves at call time. +const mockFetchAccounts = vi.fn(); +vi.mock('@/lib/steem/accounts-client', () => ({ + fetchAccounts: (...args: unknown[]) => mockFetchAccounts(...(args as [string[], { fresh?: boolean }])), +})); + +const v = vectors.vault; + +const fixtureKeys = validateVaultPayloadText(v.payloadText).keys; +const aliceEntry: VaultKeyEntry | undefined = fixtureKeys[0]; +if (!aliceEntry) { + throw new Error('fixture payload has no key entry'); +} +const aliceWif = aliceEntry.privateKey; +// The vitest mock derives 'STM' + the last 8 characters of the WIF. +const alicePublicKey = `STM${aliceWif.slice(-8)}`; +const ALICE_MEMO_WIF = '5Ktest-wif-alice-memo'; +const aliceMemoPublicKey = `STM${ALICE_MEMO_WIF.slice(-8)}`; +const OTHER_POSTING_KEY = 'STMotherPostingKey0000'; + +// One header response for every signing case (shape copied from +// /api/query/transaction-header — same source as the real-ops guard test). +const TRANSACTION_HEADER = { + success: true, + ref_block_num: 99, + ref_block_prefix: 3704360964, + expiration: '2030-01-01T12:00:00', +}; + +let fakeClock = 1_700_200_000_000; + +function makeRecord( + vaultName: string, + envelope: { salt: string; iv: string; ciphertext: string }, +): VaultRecordV1 { + return { + version: 1, + vaultName, + salt: envelope.salt, + iv: envelope.iv, + ciphertext: envelope.ciphertext, + revision: 0, + lockEpoch: 0, + createdAt: fakeClock++, + updatedAt: fakeClock++, + lastBackupAt: null, + }; +} + +/** Authority block as condenser_api returns it (fc::flat_map tuple JSON). */ +function authority(keyAuths: Array<[string, number]>, weightThreshold = 1) { + return { + weight_threshold: weightThreshold, + account_auths: [] as [string, number][], + key_auths: keyAuths, + }; +} + +/** A full SteemAccount row with the authority layout each case needs. */ +function makeAccount(name: string, authorities: { + owner?: ReturnType; + active?: ReturnType; + posting?: ReturnType; +}): SteemAccount { + const owner = authorities.owner ?? authority([[`STMowner-${name}`, 1]]); + const active = authorities.active ?? authority([[`STMactive-${name}`, 1]]); + const posting = authorities.posting ?? authority([[`STMposting-${name}`, 1]]); + return { + id: 1, + name, + owner, + active, + posting, + memo_key: `STMmemo-${name}`, + json_metadata: '', + balance: '0.000 STEEM', + sbd_balance: '0.000 SBD', + savings_balance: '0.000 STEEM', + savings_sbd_balance: '0.000 SBD', + vesting_shares: '0.000000 VESTS', + delegated_vesting_shares: '0.000000 VESTS', + received_vesting_shares: '0.000000 VESTS', + vesting_withdraw_rate: '0.000000 VESTS', + next_vesting_withdrawal: '1970-01-01T00:00:00', + withdrawn: 0, + to_withdraw: 0, + withdraw_routes: 0, + created: '1970-01-01T00:00:00', + last_owner_update: '1970-01-01T00:00:00', + last_account_update: '1970-01-01T00:00:00', + last_vote_time: '1970-01-01T00:00:00', + post_count: 0, + can_vote: true, + voting_power: 0, + last_post: '1970-01-01T00:00:00', + last_root_post: '1970-01-01T00:00:00', + vesting_balance: '0.000 STEEM', + reward_steem_balance: '0.000 STEEM', + reward_sbd_balance: '0.000 SBD', + reward_vesting_steem: '0.000 STEEM', + reward_vesting_balance: '0.000000 VESTS', + reputation: 0, + witness_votes: [], + } as SteemAccount; +} + +/** Standard happy-path on-chain view: alice's vault key IS the posting key. */ +function accountWithPostingVaultKey(): SteemAccount { + return makeAccount('alice', { posting: authority([[alicePublicKey, 1]]) }); +} + +function accountsResponse(accounts: SteemAccount[]) { + return { success: true, accounts }; +} + +/** The posting-class and active-class operations used across the cases. */ +const postingOp = (account: string): [string, Record] => [ + 'custom_json', + { required_posting_auths: [account], id: 'community', json: '[]' }, +]; +const activeOp = (account: string): [string, Record] => [ + 'transfer', + { from: account, to: 'bob', amount: '1.000 STEEM', memo: 'demo' }, +]; + +describe('vault signer (A4)', () => { + let fake: FakeIndexedDBHandle; + let registry: VaultRegistry; + let manager: VaultManagerExtended; + // Envelope of a vault whose payload ALSO carries a memo key for alice — + // sealed once in beforeAll under the fixture password. + let memoVaultEnvelope: { salt: string; iv: string; ciphertext: string }; + let signCalls: () => number; + + beforeAll(async () => { + const kek = await deriveVaultKek(v.password, decodeBase64Url(v.salt)); + const iv = generateVaultIv(); + const payloadText = JSON.stringify({ + keys: [ + { accountName: 'alice', role: 'posting', privateKey: aliceWif }, + { accountName: 'alice', role: 'memo', privateKey: ALICE_MEMO_WIF }, + ], + }); + memoVaultEnvelope = { + salt: v.salt, + iv: encodeBase64Url(iv), + ciphertext: encodeBase64Url(await encryptVaultText(kek, iv, payloadText)), + }; + }, 30000); + + beforeEach(async () => { + fake = installFakeIndexedDB(); + registry = await openVaultRegistry(); + fake.seed(VAULT_DB_NAME, 'vaults', makeRecord('demo', { salt: v.salt, iv: v.iv, ciphertext: v.ciphertext })); + fake.seed(VAULT_DB_NAME, 'vaults', makeRecord('memo-vault', memoVaultEnvelope)); + manager = createVaultManager(registry); + mockFetchAccounts.mockReset(); + vi.mocked(steem.auth.signTransaction).mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + headers: new Headers(), + json: async () => TRANSACTION_HEADER, + })), + ); + signCalls = () => vi.mocked(steem.auth.signTransaction).mock.calls.length; + }); + + afterEach(() => { + manager.destroy(); + registry.close(); + vi.unstubAllGlobals(); + fake.uninstall(); + fake.reset(); + }); + + describe('operation classifier (shared table)', () => { + it('maps every table entry and resolves the two payload-dependent ops', () => { + expect(resolveRequiredAuthorityClass([activeOp('alice')])).toBe('active'); + expect(resolveRequiredAuthorityClass([postingOp('alice')])).toBe('posting'); + expect( + resolveRequiredAuthorityClass([['claim_reward_balance', { account: 'alice' }]]), + ).toBe('posting'); + expect( + resolveRequiredAuthorityClass([['change_recovery_account', {}]]), + ).toBe('owner'); + // account_update: owner only when the payload carries an owner authority. + expect( + resolveRequiredAuthorityClass([['account_update', { account: 'alice' }]]), + ).toBe('active'); + expect( + resolveRequiredAuthorityClass([ + ['account_update', { account: 'alice', owner: authority([[alicePublicKey, 1]]) }], + ]), + ).toBe('owner'); + // custom_json: active when required_auths is non-empty. + expect( + resolveRequiredAuthorityClass([ + ['custom_json', { required_auths: ['alice'], id: 'x', json: '{}' }], + ]), + ).toBe('active'); + }); + + it('mixed operations require the most privileged class; garbage fails closed', () => { + expect(resolveRequiredAuthorityClass([postingOp('alice'), activeOp('alice')])).toBe('active'); + const expectClassifierRejection = (operations: unknown[]): void => { + try { + resolveRequiredAuthorityClass(operations); + throw new Error('expected the classifier to reject'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as { code?: unknown }).code).toBe('VAULT_OPERATION_UNSUPPORTED'); + } + }; + expectClassifierRejection([]); + expectClassifierRejection([['vote', {}]]); + expectClassifierRejection([['recover_account', {}]]); + expectClassifierRejection([['transfer', 'not-an-object']]); + expectClassifierRejection([['custom_json', { id: 'x', json: '{}' }]]); + }); + }); + + describe('authority precheck (traffic contract)', () => { + it('happy path: one sorted batched fresh query; signs with the vault key; no leaks', async () => { + mockFetchAccounts.mockResolvedValue(accountsResponse([accountWithPostingVaultKey()])); + await manager.unlockVault('demo', v.password); + const result = await manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }); + // Exactly ONE batched query, sorted, L1-bypassing (server cache intact). + expect(mockFetchAccounts).toHaveBeenCalledTimes(1); + expect(mockFetchAccounts).toHaveBeenCalledWith(['alice'], { fresh: true }); + // The signing call received the header fields and exactly the vault WIF. + expect(signCalls()).toBe(1); + const call = vi.mocked(steem.auth.signTransaction).mock.calls[0]; + expect(call).toBeDefined(); + const [tx, keys] = call as [Record, string[]]; + expect(tx['ref_block_num']).toBe(TRANSACTION_HEADER.ref_block_num); + expect(tx['ref_block_prefix']).toBe(TRANSACTION_HEADER.ref_block_prefix); + expect(tx['expiration']).toBe(TRANSACTION_HEADER.expiration); + expect(tx['operations']).toEqual([postingOp('alice')]); + expect(keys).toEqual([aliceWif]); + // Public result shape; the session stays unlocked. + expect(result.accountName).toBe('alice'); + expect(result.role).toBe('posting'); + expect(result.publicKey).toBe(alicePublicKey); + expect(result.locked).toBe(false); + expect(result.transactionId).toMatch(/^[0-9a-f]{40}$/); + // Hard leak assertions: no WIF, KEK fragment, payload text or sensitive + // field names anywhere in the exported result. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain(aliceWif); + expect(serialized).not.toContain(v.kekHex.slice(0, 32)); + expect(serialized).not.toContain(v.payloadText.slice(0, 24)); + const keyNames: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk); + else if (value !== null && typeof value === 'object') { + for (const [k, inner] of Object.entries(value)) { + keyNames.push(k); + walk(inner); + } + } + }; + walk(result); + expect(keyNames).not.toContain('privateKey'); + expect(keyNames).not.toContain('kek'); + expect(keyNames).not.toContain('payload'); + await expect(manager.getAccounts()).resolves.toBeTruthy(); // still unlocked + }, 30000); + + it.each([ + ['body flagged degraded', { success: true, accounts: [], degraded: true }], + ['success flag missing', { accounts: [] }], + ['accounts array missing', { success: true }], + ['involved account missing from the list', accountsResponse([])], + ])('rejects with VAULT_AUTHORITY_UNAVAILABLE when %s — and never signs', async (_name, response) => { + mockFetchAccounts.mockResolvedValue(response); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('rejects when authority data is malformed (missing key_auths / threshold)', async () => { + const broken = accountWithPostingVaultKey(); + broken.posting = { weight_threshold: 1, account_auths: [] } as unknown as SteemAccount['posting']; + mockFetchAccounts.mockResolvedValue(accountsResponse([broken])); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('rejects when the accounts fetch itself fails (RPC unavailable)', async () => { + mockFetchAccounts.mockRejectedValue(new Error('network down')); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('requires an unlocked session before anything else (VAULT_LOCKED, no query, no sign)', async () => { + mockFetchAccounts.mockResolvedValue(accountsResponse([accountWithPostingVaultKey()])); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_LOCKED' }); + expect(mockFetchAccounts).not.toHaveBeenCalled(); + expect(signCalls()).toBe(0); + }); + }); + + describe('key selection over the full on-chain authority', () => { + it('finds the vault key beyond key_auths[0]', async () => { + const account = makeAccount('alice', { + posting: authority([[OTHER_POSTING_KEY, 1], [alicePublicKey, 1]]), + }); + mockFetchAccounts.mockResolvedValue(accountsResponse([account])); + await manager.unlockVault('demo', v.password); + const result = await manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }); + expect(result.publicKey).toBe(alicePublicKey); + expect(result.role).toBe('posting'); + expect(signCalls()).toBe(1); + }, 30000); + + it('rejects when no single key meets weight_threshold', async () => { + const account = makeAccount('alice', { + posting: authority([[alicePublicKey, 1]], 2), // alone: 1 < 2 + }); + mockFetchAccounts.mockResolvedValue(accountsResponse([account])); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('posting-class op signs with the ACTIVE key via downward coverage (role reported truthfully)', async () => { + // The vault key sits in the account's ACTIVE authority only. + const account = makeAccount('alice', { active: authority([[alicePublicKey, 1]]) }); + mockFetchAccounts.mockResolvedValue(accountsResponse([account])); + await manager.unlockVault('demo', v.password); + const result = await manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }); + expect(result.role).toBe('active'); + expect(result.publicKey).toBe(alicePublicKey); + expect(signCalls()).toBe(1); + }, 30000); + + it('owner key covers active- and posting-class ops', async () => { + const account = makeAccount('alice', { owner: authority([[alicePublicKey, 1]]) }); + mockFetchAccounts.mockResolvedValue(accountsResponse([account])); + await manager.unlockVault('demo', v.password); + const signer = manager.createSigner(); + const active = await signer.signOperations({ + accountName: 'alice', + role: 'active', + operations: [activeOp('alice')], + }); + expect(active.role).toBe('owner'); + const posting = await signer.signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }); + expect(posting.role).toBe('owner'); + expect(signCalls()).toBe(2); + }, 30000); + + it('the requested role is a floor: no downgrade below it even when a lower key exists', async () => { + // Vault key IS the posting key, but the request asks for active on a + // posting-class op — the walk starts at active and must not fall back + // down to the posting key. + mockFetchAccounts.mockResolvedValue(accountsResponse([accountWithPostingVaultKey()])); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'active', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('memo keys are stored but never selected for signing', async () => { + // The memo-vault holds alice's posting AND memo keys; the on-chain + // posting authority contains ONLY the memo key's public key. A signer + // that forgot the memo exclusion would happily sign with it. + const account = makeAccount('alice', { + posting: authority([[aliceMemoPublicKey, 1]]), + }); + mockFetchAccounts.mockResolvedValue(accountsResponse([account])); + await manager.unlockVault('memo-vault', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }, 30000); + + it('rejects a memo signing role outright', async () => { + mockFetchAccounts.mockResolvedValue(accountsResponse([accountWithPostingVaultKey()])); + await manager.unlockVault('demo', v.password); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'memo' as 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_KEY_INVALID' }); + expect(signCalls()).toBe(0); + }, 30000); + }); + + describe('unsupported operations and garbage input', () => { + it('unknown operation types fail closed before any query or signing', async () => { + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [['piquant_novel_op', {}]], + }), + ).rejects.toMatchObject({ code: 'VAULT_OPERATION_UNSUPPORTED' }); + expect(mockFetchAccounts).not.toHaveBeenCalled(); + expect(signCalls()).toBe(0); + }); + + it('recover_account is deliberately unsupported: VAULT_OPERATION_UNSUPPORTED, zero signing', async () => { + // Chain semantics: recover_account requires the old (recent) owner AND + // the new owner to BOTH sign (client.ts signRecoverAccount; the + // 2026-10-08 production incident — a single owner signature is + // rejected with tx_missing_other_auth). The vault signer's + // single-key model cannot express dual-key signing, so the op is + // deliberately absent from the table and fails closed here; recovery + // flows keep their dedicated dual-key path. + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'owner', + operations: [ + [ + 'recover_account', + { + account_to_recover: 'alice', + new_owner_authority: { weight_threshold: 1, account_auths: [], key_auths: [] }, + recent_owner_authority: { weight_threshold: 1, account_auths: [], key_auths: [] }, + }, + ], + ], + }), + ).rejects.toMatchObject({ code: 'VAULT_OPERATION_UNSUPPORTED' }); + expect(mockFetchAccounts).not.toHaveBeenCalled(); + expect(signCalls()).toBe(0); + }); + + it('empty operation lists and malformed account names fail closed', async () => { + await expect( + manager.createSigner().signOperations({ accountName: 'alice', role: 'posting', operations: [] }), + ).rejects.toMatchObject({ code: 'VAULT_OPERATION_UNSUPPORTED' }); + await expect( + manager.createSigner().signOperations({ + accountName: 'not a valid name!', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_KEY_INVALID' }); + expect(mockFetchAccounts).not.toHaveBeenCalled(); + expect(signCalls()).toBe(0); + }); + }); + + describe('frozen transactions (signResolvedTransaction)', () => { + const frozenTx = { + ref_block_num: 12345, + ref_block_prefix: 1234567890, + expiration: '2026-10-31T00:00:00', + operations: [activeOp('alice')] as [string, Record][], + extensions: [], + }; + + beforeEach(async () => { + mockFetchAccounts.mockResolvedValue( + accountsResponse([makeAccount('alice', { active: authority([[alicePublicKey, 1]]) })]), + ); + await manager.unlockVault('demo', v.password); + }, 30000); + + it('passes the frozen fields verbatim into the signing call and never fetches a header', async () => { + const fetchSpy = vi.mocked(fetch); + const result = await manager.createSigner().signResolvedTransaction({ + accountName: 'alice', + role: 'active', + transaction: frozenTx, + }); + // No header refresh: the global fetch was never touched. + expect(fetchSpy).not.toHaveBeenCalled(); + expect(signCalls()).toBe(1); + const call = vi.mocked(steem.auth.signTransaction).mock.calls[0]; + expect(call).toBeDefined(); + const [tx, keys] = call as [Record, string[]]; + expect(tx['ref_block_num']).toBe(12345); + expect(tx['ref_block_prefix']).toBe(1234567890); + expect(tx['expiration']).toBe('2026-10-31T00:00:00'); + expect(tx['operations']).toEqual(frozenTx.operations); + expect(keys).toEqual([aliceWif]); + expect(result.role).toBe('active'); + }); + + it('pins the exact fixture transaction id (real serializer + real SHA-256)', async () => { + // vectors.exactTransaction: the fixed transfer with these frozen fields + // hashes (SHA-256 of the unsigned serialization, first 20 bytes) to + // 07c1af89bc7bfbbe2b9261e0b6cb3bf231b98c4d. The mock signTransaction + // proves the call; THIS asserts the id computation is byte-real. + const exact = { + ...frozenTx, + operations: vectors.exactTransaction.unsignedTx.operations as [string, Record][], + }; + const result = await manager.createSigner().signResolvedTransaction({ + accountName: 'alice', + role: 'active', + transaction: exact, + }); + expect(result.transactionId).toBe(vectors.exactTransaction.transactionId); + }); + + it('still runs the authority precheck and rejects on degraded data', async () => { + mockFetchAccounts.mockResolvedValue({ success: true, accounts: [], degraded: true }); + await expect( + manager.createSigner().signResolvedTransaction({ + accountName: 'alice', + role: 'active', + transaction: frozenTx, + }), + ).rejects.toMatchObject({ code: 'VAULT_AUTHORITY_UNAVAILABLE' }); + expect(signCalls()).toBe(0); + }); + + it('rejects malformed frozen transactions (VAULT_OPERATION_UNSUPPORTED)', async () => { + await expect( + manager.createSigner().signResolvedTransaction({ + accountName: 'alice', + role: 'active', + transaction: { ref_block_num: 1 } as unknown as typeof frozenTx, + }), + ).rejects.toMatchObject({ code: 'VAULT_OPERATION_UNSUPPORTED' }); + expect(signCalls()).toBe(0); + }); + }); + + describe('mid-flight session drift', () => { + it('refuses to sign when the vault is locked while the header fetch is in flight', async () => { + mockFetchAccounts.mockResolvedValue(accountsResponse([accountWithPostingVaultKey()])); + await manager.unlockVault('demo', v.password); + // The header fetch doubles as the drift injection point: another actor + // locks the vault (epoch bump) after the payload was captured but + // before the WIF is used. + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + await registry.bumpLockEpoch('demo'); + return { ok: true, headers: new Headers(), json: async () => TRANSACTION_HEADER }; + }), + ); + await expect( + manager.createSigner().signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_SESSION_STALE' }); + expect(signCalls()).toBe(0); + }, 30000); + }); + + describe('legacy privateKey signer (A4 — no vault, no authority precheck)', () => { + // The legacy signer sources its WIF from an injected getter (the A6 + // login-state wiring point). DELIBERATE DIFFERENCE under test: it never + // queries /api/query/accounts — privateKey mode must keep behaving like + // today's src/lib/steem/client.ts signing path. + + it('signs with the injected key and the fetched header; result stays key-free', async () => { + const getPrivateKey = vi.fn(() => aliceWif); + const signer = createLegacyPrivateKeySigner(getPrivateKey); + const result = await signer.signOperations({ + accountName: 'alice', + role: 'posting', + operations: [postingOp('alice')], + }); + // No authority precheck: the accounts client is never touched. + expect(mockFetchAccounts).not.toHaveBeenCalled(); + expect(getPrivateKey).toHaveBeenCalledWith('alice', 'posting'); + expect(signCalls()).toBe(1); + const call = vi.mocked(steem.auth.signTransaction).mock.calls[0]; + expect(call).toBeDefined(); + const [tx, keys] = call as [Record, string[]]; + expect(tx['ref_block_num']).toBe(TRANSACTION_HEADER.ref_block_num); + expect(tx['expiration']).toBe(TRANSACTION_HEADER.expiration); + expect(keys).toEqual([aliceWif]); + // The requested role is the role used; no vault session exists. + expect(result.role).toBe('posting'); + expect(result.publicKey).toBe(alicePublicKey); + expect(result.locked).toBe(false); + expect(result.transactionId).toMatch(/^[0-9a-f]{40}$/); + expect(JSON.stringify(result)).not.toContain(aliceWif); + }); + + it('maps a missing key to VAULT_KEY_INVALID (re-auth territory for A6)', async () => { + const signer = createLegacyPrivateKeySigner(() => undefined); + await expect( + signer.signOperations({ + accountName: 'alice', + role: 'active', + operations: [activeOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_KEY_INVALID' }); + expect(signCalls()).toBe(0); + }); + + it('fails closed on unsupported operations and memo roles before resolving any key', async () => { + const getPrivateKey = vi.fn(() => aliceWif); + const signer = createLegacyPrivateKeySigner(getPrivateKey); + await expect( + signer.signOperations({ + accountName: 'alice', + role: 'posting', + operations: [['brand_new_op', {}]], + }), + ).rejects.toMatchObject({ code: 'VAULT_OPERATION_UNSUPPORTED' }); + await expect( + signer.signOperations({ + accountName: 'alice', + role: 'memo' as 'posting', + operations: [postingOp('alice')], + }), + ).rejects.toMatchObject({ code: 'VAULT_KEY_INVALID' }); + expect(getPrivateKey).not.toHaveBeenCalled(); + expect(signCalls()).toBe(0); + }); + + it('signs frozen transactions verbatim (no header fetch) and pins the fixture txid', async () => { + const signer = createLegacyPrivateKeySigner(() => aliceWif); + const exact = { + ref_block_num: 12345, + ref_block_prefix: 1234567890, + expiration: '2026-10-31T00:00:00', + operations: vectors.exactTransaction.unsignedTx.operations as [string, Record][], + extensions: [], + }; + const fetchSpy = vi.mocked(fetch); + const result = await signer.signResolvedTransaction({ + accountName: 'alice', + role: 'active', + transaction: exact, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(signCalls()).toBe(1); + const call = vi.mocked(steem.auth.signTransaction).mock.calls[0]; + expect(call).toBeDefined(); + const [tx, keys] = call as [Record, string[]]; + expect(tx).toMatchObject({ + ref_block_num: 12345, + ref_block_prefix: 1234567890, + expiration: '2026-10-31T00:00:00', + }); + expect(keys).toEqual([aliceWif]); + expect(result.transactionId).toBe(vectors.exactTransaction.transactionId); + expect(result.role).toBe('active'); + }); + }); +});