run.mjs raw

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