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