import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as nativeSign, verify as nativeVerify, webcrypto } from 'node:crypto'; import { encryptWallet, decryptWallet, type WalletBackup } from '../shared/wallet-backup.ts'; import { getWallet, exportWallet, restoreWallet, protectWallet, unlockWallet, walletStatus, lockWallet, sign, touchWallet, WALLET_IDLE_MS } from '../src/wallet.ts'; const subtle = webcrypto.subtle as SubtleCrypto; // Ephemeral test credentials only; no production identities or secrets. const password = randomBytes(24).toString('base64'); const encode = (bytes: ArrayBuffer) => Buffer.from(bytes).toString('base64'); async function generate() { const keys = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); return { ...keys, publicKeyText: encode(await subtle.exportKey('spki', keys.publicKey)) }; } const fixture = (async () => { const keys = await generate(); return { ...keys, backup: await encryptWallet(keys.privateKey, keys.publicKeyText, password) }; })(); function mutateBase64(value: string): string { const bytes = Buffer.from(value, 'base64'); bytes[0] ^= 1; return bytes.toString('base64'); } // Independent format writer permits testing authenticated but invalid plaintext: // this exercises key validation *after* successful GCM authentication. async function seal(pkcs8: ArrayBuffer, header: WalletBackup): Promise { const material = await subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); const key = await subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', iterations: 600000, salt: Buffer.from(header.salt, 'base64') }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); const aad = new TextEncoder().encode(JSON.stringify([header.format, header.version, header.network, header.kdf, header.iterations, header.cipher, header.salt, header.iv, header.publicKey])); return { ...header, ciphertext: encode(await subtle.encrypt({ name: 'AES-GCM', tagLength: 128, iv: Buffer.from(header.iv, 'base64'), additionalData: aad }, key, pkcs8)) }; } test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing key', async () => { const { backup, privateKey, publicKeyText } = await fixture; const wallet = await decryptWallet(JSON.parse(JSON.stringify(backup)), password); const spki = Buffer.from(publicKeyText, 'base64'); assert.equal(wallet.address, createHash('sha256').update(spki).digest('hex')); assert.match(wallet.address, /^[a-f0-9]{64}$/); assert.equal(wallet.publicKey, publicKeyText); assert.equal(wallet.privateKey.extractable, true); assert.equal(wallet.privateKey.type, 'private'); assert.equal(wallet.privateKey.algorithm.name, 'ECDSA'); assert.equal((wallet.privateKey.algorithm as EcKeyAlgorithm).namedCurve, 'P-256'); assert.deepEqual(Buffer.from(await subtle.exportKey('pkcs8', wallet.privateKey)), Buffer.from(await subtle.exportKey('pkcs8', privateKey))); const signature = encode(await subtle.sign({name:'ECDSA',hash:'SHA-256'},wallet.privateKey,new TextEncoder().encode('portable identity'))); assert.equal(nativeVerify('sha256', Buffer.from('portable identity'), { key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363', }, Buffer.from(signature, 'base64')), true); assert.equal(backup.iterations, 600000); assert.equal(backup.network, 'wikideck-randomx-testnet-6'); assert.equal(Buffer.from(backup.salt, 'base64').length, 16); assert.equal(Buffer.from(backup.iv, 'base64').length, 12); assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024); const again = await encryptWallet(wallet.privateKey,wallet.publicKey,password); assert.notEqual(again.salt, backup.salt); assert.notEqual(again.iv, backup.iv); assert.notEqual(again.ciphertext, backup.ciphertext); }); test('native Node miner PKCS8 imports into WebCrypto without changing address or signatures', async () => { const native = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); const pkcs8 = native.privateKey.export({ format: 'der', type: 'pkcs8' }); const publicKey = native.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']); const restored = await decryptWallet(await encryptWallet(privateKey, publicKey, password), password); assert.equal(restored.address, createHash('sha256').update(Buffer.from(publicKey, 'base64')).digest('hex')); const message = Buffer.from('native miner identity'); const nativeSignature = nativeSign('sha256', message, { key: native.privateKey, dsaEncoding: 'ieee-p1363' }); const verifyingKey = await subtle.importKey('spki', Buffer.from(restored.publicKey, 'base64'), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); assert.equal(await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyingKey, nativeSignature, message), true); assert.equal(createPrivateKey({ key: Buffer.from(await subtle.exportKey('pkcs8', restored.privateKey)), format: 'der', type: 'pkcs8' }).asymmetricKeyType, 'ec'); }); test('wrong password and modifications of ciphertext, salt, IV, or public key fail', async () => { const { backup } = await fixture; await assert.rejects(decryptWallet(backup, randomBytes(24).toString('base64')), /Cannot unlock/); for (const field of ['ciphertext', 'salt', 'iv'] as const) { await assert.rejects(decryptWallet({ ...backup, [field]: mutateBase64(backup[field]) }, password), /Cannot unlock/); } const other = await generate(); await assert.rejects(decryptWallet({ ...backup, publicKey: other.publicKeyText }, password), /Cannot unlock/); }); test('mismatched key pairs fail on export and after authenticated decryption', async () => { const { backup, privateKey } = await fixture; const other = await generate(); await assert.rejects(encryptWallet(privateKey, other.publicKeyText, password), /does not match/); const mismatched = await seal(await subtle.exportKey('pkcs8', privateKey), { ...backup, publicKey: other.publicKeyText }); await assert.rejects(decryptWallet(mismatched, password), /mismatched wallet keys/); }); test('wrong key types, non-exportable keys, wrong curves, and invalid PKCS8 are rejected', async () => { const { backup, publicKey, publicKeyText } = await fixture; const nonexportable = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']); const ecdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']); const p384 = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']); for (const key of [publicKey, nonexportable.privateKey, ecdh.privateKey, p384.privateKey]) { await assert.rejects(encryptWallet(key, publicKeyText, password), /exportable P-256 ECDSA/); } const p384Backup = await seal(await subtle.exportKey('pkcs8', p384.privateKey), backup); await assert.rejects(decryptWallet(p384Backup, password), /Cannot unlock/); await assert.rejects(decryptWallet(await seal(new Uint8Array(32).buffer, backup), password), /Cannot unlock/); }); test('strict format, bounded fields, canonical base64, and fixed KDF work reject malformed input', async () => { const { backup } = await fixture; for (const iterations of [0, -1, 1, 599999, 600001, 1e12, Infinity, NaN, '600000', null]) { await assert.rejects(decryptWallet({ ...backup, iterations }, password), /Unsupported/); } const invalid: unknown[] = [null, undefined, [], 'x'.repeat(32769), {}, { ...backup, extra: 'unexpected' }, { ...backup, version: 2 }, { ...backup, network: 'wikideck-demo' }, { ...backup, format: 'other' }, { ...backup, kdf: 'scrypt' }, { ...backup, cipher: 'AES-CBC' }, { ...backup, salt: 'AA==' }, { ...backup, iv: null }, { ...backup, publicKey: 'a'.repeat(32769) }, { ...backup, ciphertext: 'a'.repeat(32769) }, { ...backup, ciphertext: '***=' }, { ...backup, salt: backup.salt.replace(/==$/, 'AB') }, { ...backup, salt: `${backup.salt}\n` }, Object.assign(Object.create({ inherited: true }), backup), ]; const missing = { ...backup } as Partial; delete missing.publicKey; invalid.push(missing); const accessor = { ...backup }; Object.defineProperty(accessor, 'publicKey', { get() { throw new Error('Getter must not run'); } }); invalid.push(accessor); for (const value of invalid) await assert.rejects(decryptWallet(value, password)); const { privateKey, publicKeyText } = await fixture; for (const bad of ['', 'x'.repeat(11), 'x'.repeat(1025)]) { await assert.rejects(encryptWallet(privateKey, publicKeyText, bad), /Password/); } await assert.rejects(decryptWallet(backup, 'x'.repeat(1025)), /Password/); }); test('wallet persistence commits before restore resolves, isolates demo DB, retries and recovers after failures', async () => { // Small storage-boundary double: deliberately separate get success and commit. // Browser IndexedDB/Web Locks are additionally checked against the real API. const originalIDB = Object.getOwnPropertyDescriptor(globalThis, 'indexedDB'); const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); let stored: Record | undefined; let failWrites = 0; let permanentFailure = false; let lockCalls = 0; let commits = 0; let beforeWrite: (()=>void)|undefined; const opened: string[] = []; const installNavigator = (locks: boolean) => Object.defineProperty(globalThis, 'navigator', { configurable: true, value: locks ? { locks: { request: async (name: string, callback: () => Promise) => { assert.equal(name, 'wikideck-testnet-wallet-v2:identity'); lockCalls++; return callback(); } } } : {} }); Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: { open(name: string) { opened.push(name); const request = { result: { close() {}, transaction(_store: string, mode: string) { if(mode==='readwrite'&&beforeWrite){const mutate=beforeWrite;beforeWrite=undefined;mutate();} let replacement: Record | undefined; let aborted=false; const tx = { error: null as DOMException | null, oncomplete: () => {}, onabort: () => {}, onerror: () => {}, abort: () => { aborted=true;queueMicrotask(()=>tx.onabort()); }, objectStore: () => ({ get: () => { const get = { result: stored, onsuccess: () => {} }; queueMicrotask(() => { get.onsuccess(); setImmediate(() => { if(aborted)return; if (mode === 'readwrite' && (permanentFailure || failWrites-- > 0)) { tx.error = new DOMException('Simulated storage failure', permanentFailure ? 'QuotaExceededError' : 'AbortError'); tx.onabort(); } else { if (replacement) stored = structuredClone(replacement); commits++; tx.oncomplete(); } }); }); return get; }, put: (value: Record) => { replacement = value; }, }), }; return tx; }, }, onsuccess: () => {} }; queueMicrotask(() => request.onsuccess()); return request; }, } }); try { lockWallet(); installNavigator(false); assert.equal(await getWallet(),undefined); assert.equal(await walletStatus(),'empty'); assert.equal(stored,undefined); // Startup never silently creates a plaintext key. const initial = await protectWallet(password); assert.equal('privateKey' in initial,false); assert.equal((stored as Record|undefined)?.format,'wikideck-local-vault'); assert.equal('privateKey' in stored!,false); const exported=await exportWallet(initial,password); assert.equal((await decryptWallet(exported,password)).address,initial.address); const signature=await sign(initial,'session-signature'); const publicKey=await subtle.importKey('spki',Buffer.from(initial.publicKey,'base64'),{name:'ECDSA',namedCurve:'P-256'},false,['verify']); assert.equal(await subtle.verify({name:'ECDSA',hash:'SHA-256'},publicKey,Buffer.from(signature,'base64'),new TextEncoder().encode('session-signature')),true); lockWallet(); assert.equal(await getWallet(),undefined); await assert.rejects(sign(initial,'locked'),/locked/); assert.equal(await walletStatus(),'locked'); await assert.rejects(unlockWallet('incorrect-password'),/Cannot unlock/); const unlocked=await unlockWallet(password); assert.equal(unlocked.address,initial.address); await assert.rejects(sign(initial,'stale handle'),/locked or changed/); const pendingExport=exportWallet(unlocked,password);lockWallet(); await assert.rejects(pendingExport,/locked|changed/); const unlocking=unlockWallet(password);lockWallet(); await assert.rejects(unlocking,/locked|changed/); await unlockWallet(password); const originalNow=Date.now; try { const now=Date.now();Date.now=()=>now+WALLET_IDLE_MS+1;assert.equal(await getWallet(),undefined); } finally {Date.now=originalNow;} const { backup } = await fixture; const expected = await decryptWallet(backup, password); installNavigator(true); failWrites = 1; const before = commits; const recovered = await restoreWallet(backup, password,password,true); assert.ok(commits > before); assert.equal((stored as Record|undefined)?.format,'wikideck-local-vault'); assert.equal(recovered.address, expected.address); assert.equal((await getWallet())?.address, expected.address); assert.ok(lockCalls >= 2); assert.ok(opened.every(name => name === 'wikideck-testnet-wallet-v2')); permanentFailure = true; const preserved=JSON.stringify(stored); await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' }); assert.equal(JSON.stringify(stored),preserved); permanentFailure = false; await assert.rejects(restoreWallet(backup,password,password),/replacement/); await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64'),password,true)); assert.equal((await unlockWallet(password)).address, expected.address); failWrites = 5; const attempts = opened.length; await assert.rejects(restoreWallet(backup, password,password,true), { name: 'AbortError' }); assert.equal(opened.length - attempts, 4); // One read, three write attempts. failWrites = 0; assert.equal((await restoreWallet(backup, password,password,true)).address, expected.address); // Simulate another tab replacing a vault between our initial read and commit; CAS must reject. beforeWrite=()=>{stored={...stored,revision:'other-tab-revision'};}; await assert.rejects(restoreWallet(exported,password,password,true),/changed in another tab/); assert.equal((stored as Record|undefined)?.revision,'other-tab-revision'); const current=await unlockWallet(password); stored={...(stored as Record|undefined),revision:'another-tab-replaced'}; await assert.rejects(sign(current,'cross-tab stale signer'),/changed/); assert.equal(await getWallet(),undefined); const expiry=await unlockWallet(password),baseNow=Date.now,openedAt=Date.now(); try{ let time=openedAt;Date.now=()=>time; // Trusted UI activity may extend idle time, never the four-hour absolute lifetime. for(let i=0;i<49;i++){time+=WALLET_IDLE_MS-1000;touchWallet();} await assert.rejects(sign(expiry,'absolute expiry'),/locked/); }finally{Date.now=baseNow;} // Seed the old same-origin plaintext shape; explicit migration must preserve its identity. lockWallet(); stored=expected as unknown as Record; assert.equal(await walletStatus(),'legacy');assert.equal(await getWallet(),undefined); permanentFailure=true; await assert.rejects(protectWallet(password),{name:'QuotaExceededError'}); assert.equal(stored.privateKey,expected.privateKey); // No destructive migration on failed commit. permanentFailure=false; assert.equal((await protectWallet(password)).address,expected.address); assert.equal('privateKey' in stored,false); lockWallet();assert.equal((await unlockWallet(password)).address,expected.address); } finally { lockWallet(); if (originalIDB) Object.defineProperty(globalThis, 'indexedDB', originalIDB); else Reflect.deleteProperty(globalThis, 'indexedDB'); if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else Reflect.deleteProperty(globalThis, 'navigator'); } });