encryption
This commit is contained in:
+77
-19
@@ -3,7 +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 { getWallet, exportWallet, restoreWallet, sign, type Wallet } from '../src/wallet.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.
|
||||
@@ -49,7 +49,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
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');
|
||||
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);
|
||||
@@ -58,7 +58,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
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);
|
||||
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);
|
||||
@@ -145,11 +145,12 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
// 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 stored: Record<string, unknown> | 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<unknown>) => {
|
||||
@@ -161,16 +162,20 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
const request = { result: {
|
||||
close() {},
|
||||
transaction(_store: string, mode: string) {
|
||||
let replacement: Wallet | undefined;
|
||||
if(mode==='readwrite'&&beforeWrite){const mutate=beforeWrite;beforeWrite=undefined;mutate();}
|
||||
let replacement: Record<string, unknown> | 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();
|
||||
@@ -183,7 +188,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
});
|
||||
return get;
|
||||
},
|
||||
put: (value: Wallet) => { replacement = value; },
|
||||
put: (value: Record<string,unknown>) => { replacement = value; },
|
||||
}),
|
||||
};
|
||||
return tx;
|
||||
@@ -194,35 +199,88 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
},
|
||||
} });
|
||||
try {
|
||||
lockWallet();
|
||||
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);
|
||||
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<string,unknown>|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);
|
||||
const recovered = await restoreWallet(backup, password,password,true);
|
||||
assert.ok(commits > before);
|
||||
assert.equal(stored?.address, expected.address);
|
||||
assert.equal((stored as Record<string,unknown>|undefined)?.format,'wikideck-local-vault');
|
||||
assert.equal(recovered.address, expected.address);
|
||||
assert.equal((await getWallet()).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);
|
||||
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, randomBytes(24).toString('base64')));
|
||||
assert.equal((await getWallet()).address, expected.address);
|
||||
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), { name: 'AbortError' });
|
||||
assert.equal(opened.length - attempts, 3);
|
||||
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)).address, expected.address);
|
||||
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<string,unknown>|undefined)?.revision,'other-tab-revision');
|
||||
const current=await unlockWallet(password);
|
||||
stored={...(stored as Record<string,unknown>|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<string,unknown>;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user