diff --git a/src/lib/password-vault.js b/src/lib/password-vault.js index 54b050f..ae2ae0d 100644 --- a/src/lib/password-vault.js +++ b/src/lib/password-vault.js @@ -218,3 +218,104 @@ export function listMetadata(state) { kind: e.literal ? "literal" : "generated", })); } + +// -- wallet imports ---------------------------------------------------------- +// Externally-generated wallet material (imported seeds + BIP44 paths, or raw +// WIFs) lives in its OWN encrypted file, `wallet-imports.enc`, distinct from +// the primary passwords vault. See DESIGN-wallet-multi-account-amendment.md. +// +// Same PBKDF2/AES-GCM envelope as the passwords vault, same master password, +// deliberately DIFFERENT KDF salt so the two AES keys are cryptographically +// disjoint โ€” a leak of one key never yields the other. Blast radius: a bug +// that corrupts wallet-imports.enc cannot destroy the primary seed and vice +// versa. +// +// Derivation rule (design ยง3): imports are used DIRECTLY โ€” +// `HDKey.fromMasterSeed(seed).derive(path)` OR the raw WIF's private key. +// No HKDF steps. The `path` field is the disambiguator: when present, honour +// it verbatim so the same seed + path produces the same address across +// Electron Cash, mainnet-js, Deviant's keystore, and Theseus. + +const IMPORTS_VERSION = "silentmode/wallet-imports/1"; + +function writeImportsFile(path, { salt, iv, ct, iters }) { + const raw = { + version: VAULT_VERSION, + kdf: { name: "PBKDF2", iters, salt: bytesToHex(salt) }, + iv: bytesToHex(iv), + ciphertext: bytesToHex(ct), + }; + writeFileSync(path + ".tmp", JSON.stringify(raw), "utf8"); + renameSync(path + ".tmp", path); +} + +export function importsExist(path) { return existsSync(path); } + +// Create an empty imports file. Called the first time the user adds an +// external wallet. Uses a fresh salt so its AES key is unrelated to the +// passwords vault's. +export async function createImports(path, masterPw) { + const salt = wc.getRandomValues(new Uint8Array(16)); + const iv = wc.getRandomValues(new Uint8Array(12)); + const key = await deriveVaultKey(masterPw, salt, PBKDF2_ITERS); + const plaintext = { version: IMPORTS_VERSION, generated: dateStamp(), accounts: {} }; + const ct = new Uint8Array(await wc.subtle.encrypt( + { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(plaintext)))); + writeImportsFile(path, { salt, iv, ct, iters: PBKDF2_ITERS }); + return { key, accounts: {}, _salt: salt, _iters: PBKDF2_ITERS }; +} + +// Unlock the imports file with the same master password used for the primary +// vault. Salt is the imports file's own โ€” the key differs from vaultState.key. +export async function unlockImports(path, masterPw) { + const raw = JSON.parse(readFileSync(path, "utf8")); + if (raw.version !== VAULT_VERSION) throw new Error(`imports version ${raw.version} not supported`); + const salt = hexToBytes(raw.kdf.salt); + const iv = hexToBytes(raw.iv); + const ct = hexToBytes(raw.ciphertext); + const key = await deriveVaultKey(masterPw, salt, raw.kdf.iters); + let pt; + try { pt = new Uint8Array(await wc.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)); } + catch { throw new Error("bad master password"); } + const plaintext = JSON.parse(dec.decode(pt)); + return { + key, + accounts: plaintext.accounts || {}, + _salt: salt, _iters: raw.kdf.iters, + }; +} + +export async function saveImports(path, state) { + const iv = wc.getRandomValues(new Uint8Array(12)); + const plaintext = { version: IMPORTS_VERSION, generated: dateStamp(), accounts: state.accounts || {} }; + const ct = new Uint8Array(await wc.subtle.encrypt( + { name: "AES-GCM", iv }, state.key, enc.encode(JSON.stringify(plaintext)))); + writeImportsFile(path, { salt: state._salt, iv, ct, iters: state._iters }); +} + +// Public metadata for the imports UI. Never returns seeds/WIFs. +export function listImportsMetadata(state) { + return Object.entries(state.accounts || {}).map(([id, a]) => ({ + id, + kind: a.kind, + cashaddr: a.cashaddr, + label: a.label, + category: a.category || "", + source: a.source || "", + path: a.path || "", + createdAt: a.createdAt || 0, + })); +} + +// Signer material for one imported wallet. Callers hold this in RAM for the +// duration of a single signing operation and drop it. Only ever exposed to +// add-ons that already have vault-derive-equivalent trust (i.e. the wallet +// add-on that consumes imports). +export function getImportSigner(state, id) { + const a = (state.accounts || {})[id]; + if (!a) throw new Error("no such import"); + if (a.kind === "seed") return { kind: "seed", seed: a.seed, path: a.path }; + if (a.kind === "wif") return { kind: "wif", wif: a.wif }; + throw new Error(`unknown import kind: ${a.kind}`); +} +