232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
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, sign, type Wallet } 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<WalletBackup> {
|
|
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 = await sign(wallet, '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-5');
|
|
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 exportWallet(wallet, 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<WalletBackup>;
|
|
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: Wallet | undefined;
|
|
let failWrites = 0;
|
|
let permanentFailure = false;
|
|
let lockCalls = 0;
|
|
let commits = 0;
|
|
const opened: string[] = [];
|
|
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();
|
|
} } } : {} });
|
|
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: {
|
|
open(name: string) {
|
|
opened.push(name);
|
|
const request = { result: {
|
|
close() {},
|
|
transaction(_store: string, mode: string) {
|
|
let replacement: Wallet | undefined;
|
|
const tx = {
|
|
error: null as DOMException | null,
|
|
oncomplete: () => {}, onabort: () => {}, onerror: () => {},
|
|
objectStore: () => ({
|
|
get: () => {
|
|
const get = { result: stored, onsuccess: () => {} };
|
|
queueMicrotask(() => {
|
|
get.onsuccess();
|
|
setImmediate(() => {
|
|
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: Wallet) => { replacement = value; },
|
|
}),
|
|
};
|
|
return tx;
|
|
},
|
|
}, onsuccess: () => {} };
|
|
queueMicrotask(() => request.onsuccess());
|
|
return request;
|
|
},
|
|
} });
|
|
try {
|
|
installNavigator(false);
|
|
const initial = await Promise.all(Array.from({ length: 4 }, () => getWallet()));
|
|
assert.equal(new Set(initial.map(wallet => wallet.address)).size, 1);
|
|
assert.equal(initial[0].privateKey.extractable, true);
|
|
const { backup } = await fixture;
|
|
const expected = await decryptWallet(backup, password);
|
|
installNavigator(true);
|
|
failWrites = 1;
|
|
const before = commits;
|
|
const recovered = await restoreWallet(backup, password);
|
|
assert.ok(commits > before);
|
|
assert.equal(stored?.address, expected.address);
|
|
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;
|
|
await assert.rejects(restoreWallet(await exportWallet(initial[0], password), password), { name: 'QuotaExceededError' });
|
|
assert.equal(stored?.address, expected.address);
|
|
permanentFailure = false;
|
|
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64')));
|
|
assert.equal((await getWallet()).address, expected.address);
|
|
failWrites = 5;
|
|
const attempts = opened.length;
|
|
await assert.rejects(restoreWallet(backup, password), { name: 'AbortError' });
|
|
assert.equal(opened.length - attempts, 3);
|
|
failWrites = 0;
|
|
assert.equal((await restoreWallet(backup, password)).address, expected.address);
|
|
} finally {
|
|
if (originalIDB) Object.defineProperty(globalThis, 'indexedDB', originalIDB);
|
|
else Reflect.deleteProperty(globalThis, 'indexedDB');
|
|
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
|
else Reflect.deleteProperty(globalThis, 'navigator');
|
|
}
|
|
});
|