diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 11:27:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 11:27:46 -0700 |
| commit | 4c1f05a3f287f3b5df1319008081cf6df7550647 (patch) | |
| tree | c2f0d600675ea451d147e9519460daeccdaf55f6 | |
| parent | ca76accb5ccbe0ffcd68c97da2e9609f923df8f1 (diff) | |
| download | coinosv3-4c1f05a3f287f3b5df1319008081cf6df7550647.tar.gz coinosv3-4c1f05a3f287f3b5df1319008081cf6df7550647.zip | |
Add the NWC pouch: a bounded spending capability for background use
Step 2 of background wallet-connect. Answering while hal is closed means
signing in a service worker, and service workers cannot read localStorage
where the seed lives — which is a property worth keeping, not working around.
So instead of exposing the wallet, carve off a small pouch:
- its own key branch (account chain 6), never used for ordinary coins, so a
leaked pouch key cannot sign for chain-3 vtxos, the mailbox or LN preimages
- only the pouch's private keys and its vtxo bytes go to IndexedDB, which the
service worker can read. The seed never does.
- it holds only what the user puts in it
Threat model, stated in the file rather than implied: same-origin script
access or a stolen unlocked device can read IndexedDB and spend the pouch.
That is the accepted cost of answering while closed, and it is bounded — the
pouch is the blast radius. A user who leaves background NWC off has no pouch.
tools/nwc-pouch-test.js asserts the isolation that matters: the pouch branch
is distinct from every other branch, exported keys match their pouch pubkeys
and cannot control ordinary coins, and no seed material appears in the blob.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/nwc-pouch.js | 124 | ||||
| -rw-r--r-- | tools/nwc-pouch-test.js | 48 |
2 files changed, 172 insertions, 0 deletions
diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js new file mode 100644 index 0000000..0d4fcbc --- /dev/null +++ b/src/nwc-pouch.js @@ -0,0 +1,124 @@ +// The NWC "pouch": a small, bounded spending capability that a service +// worker can use while the wallet itself stays out of reach. +// +// WHY THIS EXISTS +// hal keeps the seed in localStorage, which service workers cannot read — by +// design, and we are not changing that. But answering a wallet-connect +// request while the app is closed means signing in a service worker. So +// instead of exposing the wallet, we carve off a deliberately small pouch: +// +// * its own key branch (account chain 6), never used for ordinary coins +// * only the pouch's PRIVATE KEYS and vtxo bytes go into IndexedDB, which +// the service worker can read — never the seed, never chain 3 +// * it holds only what the user puts in it +// +// THREAT MODEL, stated plainly. Anything with same-origin script access, or +// a stolen unlocked device, can read IndexedDB and therefore spend the pouch. +// That is the accepted cost of answering while closed. It is bounded: the +// pouch is the blast radius, the rest of the wallet is not reachable from it. +// A user who does not enable background NWC has no pouch and loses nothing. +// +// This module owns the storage and key derivation only. Funding it is an +// ordinary arkoor send from the main wallet (see the ark feature); spending +// from it lives in the service worker (step 3). + +import { hex } from '@scure/base'; +import { secp256k1 } from '@noble/curves/secp256k1'; + +const DB = 'hal-nwc-pouch'; +const STORE = 'pouch'; +const VERSION = 1; + +// Chain 6 is the pouch. Chains 0-2 are hal's on-chain use, 3 ark vtxo keys, +// 4 the ark mailbox, 5 lightning-receive preimages — 6 must stay distinct so +// a leaked pouch key can never sign for an ordinary coin. +export const POUCH_CHAIN = 6; + +export function pouchKey(account, index) { + const node = account.deriveChild(POUCH_CHAIN).deriveChild(index); + return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; +} + +function openDb() { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); + const req = indexedDB.open(DB, VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function tx(mode, fn) { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const t = db.transaction(STORE, mode); + const s = t.objectStore(STORE); + let out; + Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject); + t.oncomplete = () => resolve(out); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }); + } finally { db.close(); } +} +const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); + +// One record per wallet, keyed by the wallet's cache key so several wallets +// on one device keep separate pouches. +export async function loadPouch(walletKey) { + try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; } +} +export async function savePouch(walletKey, pouch) { + return tx('readwrite', (s) => { s.put(pouch, walletKey); }); +} +export async function clearPouch(walletKey) { + return tx('readwrite', (s) => { s.delete(walletKey); }); +} + +// Everything the service worker needs to spend, and nothing more: the ASP +// endpoints, the pouch vtxos with their bytes, and the private keys for +// exactly those vtxos. +export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, vtxos, account, connections }) { + const keys = {}; + for (const v of vtxos) { + const k = pouchKey(account, v.keyIndex); + keys[String(v.keyIndex)] = hex.encode(k.privkey); + } + return { + v: 1, + updated: Date.now(), + ark: { arkUrl, esploraUrl, network, serverPubkey }, + // [{ id, bytes, keyIndex, amountSat, expiryHeight }] + vtxos: vtxos.map((v) => ({ + id: v.id, bytes: v.bytes, keyIndex: v.keyIndex, + amountSat: v.amountSat, expiryHeight: v.expiryHeight, + })), + keys, + // service keys for the connections the SW may answer for, so it can + // decrypt requests and sign replies without the wallet + connections: (connections || []).map((c) => ({ + id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, + maxSat: c.maxSat, dailySat: c.dailySat, + })), + }; +} + +export const pouchBalance = (pouch) => + (pouch?.vtxos || []).reduce((n, v) => n + (v.amountSat || 0), 0); + +// The service worker records what it spent so the app can reconcile and the +// daily budget survives a restart. +export async function notePouchSpend(walletKey, { connId, amountSat, paymentHash, preimage }) { + const p = await loadPouch(walletKey); + if (!p) return null; + p.spends = p.spends || []; + p.spends.push({ connId, amountSat, paymentHash, preimage, ts: Date.now() }); + if (p.spends.length > 200) p.spends = p.spends.slice(-200); + await savePouch(walletKey, p); + return p; +} diff --git a/tools/nwc-pouch-test.js b/tools/nwc-pouch-test.js new file mode 100644 index 0000000..090f9b1 --- /dev/null +++ b/tools/nwc-pouch-test.js @@ -0,0 +1,48 @@ +// Pouch: key isolation + IndexedDB round trip (fake-indexeddb not available, +// so exercise the pure logic and assert the key branch is separate). +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync, generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { hex } from '@scure/base'; +import { pouchKey, buildPouch, pouchBalance, POUCH_CHAIN } from '/home/adam/halwallet/src/nwc-pouch.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; + +let ok = true; const check=(n,c,d='')=>{console.log(` ${c?'✓':'✗'} ${n}${d?' — '+d:''}`); if(!c) ok=false;}; +const account = HDKey.fromMasterSeed(mnemonicToSeedSync(generateMnemonic(wordlist))).derive("m/86'/0'/9'"); + +// the pouch branch must never collide with the branches that hold real coins +const arkKey = (i) => account.deriveChild(3).deriveChild(i).privateKey; +const mailbox = account.deriveChild(4).deriveChild(0).privateKey; +const preimg = account.deriveChild(5).deriveChild(0).privateKey; +check('pouch chain is 6', POUCH_CHAIN === 6); +const p0 = pouchKey(account, 0).privkey; +check('pouch key != ark vtxo key', hex.encode(p0) !== hex.encode(arkKey(0))); +check('pouch key != mailbox key', hex.encode(p0) !== hex.encode(mailbox)); +check('pouch key != ln preimage key', hex.encode(p0) !== hex.encode(preimg)); +check('pouch indices differ', hex.encode(pouchKey(account,0).privkey) !== hex.encode(pouchKey(account,1).privkey)); + +// buildPouch must export exactly the keys for the vtxos it carries — and no seed +const vtxos = [ + { id: 'a:0', bytes: 'deadbeef', keyIndex: 0, amountSat: 5000, expiryHeight: 100 }, + { id: 'b:0', bytes: 'cafebabe', keyIndex: 1, amountSat: 2000, expiryHeight: 100 }, +]; +const pouch = buildPouch({ + arkUrl: 'https://ark.coinos.io', esploraUrl: 'https://mempool.space/api', + network: 'mainnet', serverPubkey: '03ff', vtxos, account, + connections: [{ id:'c1', servicePk:'aa', serviceSk:'bb', clientPk:'cc', maxSat:1000, dailySat:5000 }], +}); +check('pouch carries both vtxos', pouch.vtxos.length === 2); +check('pouch balance sums', pouchBalance(pouch) === 7000, String(pouchBalance(pouch))); +check('one key per vtxo index', Object.keys(pouch.keys).sort().join() === '0,1'); +const blob = JSON.stringify(pouch); +check('NO seed/mnemonic in the pouch', !/mnemonic|seed|xprv/i.test(blob)); +// the exported keys must actually correspond to the pouch pubkeys +const derived = secp256k1.getPublicKey(hex.decode(pouch.keys['0']), true); +check('exported key matches its pouch pubkey', hex.encode(derived) === hex.encode(pouchKey(account,0).pubkey)); +// and must NOT be able to sign for an ordinary ark vtxo +check('pouch key cannot control chain-3 coins', + hex.encode(secp256k1.getPublicKey(hex.decode(pouch.keys['0']), true)) + !== hex.encode(secp256k1.getPublicKey(arkKey(0), true))); +check('connection service keys travel with it', pouch.connections[0].serviceSk === 'bb'); + +console.log(ok ? '\n✅ pouch isolates correctly' : '\n❌ failed'); process.exit(ok?0:1); |
