Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 66 additions & 15 deletions docs/AI-driver/09-vault.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions src/lib/auth/legacy-private-key-signer.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, unknown>,
);
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<SignResult>;
signResolvedTransaction(request: SignResolvedTransactionRequest): Promise<SignResult>;
} {
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<SignResult> {
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<string, unknown>,
) 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<SignResult> {
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,
};
},
};
}
Loading
Loading