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