This commit is contained in:
2026-10-29 11:45:51 -04:00
parent b3a1179703
commit b427fa4217
35 changed files with 469 additions and 158 deletions
+20 -4
View File
@@ -3,6 +3,7 @@ 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 { expectedChainIdentity } from '../shared/chain-identity.ts';
import { getWallet, exportWallet, restoreWallet, protectWallet, unlockWallet, walletStatus, lockWallet, sign, touchWallet, WALLET_IDLE_MS } from '../src/wallet.ts';
const subtle = webcrypto.subtle as SubtleCrypto;
@@ -31,6 +32,7 @@ async function seal(pkcs8: ArrayBuffer, header: WalletBackup): Promise<WalletBac
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.version === 2 ? [header.chainId] : []),
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)) };
@@ -54,7 +56,9 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
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(backup.version, 2);
assert.equal(backup.network, 'wikideck-randomx-testnet-6');
assert.equal(backup.chainId, (await expectedChainIdentity()).chainId);
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);
@@ -64,6 +68,17 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
assert.notEqual(again.ciphertext, backup.ciphertext);
});
test('chain-bound v2 rejects a different chain and original testnet v1 backups remain recoverable', async () => {
const { backup, privateKey } = await fixture;
assert.ok(backup.version === 2);
await assert.rejects(decryptWallet({ ...backup, chainId: '0'.repeat(64) }, password), /different chain/);
const { chainId: _chainId, ...legacyFields } = backup;
const legacy: WalletBackup = { ...legacyFields, version: 1, ciphertext: '' };
const legacyBackup = await seal(await subtle.exportKey('pkcs8', privateKey), legacy);
assert.equal((await decryptWallet(legacyBackup, password)).address,
(await decryptWallet(backup, password)).address);
});
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' });
@@ -117,7 +132,7 @@ test('strict format, bounded fields, canonical base64, and fixed KDF work reject
await assert.rejects(decryptWallet({ ...backup, iterations }, password), /Unsupported/);
}
const invalid: unknown[] = [null, undefined, [], 'x'.repeat(32769), {},
{ ...backup, extra: 'unexpected' }, { ...backup, version: 2 },
{ ...backup, extra: 'unexpected' }, { ...backup, version: 3 },
{ ...backup, network: 'wikideck-demo' }, { ...backup, format: 'other' },
{ ...backup, kdf: 'scrypt' }, { ...backup, cipher: 'AES-CBC' },
{ ...backup, salt: 'AA==' }, { ...backup, iv: null },
@@ -152,9 +167,10 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
let commits = 0;
let beforeWrite: (()=>void)|undefined;
const opened: string[] = [];
const dbName = `wikideck-wallet-${(await expectedChainIdentity()).chainId}-v2`;
const installNavigator = (locks: boolean) => Object.defineProperty(globalThis, 'navigator', { configurable: true,
value: locks ? { locks: { request: async (name: string, callback: () => Promise<unknown>) => {
assert.equal(name, 'wikideck-testnet-wallet-v2:identity'); lockCalls++; return callback();
assert.equal(name, `${dbName}:identity`); lockCalls++; return callback();
} } } : {} });
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: {
open(name: string) {
@@ -239,7 +255,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
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'));
assert.ok(opened.every(name => name === dbName));
permanentFailure = true;
const preserved=JSON.stringify(stored);
await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' });