aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-pouch.js
blob: 0d4fcbc76ccc5854f64e45e267dcd78dd1b52a86 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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;
}