run.mjs raw
1 // run.mjs — Node.js test harness for web/wasm/signer/
2 // Directly instantiates signer.wasm with minimal JS bridge stubs.
3 // No moxiejs runtime, no bundler, no npm deps.
4 //
5 // Usage: node run.mjs [path/to/signer.wasm]
6 // default wasm path: ../../ext/bg/signer.wasm
7
8 import { readFileSync } from 'fs';
9 import { createHash, createHmac, createCipheriv, createDecipheriv, pbkdf2Sync, randomFillSync } from 'crypto';
10 import { webcrypto } from 'crypto';
11 import { fileURLToPath } from 'url';
12 import { dirname, join } from 'path';
13
14 // Node v24 already exposes globalThis.crypto; only set if missing.
15 if (!globalThis.crypto) globalThis.crypto = webcrypto;
16
17 const __dirname = dirname(fileURLToPath(import.meta.url));
18
19 // Import secp256k1 from the legacy moxiejs runtime (BigInt impl).
20 // Used as an independent verifier for WASM signer signatures in this harness.
21 // Path reflects the moxiejs ext build location after the WASM migration.
22 const rtDir = join(__dirname, '../../../ext/signer-bg/$runtime');
23 const { Slice } = await import(join(rtDir, 'builtin.mjs'));
24 const secp = await import(join(rtDir, 'schnorr.mjs'));
25
26 // --------------------------------------------------------------------------
27 // Bridge helpers
28 // --------------------------------------------------------------------------
29
30 let mem, xp;
31 const enc = new TextEncoder();
32 const dec = new TextDecoder();
33
34 function readStr(ptr, len) {
35 return len <= 0 ? '' : dec.decode(new Uint8Array(mem.buffer, ptr, len));
36 }
37 function readBytes(ptr, len) {
38 return len <= 0 ? new Uint8Array(0) : new Uint8Array(mem.buffer, ptr, len);
39 }
40 function alloc(n) {
41 const ptr = xp.__alloc(n);
42 return ptr;
43 }
44 function writeStr(s) {
45 const b = enc.encode('' + s);
46 const ptr = alloc(b.length);
47 new Uint8Array(mem.buffer, ptr, b.length).set(b);
48 return [ptr, b.length];
49 }
50 function writeI32(addr, v) {
51 new DataView(mem.buffer).setInt32(addr, v, true);
52 }
53 function fromSlice(s) {
54 if (s instanceof Uint8Array) return s;
55 if (s && s.$array != null) {
56 const u = new Uint8Array(s.$length);
57 for (let i = 0; i < s.$length; i++) u[i] = s.$array[s.$offset + i];
58 return u;
59 }
60 return s instanceof Uint8Array ? s : new Uint8Array(s ?? 0);
61 }
62 function writeBytes(data) {
63 const u = fromSlice(data);
64 const ptr = alloc(u.length);
65 if (u.length) new Uint8Array(mem.buffer, ptr, u.length).set(u);
66 return [ptr, u.length];
67 }
68
69 // Callback dispatchers — call back into WASM.
70 function cbs(id, s) { const [p,l] = writeStr(s); xp.__cbs(id, p, l); }
71 function cbdata(id, data) { const [p,l] = writeBytes(data); xp.__cbdata(id, p, l); }
72 function writeOut(data, rPtrAddr, rLenAddr, rOkAddr, ok) {
73 const [ptr, len] = writeBytes(data);
74 writeI32(rPtrAddr, ptr);
75 writeI32(rLenAddr, len);
76 if (rOkAddr !== undefined) writeI32(rOkAddr, ok ? 1 : 0);
77 }
78
79 // --------------------------------------------------------------------------
80 // Synchronous crypto bridge helpers (Node.js native)
81 // --------------------------------------------------------------------------
82
83 function sha256(data) {
84 return createHash('sha256').update(data).digest();
85 }
86 function hmacSHA512(key, data) {
87 return createHmac('sha512', key).update(data).digest();
88 }
89 function pbkdf2SHA256(pw, salt, iters, dkLen) {
90 return pbkdf2Sync(typeof pw === 'string' ? pw : Buffer.from(pw), salt, iters, dkLen, 'sha256');
91 }
92 function pbkdf2SHA512(pw, salt, iters, dkLen) {
93 return pbkdf2Sync(typeof pw === 'string' ? pw : Buffer.from(pw), salt, iters, dkLen, 'sha512');
94 }
95 function aesCBCEncrypt(key, iv, pt) {
96 // AES-256-CBC with PKCS7 padding (same as crypto.subtle AES-CBC).
97 const cipher = createCipheriv('aes-256-cbc', key, iv);
98 return Buffer.concat([cipher.update(pt), cipher.final()]);
99 }
100 function aesCBCDecrypt(key, iv, ct) {
101 try {
102 const dc = createDecipheriv('aes-256-cbc', key, iv);
103 return Buffer.concat([dc.update(ct), dc.final()]);
104 } catch (_) { return new Uint8Array(0); }
105 }
106 function argon2idStub(pw, salt, t, m, p, dkLen) {
107 // Placeholder: vault create/unlock are TODO; Argon2 won't be called in
108 // current tests. Return deterministic bytes so the callback fires cleanly
109 // if it is called (avoids hanging tests).
110 const h = createHash('sha256').update('argon2stub').update(pw).update(salt).digest();
111 const out = new Uint8Array(dkLen);
112 for (let i = 0; i < dkLen; i++) out[i] = h[i % 32];
113 return out;
114 }
115
116 // --------------------------------------------------------------------------
117 // In-memory ext storage
118 // --------------------------------------------------------------------------
119
120 const storage = new Map();
121 const session = new Map();
122
123 // Message handler registered by WASM via ext_on_message.
124 let _onMessage = null;
125 let _nextReq = 1;
126 const _pending = new Map();
127
128 // --------------------------------------------------------------------------
129 // Bridge import object
130 // --------------------------------------------------------------------------
131
132 const bridge = {
133 // --- ext ---
134 ext_storage_get(kPtr, kLen, _, cbID) {
135 const val = storage.get(readStr(kPtr, kLen)) ?? '';
136 // Must be async: cannot re-enter WASM while inside imported fn call.
137 queueMicrotask(() => cbs(cbID, val));
138 },
139 ext_storage_set(kPtr, kLen, _, vPtr, vLen) {
140 storage.set(readStr(kPtr, kLen), readStr(vPtr, vLen));
141 },
142 ext_storage_remove(kPtr, kLen) {
143 storage.delete(readStr(kPtr, kLen));
144 },
145 ext_on_message(cbID) {
146 _onMessage = function(method, params, tabID, respond) {
147 const reqID = _nextReq++;
148 _pending.set(reqID, respond);
149 const [mPtr, mLen] = writeStr(method);
150 const [pPtr, pLen] = writeStr(params);
151 xp.__hook_ext_on_message(reqID, mPtr, mLen, pPtr, pLen, tabID);
152 };
153 },
154 ext_on_message_respond(reqID, ptr, len) {
155 const fn = _pending.get(reqID);
156 if (fn) { _pending.delete(reqID); fn(readStr(ptr, len)); }
157 },
158 ext_console_log(ptr, len) {
159 process.stderr.write('[signer] ' + readStr(ptr, len) + '\n');
160 },
161 ext_session_get(kPtr, kLen, _, cbID) {
162 const val = session.get(readStr(kPtr, kLen)) ?? '';
163 queueMicrotask(() => cbs(cbID, val));
164 },
165 ext_session_set(kPtr, kLen, _, vPtr, vLen) {
166 session.set(readStr(kPtr, kLen), readStr(vPtr, vLen));
167 },
168 ext_is_in_page() { return 0; },
169
170 // --- schnorr (synchronous - pure BigInt secp256k1) ---
171 schnorr_sha256sum(dPtr, dLen, _, rPtrAddr, rLenAddr) {
172 writeOut(secp.SHA256Sum(readBytes(dPtr, dLen)), rPtrAddr, rLenAddr);
173 },
174 schnorr_pubkey_from_seckey(skPtr, skLen, _, rPtrAddr, rLenAddr, rOkAddr) {
175 const [r, ok] = secp.PubKeyFromSecKey(readBytes(skPtr, skLen));
176 writeOut(r ?? new Uint8Array(0), rPtrAddr, rLenAddr, rOkAddr, ok);
177 },
178 schnorr_sign(skPtr, skLen, _, msgPtr, msgLen, __, auxPtr, auxLen, ___, rPtrAddr, rLenAddr, rOkAddr) {
179 const [r, ok] = secp.SignSchnorr(readBytes(skPtr, skLen), readBytes(msgPtr, msgLen), readBytes(auxPtr, auxLen));
180 writeOut(r ?? new Uint8Array(0), rPtrAddr, rLenAddr, rOkAddr, ok);
181 },
182 schnorr_verify(pkPtr, pkLen, _, msgPtr, msgLen, __, sigPtr, sigLen) {
183 return secp.VerifySchnorr(readBytes(pkPtr, pkLen), readBytes(msgPtr, msgLen), readBytes(sigPtr, sigLen)) ? 1 : 0;
184 },
185 schnorr_ecdh(skPtr, skLen, _, pkPtr, pkLen, __, rPtrAddr, rLenAddr, rOkAddr) {
186 const [r, ok] = secp.ECDH(readBytes(skPtr, skLen), readBytes(pkPtr, pkLen));
187 writeOut(r ?? new Uint8Array(0), rPtrAddr, rLenAddr, rOkAddr, ok);
188 },
189 schnorr_scalar_add_mod_n(aPtr, aLen, _, bPtr, bLen, __, rPtrAddr, rLenAddr, rOkAddr) {
190 const [r, ok] = secp.ScalarAddModN(readBytes(aPtr, aLen), readBytes(bPtr, bLen));
191 writeOut(r ?? new Uint8Array(0), rPtrAddr, rLenAddr, rOkAddr, ok);
192 },
193 schnorr_compressed_pubkey(skPtr, skLen, _, rPtrAddr, rLenAddr, rOkAddr) {
194 const [r, ok] = secp.CompressedPubKey(readBytes(skPtr, skLen));
195 writeOut(r ?? new Uint8Array(0), rPtrAddr, rLenAddr, rOkAddr, ok);
196 },
197
198 // --- subtle (async paths fire callback via queueMicrotask) ---
199 subtle_random_bytes(ptr, len) {
200 randomFillSync(new Uint8Array(mem.buffer, ptr, len));
201 },
202 subtle_aes_cbc_encrypt(kPtr, kLen, _, ivPtr, ivLen, __, ptPtr, ptLen, ___, cbID) {
203 const result = aesCBCEncrypt(readBytes(kPtr, kLen), readBytes(ivPtr, ivLen), readBytes(ptPtr, ptLen));
204 queueMicrotask(() => cbdata(cbID, result));
205 },
206 subtle_aes_cbc_decrypt(kPtr, kLen, _, ivPtr, ivLen, __, ctPtr, ctLen, ___, cbID) {
207 const result = aesCBCDecrypt(readBytes(kPtr, kLen), readBytes(ivPtr, ivLen), readBytes(ctPtr, ctLen));
208 queueMicrotask(() => cbdata(cbID, result));
209 },
210 subtle_aes_gcm_encrypt(kPtr, kLen, _, ivPtr, ivLen, __, ptPtr, ptLen, ___, cbID) {
211 queueMicrotask(() => cbdata(cbID, new Uint8Array(0)));
212 },
213 subtle_aes_gcm_decrypt(kPtr, kLen, _, ivPtr, ivLen, __, ctPtr, ctLen, ___, cbID) {
214 queueMicrotask(() => cbdata(cbID, new Uint8Array(0)));
215 },
216 subtle_pbkdf2_derive_key(pwPtr, pwLen, _, saltPtr, saltLen, __, iters, cbID) {
217 const result = pbkdf2SHA256(readStr(pwPtr, pwLen), readBytes(saltPtr, saltLen), iters, 32);
218 queueMicrotask(() => cbdata(cbID, result));
219 },
220 subtle_argon2id_derive_key(pwPtr, pwLen, _, saltPtr, saltLen, __, t, m, p, dkLen, cbID) {
221 const result = argon2idStub(readStr(pwPtr, pwLen), readBytes(saltPtr, saltLen), t, m, p, dkLen);
222 queueMicrotask(() => cbdata(cbID, result));
223 },
224 subtle_sha256_hex(ptr, len, _, cbID) {
225 const hex = sha256(readBytes(ptr, len)).toString('hex');
226 queueMicrotask(() => cbs(cbID, hex));
227 },
228 subtle_hmac_sha512(kPtr, kLen, _, dPtr, dLen, __, cbID) {
229 const result = hmacSHA512(readBytes(kPtr, kLen), readBytes(dPtr, dLen));
230 queueMicrotask(() => cbdata(cbID, result));
231 },
232 subtle_pbkdf2_sha512(pwPtr, pwLen, _, saltPtr, saltLen, __, iters, dkLen, cbID) {
233 const result = pbkdf2SHA512(readStr(pwPtr, pwLen), readBytes(saltPtr, saltLen), iters, dkLen);
234 queueMicrotask(() => cbdata(cbID, result));
235 },
236 };
237
238 const wasi = {
239 fd_write(fd, iovs, iovs_len, nwritten_ptr) {
240 const dv = new DataView(mem.buffer);
241 let total = 0;
242 for (let i = 0; i < iovs_len; i++) {
243 const ptr = dv.getUint32(iovs + i * 8, true);
244 const len = dv.getUint32(iovs + i * 8 + 4, true);
245 const s = dec.decode(new Uint8Array(mem.buffer, ptr, len));
246 if (fd === 1) process.stdout.write(s);
247 else if (fd === 2) process.stderr.write(s);
248 total += len;
249 }
250 dv.setUint32(nwritten_ptr, total, true);
251 return 0;
252 },
253 // CLOCK_REALTIME=0, CLOCK_MONOTONIC=1. Both back to host high-res time.
254 clock_time_get(clockID, precision, time_ptr) {
255 const ns = BigInt(Math.floor(performance.now() * 1e6));
256 new DataView(mem.buffer).setBigUint64(time_ptr, ns, true);
257 return 0;
258 },
259 };
260
261 const bridgeExtras = {
262 timezone_offset_minutes() {
263 return -new Date().getTimezoneOffset();
264 },
265 };
266
267 // --------------------------------------------------------------------------
268 // Boot
269 // --------------------------------------------------------------------------
270
271 async function boot(wasmPath, initialStorage = {}) {
272 for (const [k, v] of Object.entries(initialStorage)) storage.set(k, v);
273
274 const wasmBytes = readFileSync(wasmPath);
275 const { instance } = await WebAssembly.instantiate(wasmBytes, {
276 bridge: { ...bridge, ...bridgeExtras },
277 wasi_snapshot_preview1: wasi,
278 });
279 mem = instance.exports.memory;
280 xp = instance.exports;
281 xp._start();
282
283 // Drain microtask queue so loadVault + loadPermissions callbacks fire.
284 for (let i = 0; i < 10; i++) await Promise.resolve();
285 }
286
287 // Send a message to the WASM signer and wait for the response.
288 function send(method, params = '{}') {
289 return new Promise((resolve, reject) => {
290 if (!_onMessage) { reject(new Error('WASM not ready')); return; }
291 _onMessage(method, params, 0, resolve);
292 });
293 }
294
295 // Like send but also drain microtasks afterward (for async bridge paths).
296 async function sendAsync(method, params = '{}') {
297 const p = send(method, params);
298 for (let i = 0; i < 20; i++) await Promise.resolve();
299 return p;
300 }
301
302 // --------------------------------------------------------------------------
303 // Test runner
304 // --------------------------------------------------------------------------
305
306 let _pass = 0, _fail = 0;
307
308 function ok(name, cond, detail = '') {
309 if (cond) {
310 console.log(` ok ${name}`);
311 _pass++;
312 } else {
313 console.log(`FAIL ${name}${detail ? ' — ' + detail : ''}`);
314 _fail++;
315 }
316 }
317
318 function jsonOk(r) {
319 try { return JSON.parse(r); } catch(_) { return null; }
320 }
321
322 // --------------------------------------------------------------------------
323 // BIP-340 test vectors (for bridge verification)
324 // --------------------------------------------------------------------------
325
326 const TV_SK = '0000000000000000000000000000000000000000000000000000000000000003';
327 const TV_PK = 'f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9';
328 const TV_MSG = '0000000000000000000000000000000000000000000000000000000000000000';
329 const TV_AUX = '0000000000000000000000000000000000000000000000000000000000000000';
330 const TV_SIG = ''.padEnd(128, '0'); // not checking exact sig, just that it verifies
331
332 function hexToBytes(h) { return new Uint8Array(h.match(/../g).map(b => parseInt(b, 16))); }
333 function bytesToHex(b) { return Array.from(b).map(x => x.toString(16).padStart(2,'0')).join(''); }
334
335 // --------------------------------------------------------------------------
336 // Version-0 vault fixture (plaintext — avoids Argon2id for current tests)
337 // --------------------------------------------------------------------------
338
339 const FIXTURE_SK = '0000000000000000000000000000000000000000000000000000000000000003';
340 const FIXTURE_PK = TV_PK;
341 // version must be a JSON number 0, not string "0".
342 // JsonGetValue returns raw JSON: number 0 → "0", string "0" → '"0"'.
343 // vault.mx checks: ver == "0" where ver is the raw token, so needs number.
344 const FIXTURE_VAULT = JSON.stringify({
345 version: 0,
346 identities: { pubkey: FIXTURE_PK, seckey: FIXTURE_SK },
347 });
348
349 // --------------------------------------------------------------------------
350 // Tests
351 // --------------------------------------------------------------------------
352
353 async function runFreshTests(wasmPath) {
354 console.log('\n── Fresh vault (no storage) ──');
355
356 storage.clear();
357 _onMessage = null;
358 await boot(wasmPath);
359
360 // 1. getVaultStatus → none
361 const s = await send('smesh.getVaultStatus');
362 ok('getVaultStatus is none', jsonOk(s)?.result === 'none', s);
363
364 // 2. nwcList → empty
365 const nwc = await send('smesh.nwc.list');
366 ok('nwcList is empty', JSON.stringify(jsonOk(nwc)?.result) === '[]', nwc);
367
368 // 3. getPermissions → empty
369 const perms = await send('smesh.getPermissions');
370 ok('getPermissions empty', JSON.stringify(jsonOk(perms)?.result) === '[]', perms);
371
372 // 4. getPublicKey → error (no identity)
373 const pk = await send('getPublicKey');
374 ok('getPublicKey no identity', jsonOk(pk)?.error != null, pk);
375
376 // 5. unknown method → error
377 const unk = await send('smesh.doesNotExist');
378 ok('unknown method returns error', jsonOk(unk)?.error === 'unknown method', unk);
379
380 // 6. generateMnemonic → 12 words
381 const mnem = await sendAsync('smesh.generateMnemonic');
382 const words = jsonOk(mnem)?.result?.split(' ') ?? [];
383 ok('generateMnemonic is 12 words', words.length === 12, mnem);
384
385 // 7. validateMnemonic (non-empty) → true (stub)
386 const vm = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: words.join(' ') }));
387 ok('validateMnemonic non-empty → true', jsonOk(vm)?.result === true, vm);
388
389 // 8. validateMnemonic empty → false
390 const vme = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: '' }));
391 ok('validateMnemonic empty → false', jsonOk(vme)?.result === false, vme);
392
393 // 9. permissions roundtrip
394 await send('smesh.setPermission', JSON.stringify({ host: 'test.local', method: 'signEvent', policy: 'allow' }));
395 const gp = await send('smesh.getPermissions');
396 const gpObj = jsonOk(gp);
397 const found = gpObj?.result?.some(p => p.host === 'test.local' && p.method === 'signEvent' && p.policy === 'allow');
398 ok('setPermission + getPermissions roundtrip', found, gp);
399
400 // 10. resetExtension → true + getVaultStatus → none
401 const reset = await send('smesh.resetExtension');
402 ok('resetExtension → true', jsonOk(reset)?.result === true, reset);
403 const afterReset = await send('smesh.getVaultStatus');
404 ok('after resetExtension vaultStatus is none', jsonOk(afterReset)?.result === 'none', afterReset);
405 }
406
407 async function runSecp256k1BridgeTests() {
408 console.log('\n── secp256k1 bridge (direct JS) ──');
409
410 // Verify the bridge functions produce correct BIP-340 results
411 // independently of WASM — confirms the bridge stubs are correct.
412 const sk = hexToBytes(TV_SK);
413 const msg = hexToBytes(TV_MSG);
414 const aux = hexToBytes(TV_AUX);
415
416 const [pk, pkOk] = secp.PubKeyFromSecKey(sk);
417 ok('PubKeyFromSecKey BIP-340 vector 0', pkOk && bytesToHex(fromSlice(pk)) === TV_PK);
418
419 const [sig, sigOk] = secp.SignSchnorr(sk, msg, aux);
420 ok('SignSchnorr succeeds', sigOk && fromSlice(sig).length === 64);
421
422 const pkB = fromSlice(pk);
423 const sigB = fromSlice(sig);
424 const valid = secp.VerifySchnorr(pkB, msg, sigB);
425 ok('VerifySchnorr on own sig', valid);
426
427 const [shared, sharedOk] = secp.ECDH(sk, pkB);
428 ok('ECDH succeeds', sharedOk && fromSlice(shared).length === 32);
429
430 const sumSHA = fromSlice(secp.SHA256Sum(new Uint8Array([1,2,3])));
431 ok('SHA256Sum returns 32 bytes', sumSHA.length === 32);
432 }
433
434 async function runPreseededTests(wasmPath) {
435 console.log('\n── Pre-seeded v0 vault ──');
436
437 storage.clear();
438 _onMessage = null;
439 await boot(wasmPath, { 'smesh-vault': FIXTURE_VAULT });
440
441 // 1. vault auto-restored → unlocked
442 const vs = await send('smesh.getVaultStatus');
443 ok('v0 vault auto-unlocked on boot', jsonOk(vs)?.result === 'unlocked', vs);
444
445 // 2. getPublicKey → fixture pubkey
446 const pk = await send('getPublicKey');
447 ok('getPublicKey returns fixture pubkey', jsonOk(pk)?.result === FIXTURE_PK, pk);
448
449 // 3. listIdentities → contains fixture
450 const list = await send('smesh.listIdentities');
451 const ids = jsonOk(list)?.result ?? [];
452 ok('listIdentities contains fixture', ids.some(i => i.pubkey === FIXTURE_PK), list);
453
454 // 4. signEvent → valid BIP-340 Schnorr sig
455 const evIn = JSON.stringify({ event: { kind: 1, content: 'hello', created_at: 1700000000, tags: [] } });
456 const signed = await sendAsync('signEvent', evIn);
457 const ev = jsonOk(signed)?.result;
458 ok('signEvent has sig', ev?.sig?.length === 128, signed);
459 ok('signEvent has pubkey', ev?.pubkey === FIXTURE_PK, signed);
460 // verify the sig using our bridge
461 if (ev?.sig && ev?.id) {
462 const msgBytes = hexToBytes(ev.id);
463 const pkBytes = hexToBytes(FIXTURE_PK);
464 const sigBytes = hexToBytes(ev.sig);
465 const sigVerifies = secp.VerifySchnorr(pkBytes, msgBytes, sigBytes);
466 ok('signEvent sig verifies', sigVerifies);
467 } else {
468 ok('signEvent sig verifies', false, 'no sig/id in response');
469 }
470
471 // 5. NIP-44 encrypt → decrypt roundtrip (synchronous path)
472 const plaintext = 'hello nip44';
473 // Use a different pubkey (derived from seckey 2) as the peer.
474 const peerSK = hexToBytes('0000000000000000000000000000000000000000000000000000000000000002');
475 const [peerPK] = secp.PubKeyFromSecKey(peerSK);
476 const peerPKHex = bytesToHex(fromSlice(peerPK));
477
478 const encP = JSON.stringify({ pubkey: peerPKHex, plaintext });
479 const encR = await send('nip44.encrypt', encP);
480 const ciphertext = jsonOk(encR)?.result;
481 ok('nip44.encrypt returns ciphertext', typeof ciphertext === 'string' && ciphertext.length > 0, encR);
482
483 if (ciphertext) {
484 const decP = JSON.stringify({ pubkey: peerPKHex, ciphertext });
485 const decR = await send('nip44.decrypt', decP);
486 ok('nip44.decrypt roundtrip', jsonOk(decR)?.result === plaintext, decR);
487 }
488
489 // 6. NIP-04 encrypt → decrypt roundtrip (async AES-CBC path)
490 const encP04 = JSON.stringify({ pubkey: peerPKHex, plaintext: 'hello nip04' });
491 const encR04 = await sendAsync('nip04.encrypt', encP04);
492 const ct04 = jsonOk(encR04)?.result;
493 ok('nip04.encrypt returns ciphertext', typeof ct04 === 'string' && ct04.includes('?iv='), encR04);
494
495 if (ct04) {
496 const decP04 = JSON.stringify({ pubkey: peerPKHex, ciphertext: ct04 });
497 const decR04 = await sendAsync('nip04.decrypt', decP04);
498 ok('nip04.decrypt roundtrip', jsonOk(decR04)?.result === 'hello nip04', decR04);
499 }
500
501 // 7. getSharedSecret → 64-char hex
502 const gss = await send('getSharedSecret', JSON.stringify({ pubkey: peerPKHex }));
503 ok('getSharedSecret returns hex', jsonOk(gss)?.result?.length === 64, gss);
504
505 // 8. addIdentity (generate fresh key)
506 const addR = await send('smesh.addIdentity', JSON.stringify({ name: 'test2' }));
507 const newPK = jsonOk(addR)?.result;
508 ok('addIdentity returns pubkey', typeof newPK === 'string' && newPK.length === 64, addR);
509
510 // 9. listIdentities now has 2
511 const list2 = await send('smesh.listIdentities');
512 const ids2 = jsonOk(list2)?.result ?? [];
513 ok('listIdentities has 2 after addIdentity', ids2.length === 2, list2);
514
515 // 10. switchIdentity
516 if (newPK) {
517 const sw = await send('smesh.switchIdentity', JSON.stringify({ pubkey: newPK }));
518 ok('switchIdentity → true', jsonOk(sw)?.result === true, sw);
519 const pk2 = await send('getPublicKey');
520 ok('getPublicKey after switch = new identity', jsonOk(pk2)?.result === newPK, pk2);
521 }
522
523 // 11. removeIdentity
524 if (newPK) {
525 const rm = await send('smesh.removeIdentity', JSON.stringify({ pubkey: newPK }));
526 ok('removeIdentity → true', jsonOk(rm)?.result === true, rm);
527 const list3 = await send('smesh.listIdentities');
528 const ids3 = jsonOk(list3)?.result ?? [];
529 ok('listIdentities back to 1 after remove', ids3.length === 1, list3);
530 }
531
532 // 12. lockVault → locked
533 const lk = await send('smesh.lockVault');
534 ok('lockVault → true', jsonOk(lk)?.result === true, lk);
535 const vsLocked = await send('smesh.getVaultStatus');
536 ok('getVaultStatus is locked after lock', jsonOk(vsLocked)?.result === 'locked', vsLocked);
537
538 // 13. getPublicKey after lock → error
539 const pkLocked = await send('getPublicKey');
540 ok('getPublicKey locked → error', jsonOk(pkLocked)?.error != null, pkLocked);
541
542 // 14. signEvent after lock → error
543 const signLocked = await sendAsync('signEvent', evIn);
544 ok('signEvent locked → error', jsonOk(signLocked)?.error != null, signLocked);
545 }
546
547 async function runNWCTests(wasmPath) {
548 console.log('\n── NWC (stub paths) ──');
549
550 storage.clear();
551 _onMessage = null;
552 await boot(wasmPath);
553
554 // nwcList empty
555 const l = await send('smesh.nwc.list');
556 ok('nwcList empty on fresh boot', JSON.stringify(jsonOk(l)?.result) === '[]', l);
557
558 // nwcAdd with missing uri → error
559 const addBad = await send('smesh.nwc.add', JSON.stringify({ alias: 'test' }));
560 ok('nwcAdd missing uri → error', jsonOk(addBad)?.error != null, addBad);
561
562 // nwcAdd with invalid uri (parseNWCURI stub returns empty strings) → error
563 const addInvalid = await send('smesh.nwc.add', JSON.stringify({ uri: 'nostr+walletconnect://bad', alias: 'test' }));
564 ok('nwcAdd invalid uri → error', jsonOk(addInvalid)?.error != null, addInvalid);
565 }
566
567 // --------------------------------------------------------------------------
568 // Bech32 encoder (for nsec/npub test vectors — no external deps)
569 // --------------------------------------------------------------------------
570
571 const B32CS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
572 const B32GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
573
574 function b32Polymod(v) {
575 let c = 1;
576 for (const x of v) {
577 const t = c >> 25;
578 c = ((c & 0x1ffffff) << 5) ^ x;
579 for (let i = 0; i < 5; i++) if ((t >> i) & 1) c ^= B32GEN[i];
580 }
581 return c;
582 }
583 function b32Expand(hrp) {
584 const r = [];
585 for (const c of hrp) r.push(c.charCodeAt(0) >> 5);
586 r.push(0);
587 for (const c of hrp) r.push(c.charCodeAt(0) & 31);
588 return r;
589 }
590 function b32Bits(data, from, to) {
591 let acc = 0, bits = 0;
592 const out = [], maxv = (1 << to) - 1;
593 for (const v of data) {
594 acc = (acc << from) | v; bits += from;
595 while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); }
596 }
597 if (bits) out.push((acc << (to - bits)) & maxv);
598 return out;
599 }
600 function b32Encode(hrp, data5) {
601 const pay = [...b32Expand(hrp), ...data5, 0, 0, 0, 0, 0, 0];
602 const mod = b32Polymod(pay) ^ 1;
603 const ck = Array.from({length: 6}, (_, i) => (mod >> (5 * (5 - i))) & 31);
604 return hrp + '1' + [...data5, ...ck].map(x => B32CS[x]).join('');
605 }
606 function nsecEncode(sk) { return b32Encode('nsec', b32Bits([...sk], 8, 5)); }
607
608 // Standard BIP-39 word list subset used for known-vector tests.
609 // Index 0='abandon', 1='ability', 2='able', 3='about', 2047='zoo'
610 // Full list lives in wordlist.mx; here we only need the boundary words.
611 const BIP39_KNOWN_GOOD = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
612 const BIP39_BAD_CHECKSUM = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zoo';
613 const BIP39_UNKNOWN_WORD = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon xyzzy';
614 const BIP39_WRONG_LENGTH = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon';
615
616 // NIP-06 BIP-32 derivation reference mnemonic.
617 const NIP06_MNEMONIC = 'leader monkey parrot ring guide accident before fence cannon height naive bean';
618
619 // NWC URI fixture: walletPK = BIP-340 TV_PK, secret = BIP-340 TV_SK (reuse test keys).
620 const NWC_WALLET_PK = TV_PK;
621 const NWC_SECRET_HEX = TV_SK;
622 const NWC_RELAY = 'wss://relay.example.com';
623 const NWC_URI = `nostr+walletconnect://${NWC_WALLET_PK}?relay=${NWC_RELAY}&secret=${NWC_SECRET_HEX}`;
624
625 // --------------------------------------------------------------------------
626 // BIP-39 encoding and validation vectors
627 // --------------------------------------------------------------------------
628
629 async function runBip39Tests(wasmPath) {
630 console.log('\n── BIP-39 encoding + validation ──');
631
632 storage.clear(); _onMessage = null;
633 await boot(wasmPath);
634
635 // Known-good mnemonic validates → true.
636 const r1 = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: BIP39_KNOWN_GOOD }));
637 ok('validateMnemonic known-good BIP-39 vector → true', jsonOk(r1)?.result === true, r1);
638
639 // Wrong checksum word (zoo has index 2047, correct is about=3) → false.
640 // STUB: current impl checks non-empty only; fails until checksum is implemented.
641 const r2 = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: BIP39_BAD_CHECKSUM }));
642 ok('validateMnemonic wrong checksum → false', jsonOk(r2)?.result === false, r2);
643
644 // Word not in BIP-39 list → false.
645 // STUB: same — fails until word-lookup is implemented.
646 const r3 = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: BIP39_UNKNOWN_WORD }));
647 ok('validateMnemonic unknown word → false', jsonOk(r3)?.result === false, r3);
648
649 // 11 words (too short) → false.
650 // STUB: fails until word-count check is implemented.
651 const r4 = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic: BIP39_WRONG_LENGTH }));
652 ok('validateMnemonic 11 words → false', jsonOk(r4)?.result === false, r4);
653
654 // generateMnemonic → validateMnemonic roundtrip (3 samples).
655 // When validateMnemonic implements real BIP-39, this proves entropyToMnemonic
656 // produces well-formed output with correct checksum every time.
657 for (let i = 0; i < 3; i++) {
658 const mn = await sendAsync('smesh.generateMnemonic');
659 const mnemonic = jsonOk(mn)?.result ?? '';
660 const vr = await send('smesh.validateMnemonic', JSON.stringify({ mnemonic }));
661 ok(`generateMnemonic→validateMnemonic roundtrip #${i + 1}`, jsonOk(vr)?.result === true, vr);
662 }
663
664 // All 12 generated words must be unique from '' (non-degenerate output).
665 const mn = await sendAsync('smesh.generateMnemonic');
666 const words = (jsonOk(mn)?.result ?? '').trim().split(/\s+/);
667 ok('generateMnemonic exactly 12 words', words.length === 12, String(words.length));
668 ok('generateMnemonic no empty words', words.every(w => w.length > 0), words.join(' '));
669 }
670
671 // --------------------------------------------------------------------------
672 // Vault create / lock / unlock lifecycle
673 // --------------------------------------------------------------------------
674
675 async function runVaultLifecycleTests(wasmPath) {
676 console.log('\n── Vault create / lock / unlock ──');
677
678 storage.clear(); _onMessage = null;
679 await boot(wasmPath);
680
681 // Missing password → error immediately (sync path).
682 const noPass = await send('smesh.createVault', '{}');
683 ok('createVault missing password → error', jsonOk(noPass)?.error != null, noPass);
684
685 // createVault with password → unlocked.
686 // Async path: sha256_hex + argon2id_derive_key + storage write.
687 const cv = await sendAsync('smesh.createVault', JSON.stringify({ password: 'correct-password' }));
688 ok('createVault → true', jsonOk(cv)?.result === true, cv);
689
690 const vs1 = await send('smesh.getVaultStatus');
691 ok('getVaultStatus after createVault → unlocked', jsonOk(vs1)?.result === 'unlocked', vs1);
692
693 // Storage must contain a vault record after create.
694 const storedJSON = storage.get('smesh-vault') ?? '';
695 ok('storage has vault JSON after createVault', storedJSON.length > 2, storedJSON.slice(0, 40));
696
697 // Vault JSON must have version field.
698 const storedObj = JSON.parse(storedJSON.length > 2 ? storedJSON : '{}');
699 ok('vault JSON has version', storedObj.version != null, storedJSON.slice(0, 80));
700
701 // v3 vault: per-field IVs embedded in each identity's seckey ciphertext (base64
702 // of iv[12] || ct || tag[16]). No top-level `iv` field. Top-level required
703 // fields: vaultHash (sha256 of password, 64-char hex), salt (base64 32-byte
704 // Argon2id salt), and version (3).
705 ok('vault JSON v3 (no top-level iv)', storedObj.iv === undefined, storedJSON.slice(0, 120));
706 ok('vault JSON has vaultHash', typeof storedObj.vaultHash === 'string' && storedObj.vaultHash.length === 64, storedJSON.slice(0, 80));
707 ok('vault JSON has salt', typeof storedObj.salt === 'string' && storedObj.salt.length > 0, storedJSON.slice(0, 80));
708 ok('vault JSON version is 3', storedObj.version === 3, storedJSON.slice(0, 80));
709
710 // Add an identity while unlocked, then lock.
711 const addR = await send('smesh.addIdentity', JSON.stringify({ name: 'main' }));
712 const newPK = jsonOk(addR)?.result;
713 ok('addIdentity while unlocked → pubkey', typeof newPK === 'string' && newPK.length === 64, addR);
714
715 const lk = await send('smesh.lockVault');
716 ok('lockVault → true', jsonOk(lk)?.result === true, lk);
717
718 const vs2 = await send('smesh.getVaultStatus');
719 ok('getVaultStatus after lock → locked', jsonOk(vs2)?.result === 'locked', vs2);
720
721 // Wrong password → false.
722 // Async path: sha256_hex fires, hash check fails, done(false).
723 // BUG: vaultRawCache is "" (not updated by saveVault) → done(false) before hash check.
724 // Correct behaviour after fix: hash mismatch → false.
725 const wrongPW = await sendAsync('smesh.unlockVault', JSON.stringify({ password: 'wrong-password' }));
726 ok('unlockVault wrong password → false', jsonOk(wrongPW)?.result === false, wrongPW);
727
728 // Correct password → true and vault re-opens.
729 // BUG: vaultRawCache is "" → done(false) immediately; fails until saveVault updates vaultRawCache.
730 const correctPW = await sendAsync('smesh.unlockVault', JSON.stringify({ password: 'correct-password' }));
731 ok('unlockVault correct password → true', jsonOk(correctPW)?.result === true, correctPW);
732
733 if (jsonOk(correctPW)?.result === true) {
734 const vs3 = await send('smesh.getVaultStatus');
735 ok('getVaultStatus after unlock → unlocked', jsonOk(vs3)?.result === 'unlocked', vs3);
736
737 // Identity must survive the lock/unlock cycle.
738 const list = await send('smesh.listIdentities');
739 const ids = jsonOk(list)?.result ?? [];
740 ok('identity persists through lock/unlock', ids.some(i => i.pubkey === newPK), list);
741 }
742 }
743
744 // --------------------------------------------------------------------------
745 // nsecLogin — bech32 nsec decode → v0 vault
746 // --------------------------------------------------------------------------
747
748 async function runNsecLoginTests(wasmPath) {
749 console.log('\n── nsecLogin ──');
750
751 storage.clear(); _onMessage = null;
752 await boot(wasmPath);
753
754 // Missing nsec param → error (sync path, implemented).
755 const noNsec = await send('smesh.nsecLogin', '{}');
756 ok('nsecLogin missing nsec → error', jsonOk(noNsec)?.error != null, noNsec);
757
758 // Invalid format (not bech32 nsec) → error.
759 // STUB: current impl returns false for any non-empty nsec; fails until decode.
760 const badNsec = await send('smesh.nsecLogin', JSON.stringify({ nsec: 'not-a-bech32-string' }));
761 ok('nsecLogin invalid format → error', jsonOk(badNsec)?.error != null, badNsec);
762
763 // Valid bech32 nsec → vault unlocked, pubkey matches.
764 const tvNsec = nsecEncode(hexToBytes(TV_SK));
765 const loginR = await send('smesh.nsecLogin', JSON.stringify({ nsec: tvNsec }));
766 // STUB: returns false until bech32 decode is implemented.
767 ok('nsecLogin valid nsec → result true', jsonOk(loginR)?.result === true, loginR);
768
769 if (jsonOk(loginR)?.result === true) {
770 const vs = await send('smesh.getVaultStatus');
771 ok('nsecLogin → vault unlocked', jsonOk(vs)?.result === 'unlocked', vs);
772
773 const pk = await send('getPublicKey');
774 ok('nsecLogin → pubkey matches TV_PK', jsonOk(pk)?.result === TV_PK, pk);
775 }
776 }
777
778 // --------------------------------------------------------------------------
779 // getMnemonic / isHD
780 // --------------------------------------------------------------------------
781
782 async function runGetMnemonicTests(wasmPath) {
783 console.log('\n── getMnemonic / isHD ──');
784
785 // Case 1: regular (non-HD) vault → isHD=false, getMnemonic="".
786 storage.clear(); _onMessage = null;
787 await boot(wasmPath, { 'smesh-vault': FIXTURE_VAULT });
788
789 const isHD1 = await send('smesh.isHD');
790 ok('isHD after v0 vault → false', jsonOk(isHD1)?.result === false, isHD1);
791
792 const gm1 = await send('smesh.getMnemonic');
793 ok('getMnemonic no HD vault → empty string', jsonOk(gm1)?.result === '', gm1);
794
795 // Case 2: HD vault → isHD=true, getMnemonic returns the mnemonic.
796 // Requires createHDVault to succeed (async PBKDF2+HMAC chain + createVault).
797 storage.clear(); _onMessage = null;
798 await boot(wasmPath);
799
800 const phrase = NIP06_MNEMONIC;
801 const hdR = await sendAsync('smesh.createHDVault', JSON.stringify({ password: 'test', mnemonic: phrase }));
802 // STUB: createHDVault calls createVault which works, but mnemonicToSeed needs PBKDF2SHA512.
803 ok('createHDVault → true', jsonOk(hdR)?.result === true, hdR);
804
805 if (jsonOk(hdR)?.result === true) {
806 const isHD2 = await send('smesh.isHD');
807 ok('isHD after createHDVault → true', jsonOk(isHD2)?.result === true, isHD2);
808
809 const gm2 = await send('smesh.getMnemonic');
810 ok('getMnemonic after HD vault → returns mnemonic', jsonOk(gm2)?.result === phrase, gm2);
811 }
812 }
813
814 // --------------------------------------------------------------------------
815 // HD key derivation — NIP-06 determinism and cross-account divergence
816 // --------------------------------------------------------------------------
817
818 async function runHDDerivationTests(wasmPath) {
819 console.log('\n── HD key derivation ──');
820
821 storage.clear(); _onMessage = null;
822 await boot(wasmPath);
823
824 // Create HD vault from NIP-06 reference mnemonic.
825 const hdR = await sendAsync('smesh.createHDVault', JSON.stringify({ password: 'pw', mnemonic: NIP06_MNEMONIC }));
826 // STUB: fails if createHDVault/mnemonicToSeed not fully implemented.
827 if (jsonOk(hdR)?.result !== true) {
828 ok('createHDVault for NIP-06 test (skipped — stub)', false, hdR);
829 return;
830 }
831
832 // Account 0 pubkey must be valid 64-char hex.
833 const list = await send('smesh.listIdentities');
834 const ids = jsonOk(list)?.result ?? [];
835 ok('HD account 0 in identities', ids.length >= 1, list);
836 const acc0PK = ids[0]?.pubkey ?? '';
837 ok('HD account 0 pubkey is 64-char hex', acc0PK.length === 64 && /^[0-9a-f]+$/.test(acc0PK), acc0PK);
838
839 // NIP-06 reference vector (https://github.com/nostr-protocol/nips/blob/master/06.md).
840 // Mnemonic: "leader monkey parrot ring guide accident before fence cannon height naive bean"
841 // Expected x-only pubkey at m/44'/1237'/0'/0/0 = 17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917.
842 // This validates the full HD chain end-to-end: BIP-39 mnemonic→seed (PBKDF2-SHA512),
843 // BIP-32 path derivation (HMAC-SHA512 + scalar add mod n), and BIP-340 PubKeyFromSecKey.
844 const NIP06_EXPECTED_PK = '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917';
845 ok('HD account 0 matches NIP-06 reference pubkey', acc0PK === NIP06_EXPECTED_PK, `got ${acc0PK} want ${NIP06_EXPECTED_PK}`);
846
847 // Derive account 1 — must differ from account 0.
848 const d1 = await sendAsync('smesh.deriveIdentity', JSON.stringify({ account: 1 }));
849 // STUB: fails until deriveIdentity is implemented.
850 ok('deriveIdentity account 1 → pubkey', typeof jsonOk(d1)?.result === 'string' && jsonOk(d1).result.length === 64, d1);
851 ok('account 1 differs from account 0', jsonOk(d1)?.result !== acc0PK, d1);
852
853 // Derive account 2 — must differ from both.
854 const d2 = await sendAsync('smesh.deriveIdentity', JSON.stringify({ account: 2 }));
855 ok('deriveIdentity account 2 → pubkey', typeof jsonOk(d2)?.result === 'string' && jsonOk(d2).result.length === 64, d2);
856 ok('account 2 differs from account 1', jsonOk(d2)?.result !== jsonOk(d1)?.result, d2);
857
858 // Determinism: re-create HD vault from same mnemonic → same account 0 pubkey.
859 storage.clear(); _onMessage = null;
860 await boot(wasmPath);
861 const hdR2 = await sendAsync('smesh.createHDVault', JSON.stringify({ password: 'pw', mnemonic: NIP06_MNEMONIC }));
862 if (jsonOk(hdR2)?.result === true) {
863 const list2 = await send('smesh.listIdentities');
864 const ids2 = jsonOk(list2)?.result ?? [];
865 ok('HD derivation is deterministic', ids2[0]?.pubkey === acc0PK, ids2[0]?.pubkey ?? 'no ids');
866 }
867 }
868
869 // --------------------------------------------------------------------------
870 // NWC full roundtrip — add → list → remove → buildRequest → parseResponse
871 // --------------------------------------------------------------------------
872
873 async function runNWCRoundtripTests(wasmPath) {
874 console.log('\n── NWC URI roundtrip ──');
875
876 storage.clear(); _onMessage = null;
877 await boot(wasmPath, { 'smesh-vault': FIXTURE_VAULT });
878
879 // Add with valid NWC URI.
880 // STUB: parseNWCURI returns "", so fails until URI parsing is implemented.
881 const addR = await send('smesh.nwc.add', JSON.stringify({ uri: NWC_URI, alias: 'test-wallet' }));
882 ok('nwcAdd valid URI → true', jsonOk(addR)?.result === true, addR);
883
884 if (jsonOk(addR)?.result !== true) {
885 // Skip remaining NWC tests — they all depend on add succeeding.
886 for (const t of ['nwcList after add', 'nwcRemove', 'nwcBuildRequest', 'nwcParseResponse roundtrip'])
887 ok(t + ' (skipped — nwcAdd stub)', false, 'parseNWCURI not implemented');
888 return;
889 }
890
891 // List → contains the added connection.
892 const list = await send('smesh.nwc.list');
893 const conns = jsonOk(list)?.result ?? [];
894 const found = conns.some(c => c.walletPK === NWC_WALLET_PK && c.alias === 'test-wallet');
895 ok('nwcList after add → connection present', found, list);
896
897 // buildRequest for pay_invoice.
898 const breq = await send('smesh.nwc.buildRequest', JSON.stringify({
899 walletPK: NWC_WALLET_PK,
900 method: 'pay_invoice',
901 params: JSON.stringify({ invoice: 'lnbc1pvjluezpp5...' }),
902 }));
903 const breqObj = jsonOk(breq);
904 ok('nwcBuildRequest → signed event', breqObj?.result?.kind === 23194, breq);
905 ok('nwcBuildRequest event has pubkey', typeof breqObj?.result?.pubkey === 'string', breq);
906 ok('nwcBuildRequest event has sig', breqObj?.result?.sig?.length === 128, breq);
907 ok('nwcBuildRequest event content is NIP-44', typeof breqObj?.result?.content === 'string', breq);
908 ok('nwcBuildRequest reqID in response', typeof breqObj?.reqID === 'string', breq);
909
910 // parseResponse: encrypt a fake response and decrypt it back.
911 // Build a fake NIP-47 response encrypted to the connection's keys.
912 // We need to encrypt FROM walletPK TO our connection's pubkey.
913 // For this test we use the bridge's secp/nip44 directly.
914 if (breqObj?.result?.pubkey) {
915 const ourPKHex = breqObj.result.pubkey;
916 // Encrypt a fake response using our (test) wallet seckey → our connection pubkey.
917 // The NWC flow: wallet encrypts response to our connection's pubkey using wallet seckey.
918 // Our connection's pubkey = PubKey from NWC_SECRET_HEX.
919 const ourPKBytes = fromSlice(secp.PubKeyFromSecKey(hexToBytes(NWC_SECRET_HEX))[0]);
920 // Compute conversation key: walletSK ecdh ourPubKey (from connection).
921 // For test: walletSK = TV_SK (same as NWC_WALLET_PK secret), ourPK = derived from NWC_SECRET_HEX.
922 // Actually NWC_WALLET_PK = TV_PK, so the wallet seckey must be TV_SK.
923 const walletSKBytes = hexToBytes(TV_SK);
924 const [sharedWallet] = secp.ECDH(walletSKBytes, ourPKBytes);
925 // Use nip44.ConversationKey equivalent: HKDF-extract with "nip44-v2".
926 // Since we don't have HKDF in the test harness, we test with raw ECDH.
927 // Instead, just verify parseResponse returns an error for wrong-format content.
928 const fakeContent = 'not-valid-nip44-content';
929 const pr = await send('smesh.nwc.parseResponse', JSON.stringify({
930 walletPK: NWC_WALLET_PK,
931 content: fakeContent,
932 }));
933 ok('nwcParseResponse bad content → error', jsonOk(pr)?.error != null, pr);
934 }
935
936 // Remove the connection.
937 const rm = await send('smesh.nwc.remove', JSON.stringify({ walletPK: NWC_WALLET_PK }));
938 ok('nwcRemove → true', jsonOk(rm)?.result === true, rm);
939
940 const list2 = await send('smesh.nwc.list');
941 ok('nwcList after remove → empty', JSON.stringify(jsonOk(list2)?.result) === '[]', list2);
942 }
943
944 // --------------------------------------------------------------------------
945 // Permission edge cases — duplicate upsert, missing params
946 // --------------------------------------------------------------------------
947
948 async function runPermissionEdgeTests(wasmPath) {
949 console.log('\n── Permission edge cases ──');
950
951 storage.clear(); _onMessage = null;
952 await boot(wasmPath);
953
954 // Missing host param → error.
955 const bad1 = await send('smesh.setPermission', JSON.stringify({ method: 'signEvent', policy: 'allow' }));
956 ok('setPermission missing host → error', jsonOk(bad1)?.error != null, bad1);
957
958 // Missing method param → error.
959 const bad2 = await send('smesh.setPermission', JSON.stringify({ host: 'a.com', policy: 'allow' }));
960 ok('setPermission missing method → error', jsonOk(bad2)?.error != null, bad2);
961
962 // Missing policy param → error.
963 const bad3 = await send('smesh.setPermission', JSON.stringify({ host: 'a.com', method: 'signEvent' }));
964 ok('setPermission missing policy → error', jsonOk(bad3)?.error != null, bad3);
965
966 // Set then update (upsert) — second set wins.
967 await send('smesh.setPermission', JSON.stringify({ host: 'b.com', method: 'signEvent', policy: 'allow' }));
968 await send('smesh.setPermission', JSON.stringify({ host: 'b.com', method: 'signEvent', policy: 'deny' }));
969 const gp = await send('smesh.getPermissions');
970 const perms = jsonOk(gp)?.result ?? [];
971 const matching = perms.filter(p => p.host === 'b.com' && p.method === 'signEvent');
972 ok('setPermission upsert: only one entry per host+method', matching.length === 1, JSON.stringify(matching));
973 ok('setPermission upsert: latest policy wins', matching[0]?.policy === 'deny', JSON.stringify(matching[0]));
974
975 // Multiple different methods for same host.
976 await send('smesh.setPermission', JSON.stringify({ host: 'c.com', method: 'signEvent', policy: 'allow' }));
977 await send('smesh.setPermission', JSON.stringify({ host: 'c.com', method: 'nip04.encrypt', policy: 'deny' }));
978 const gp2 = await send('smesh.getPermissions');
979 const perms2 = jsonOk(gp2)?.result ?? [];
980 const cPerms = perms2.filter(p => p.host === 'c.com');
981 ok('setPermission two methods for same host → two entries', cPerms.length === 2, JSON.stringify(cPerms));
982
983 // Permissions persist to storage (ext_storage_set is called synchronously).
984 const stored = storage.get('smesh-permissions') ?? '';
985 ok('permissions written to storage', stored.length > 2, stored.slice(0, 40));
986 // Roundtrip: parse stored JSON and verify entries.
987 let storedPerms = [];
988 try { storedPerms = JSON.parse(stored); } catch (_) {}
989 ok('stored permissions parse as array', Array.isArray(storedPerms), stored.slice(0, 80));
990
991 // resetExtension clears permissions from storage.
992 await send('smesh.resetExtension');
993 const afterReset = storage.get('smesh-permissions') ?? '';
994 ok('resetExtension clears permission storage', afterReset === '' || afterReset === '[]', afterReset);
995 }
996
997 // --------------------------------------------------------------------------
998 // encryptField / decryptField — via vault serialization
999 // --------------------------------------------------------------------------
1000
1001 async function runFieldEncryptionTests(wasmPath) {
1002 console.log('\n── Vault field encryption ──');
1003
1004 storage.clear(); _onMessage = null;
1005 await boot(wasmPath);
1006
1007 // Create vault so encryptField has a key to work with.
1008 const cv = await sendAsync('smesh.createVault', JSON.stringify({ password: 'testpw' }));
1009 if (jsonOk(cv)?.result !== true) {
1010 ok('createVault for field encryption test (skipped)', false, cv);
1011 return;
1012 }
1013
1014 // Add two identities — their seckeys are field-encrypted in the vault.
1015 const a1 = await send('smesh.addIdentity', JSON.stringify({ name: 'alice' }));
1016 const a2 = await send('smesh.addIdentity', JSON.stringify({ name: 'bob' }));
1017 ok('addIdentity alice → pubkey', typeof jsonOk(a1)?.result === 'string', a1);
1018 ok('addIdentity bob → pubkey', typeof jsonOk(a2)?.result === 'string', a2);
1019
1020 // Vault storage JSON should reference the encrypted identities.
1021 // STUB: saveVault stores '{}' until serialization is implemented.
1022 const vaultJSON = storage.get('smesh-vault') ?? '';
1023 ok('vault JSON contains identities key after addIdentity', vaultJSON.includes('identities'), vaultJSON.slice(0, 80));
1024
1025 // Lock then sign — should fail (key zeroed).
1026 await send('smesh.lockVault');
1027 const evIn = JSON.stringify({ event: { kind: 1, content: 'test', created_at: 1700000000, tags: [] } });
1028 const locked = await sendAsync('signEvent', evIn);
1029 ok('signEvent locked → error', jsonOk(locked)?.error != null, locked);
1030
1031 // Unlock with correct password → sign works again.
1032 // STUB: unlockVault fails until vaultRawCache is updated by saveVault.
1033 const ul = await sendAsync('smesh.unlockVault', JSON.stringify({ password: 'testpw' }));
1034 ok('unlockVault after createVault + addIdentity → true', jsonOk(ul)?.result === true, ul);
1035 if (jsonOk(ul)?.result === true) {
1036 const signed = await sendAsync('signEvent', evIn);
1037 ok('signEvent after unlock → signed', jsonOk(signed)?.result?.sig?.length === 128, signed);
1038 }
1039 }
1040
1041 // --------------------------------------------------------------------------
1042 // Entry
1043 // --------------------------------------------------------------------------
1044
1045 const wasmPath = process.argv[2] ?? join(__dirname, '../../ext/bg/signer.wasm');
1046 console.log(`signer: ${wasmPath}`);
1047
1048 await runSecp256k1BridgeTests();
1049 await runFreshTests(wasmPath);
1050 await runPreseededTests(wasmPath);
1051 await runNWCTests(wasmPath);
1052 await runBip39Tests(wasmPath);
1053 await runVaultLifecycleTests(wasmPath);
1054 await runNsecLoginTests(wasmPath);
1055 await runGetMnemonicTests(wasmPath);
1056 await runHDDerivationTests(wasmPath);
1057 await runNWCRoundtripTests(wasmPath);
1058 await runPermissionEdgeTests(wasmPath);
1059 await runFieldEncryptionTests(wasmPath);
1060
1061 console.log(`\n${_pass + _fail} tests: ${_pass} passed, ${_fail} failed`);
1062 process.exit(_fail > 0 ? 1 : 0);
1063