diff options
| -rw-r--r-- | nwcpush/server.js | 24 | ||||
| -rw-r--r-- | src/dm-inbox.js | 115 | ||||
| -rw-r--r-- | src/features/messages.js | 36 | ||||
| -rw-r--r-- | src/sw-nwc.js | 16 | ||||
| -rw-r--r-- | tools/dm-filter-test.js | 101 |
5 files changed, 288 insertions, 4 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js index 5095151..6300732 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -119,7 +119,10 @@ function resubscribeNotify() { } // throttle: chat bursts collapse to one push per device per window -const COOLDOWN = { payment: 10_000, dm: 10_000, chat: 90_000 }; +// DMs are throttled barely at all now: the device drops the ones it decides +// are noise (a stranger, or our own sent-copy), and a server-side cooldown +// would let a discarded push swallow the friend's message that followed it. +const COOLDOWN = { payment: 10_000, dm: 1_000, chat: 90_000 }; const lastPush = new Map(); // endpointId:reason -> ts const seenNotifyEvents = new Map(); // event id -> ts function onNotifyEvent(ev) { @@ -132,11 +135,17 @@ function onNotifyEvent(ev) { const p = ev.tags?.find((t) => t[0] === 'p')?.[1]; const reason = ev.kind === 9735 || ev.kind === 9737 ? 'payment' : p && notifyPtags().includes(p) ? 'dm' : 'chat'; + // A wrapped DM hides its sender from us by design, so we can't decide here + // whether it's from a friend or a stranger — we hand the whole wrap to the + // device, which holds the only key that can open it. Web Push tops out + // around 4KB after encryption; anything larger goes as a bare nudge and the + // device shows its generic notification. + const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {}; for (const [id, r] of Object.entries(notifyRegs)) { const hit = reason === 'chat' ? (r.authors || []).includes(ev.pubkey) : p && (r.ptags || []).includes(p); - if (hit) pushNotify(id, r, reason, {}).catch(() => {}); + if (hit) pushNotify(id, r, reason, extra).catch(() => {}); } } @@ -147,7 +156,16 @@ async function pushNotify(id, r, reason, extra) { lastPush.set(key, Date.now()); const payload = JSON.stringify({ type: 'notify', reason, ...extra }); try { - await webpush.sendNotification(r.sub, payload, { TTL: 3600, urgency: 'normal' }); + try { + await webpush.sendNotification(r.sub, payload, { TTL: 3600, urgency: 'normal' }); + } catch (e) { + // A payload the push service won't take (413, or the library refusing + // the size) must not cost the notification itself — send the bare nudge + // and let the device show its generic message. + if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e; + log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`); + await webpush.sendNotification(r.sub, JSON.stringify({ type: 'notify', reason }), { TTL: 3600, urgency: 'normal' }); + } log(`notify(${reason}) -> device ${id}`); } catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { diff --git a/src/dm-inbox.js b/src/dm-inbox.js new file mode 100644 index 0000000..a91a8e0 --- /dev/null +++ b/src/dm-inbox.js @@ -0,0 +1,115 @@ +// What the service worker needs to tell a friend's DM from a stranger's. +// +// A NIP-17 wrap is signed by a throwaway key and the sender's identity sits +// under two layers of nip44, so no server can filter DMs by who sent them — +// only the recipient's own key can open one. The notifier therefore ships the +// wrap inside the push payload and the decision happens here, on the device. +// +// THREAT MODEL, stated plainly. This record holds the nostr identity secret, +// so anything with same-origin script access or a stolen unlocked device can +// read every DM this account can read. That is not a new exposure for a +// seed-derived identity — localStorage already holds the seed that key comes +// from — but it is a second copy, and it exists only to keep strangers from +// buzzing the phone. A remote signer (bunker/extension) never hands over its +// key: those wallets store no secret here and fall back to a generic +// notification instead of a filtered one. + +import { unwrapDM } from './dm.js'; + +const DB = 'coinos-dm-inbox'; +const STORE = 'inbox'; +const VERSION = 1; + +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(); } +} + +// One record per wallet: { sk, pubkey, follows: [hex], names: { hex: name } }. +// `sk` is null when the identity lives behind a remote signer. +export async function saveInbox(walletKey, rec) { + return tx('readwrite', (s) => { s.put(rec, walletKey); }); +} +export async function clearInbox(walletKey) { + return tx('readwrite', (s) => { s.delete(walletKey); }); +} +export async function allInboxes() { + try { + return await tx('readonly', (s) => new Promise((res, rej) => { + const out = []; + const cur = s.openCursor(); + cur.onsuccess = () => { + const c = cur.result; + if (!c) return res(out); + out.push(c.value); + c.continue(); + }; + cur.onerror = () => rej(cur.error); + })); + } catch { return []; } +} + +const unhex = (h) => Uint8Array.from(h.match(/.{2}/g).map((b) => parseInt(b, 16))); + +// Open a wrap against every identity we hold a key for. Returns +// { author, followed, name, mine } for the first one that opens it, or null +// if none can — which includes every remote-signer wallet, so a null means +// "we don't know", never "it's a stranger". +export async function classifyDm(wrap, inboxes) { + for (const rec of inboxes) { + if (!rec || !rec.sk) continue; + let sk; + try { sk = unhex(rec.sk); } catch { continue; } + const opened = await unwrapDM(wrap, sk); + if (!opened) continue; + const author = opened.author; + return { + author, + mine: author === rec.pubkey, + followed: (rec.follows || []).includes(author), + // Someone you're already talking to is not a stranger, whether or not + // you ever followed them. + known: (rec.known || []).includes(author), + // No contact list published at all: there's nothing to filter against, + // and silence would be worse than noise. + hasList: !!rec.hasList, + name: (rec.names || {})[author] || null, + content: opened.rumor && opened.rumor.kind === 14 ? opened.rumor.content : '', + }; + } + return null; +} + +// Should this DM interrupt someone? Our own sent-copy never should — every +// NIP-17 message is wrapped to the sender too, and being notified about what +// you just typed is pure noise. Anything we couldn't open stays notifiable: +// better a generic buzz than a silent drop. +export function shouldNotifyDm(verdict) { + if (!verdict) return true; + if (verdict.mine) return false; + if (verdict.followed || verdict.known) return true; + return !verdict.hasList; +} diff --git a/src/features/messages.js b/src/features/messages.js index 70886d3..5aa8dfe 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -23,6 +23,7 @@ import { communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDM, unwrapDM, wrapDM } from '../dm.js'; +import { saveInbox } from '../dm-inbox.js'; import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { t } from '../i18n.js'; @@ -607,6 +608,37 @@ export function messagesFeature(ctx) { } } + // Hand the service worker what it needs to tell a friend's DM from a + // stranger's: our key (only if we actually hold one), who we follow, and the + // names we already know. See dm-inbox.js for the threat model — a remote + // signer stores no key here and simply gets unfiltered notifications. + let inboxAt = 0; + async function syncInbox({ force = false } = {}) { + if (!force && Date.now() - inboxAt < 5 * 60_000) return; + inboxAt = Date.now(); + try { + const id = await identity(); + if (!id) return; + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], + { kinds: [3], authors: [id.pubkey] }, 4000); + const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; + const follows = newest + ? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))] + : []; + const known = [...threads.keys()]; + const names = {}; + for (const pk of [...follows, ...known]) { + const p = profiles.get(pk); + if (p && p.name) names[pk] = p.name; + } + await saveInbox(wallet._cacheKey(), { + pubkey: id.pubkey, + sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null, + follows, known, names, hasList: !!newest, updated: Date.now(), + }); + } catch { inboxAt = 0; } + } + async function registerPush({ interactive = false } = {}) { try { if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false; @@ -1205,6 +1237,9 @@ export function messagesFeature(ctx) { function homeView() { startDMs(); + // Threads you've since replied to should stop being strangers to the + // worker; throttled inside, so this is cheap on every render. + syncInbox().catch(() => {}); for (const jm of communities()) ensureRoom(jm); const dmRows = [...threads.entries()] @@ -1639,6 +1674,7 @@ export function messagesFeature(ctx) { // header's unread dot can't report a room nobody is listening to. for (const jm of communities()) { try { ensureRoom(jm); } catch {} } syncLists().catch(() => {}); + syncInbox().catch(() => {}); registerPush().catch(() => {}); // silent refresh when permission already granted }, stop() { diff --git a/src/sw-nwc.js b/src/sw-nwc.js index e887963..edf1f5e 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -6,6 +6,7 @@ import { respondFromBg } from './nwc-respond.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; +import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; @@ -25,9 +26,22 @@ self.addEventListener('push', (e) => { try { await bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }); } catch (err) { console.warn('[sw-aw] failed:', err && err.message); } } + // A DM arrives wrapped, so only this device can see who sent it. Open it + // here: a message from someone we follow gets their name on it, our own + // sent-copy and a stranger's get nothing at all. Anything we can't open + // — a remote-signer wallet, an oversized wrap — falls through to the + // generic notification rather than being dropped silently. + let dmName = null; + if (data.reason === 'dm' && data.wrap) { + try { + const verdict = await classifyDm(data.wrap, await allInboxes()); + if (!shouldNotifyDm(verdict)) return; + if (verdict && verdict.name) dmName = verdict.name; + } catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); } + } const T = { payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats — open coinos to see it.` : 'Open coinos to see it.'], - dm: ['New message', 'You have a new private message.'], + dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'], chat: ['New chat activity', 'There are new messages in your communities.'], }; const [title, body] = T[data.reason] || T.chat; diff --git a/tools/dm-filter-test.js b/tools/dm-filter-test.js new file mode 100644 index 0000000..b17bcd1 --- /dev/null +++ b/tools/dm-filter-test.js @@ -0,0 +1,101 @@ +// Who is allowed to buzz your phone. The wrap hides its sender from every +// server, so this decision is made on the device — and it has to be right in +// both directions: a friend must always get through, a stranger never, and +// anything we can't open must fall back to notifying rather than vanish. +// Run: bun tools/dm-filter-test.js +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from '@noble/hashes/utils'; +import { makeDM } from '../src/dm.js'; +import { classifyDm, shouldNotifyDm } from '../src/dm-inbox.js'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!ok) fails++; +}; + +const meSk = generateSecretKey(); +const me = getPublicKey(meSk); +const friendSk = generateSecretKey(); +const friend = getPublicKey(friendSk); +const strangerSk = generateSecretKey(); +const stranger = getPublicKey(strangerSk); + +const inbox = { pubkey: me, sk: bytesToHex(meSk), follows: [friend], known: [], hasList: true, names: { [friend]: 'Alice' } }; + +console.log('[a friend]'); +{ + const { toPeer } = await makeDM(friendSk, me, 'hey'); + const v = await classifyDm(toPeer, [inbox]); + check('opens the wrap', !!v, v ? v.author.slice(0, 8) : 'null'); + check('names the sender', v && v.author === friend && v.name === 'Alice'); + check('is followed', v && v.followed); + check('notifies', shouldNotifyDm(v)); +} + +console.log('\n[a stranger]'); +{ + const { toPeer } = await makeDM(strangerSk, me, 'buy my coin'); + const v = await classifyDm(toPeer, [inbox]); + check('opens the wrap', !!v); + check('is not followed', v && !v.followed && v.author === stranger); + check('stays silent', !shouldNotifyDm(v)); +} + +console.log('\n[our own sent copy]'); +{ + const { toSelf } = await makeDM(meSk, friend, 'on my way'); + const v = await classifyDm(toSelf, [inbox]); + check('recognises it as ours', v && v.mine); + check('stays silent', !shouldNotifyDm(v)); +} + +console.log('\n[not ours to open]'); +{ + // addressed to someone else entirely + const { toPeer } = await makeDM(strangerSk, friend, 'psst'); + const v = await classifyDm(toPeer, [inbox]); + check('cannot open it', v === null); + check('falls back to notifying rather than dropping', shouldNotifyDm(v)); +} + +console.log('\n[a remote signer holds the key]'); +{ + const { toPeer } = await makeDM(friendSk, me, 'hey'); + const v = await classifyDm(toPeer, [{ pubkey: me, sk: null, follows: [friend], hasList: true }]); + check('no key, no verdict', v === null); + check('notifies anyway', shouldNotifyDm(v)); +} + +console.log('\n[several wallets on one device]'); +{ + const otherSk = generateSecretKey(); + const other = { pubkey: getPublicKey(otherSk), sk: bytesToHex(otherSk), follows: [], hasList: true }; + const { toPeer } = await makeDM(friendSk, me, 'hey'); + const v = await classifyDm(toPeer, [other, inbox]); + check('finds the wallet it was addressed to', v && v.author === friend && v.followed); + check('notifies', shouldNotifyDm(v)); +} + +console.log('\n[someone we are already talking to]'); +{ + const { toPeer } = await makeDM(strangerSk, me, 'as we were saying'); + const v = await classifyDm(toPeer, [{ ...inbox, known: [stranger] }]); + check('an open thread counts as known', v && v.known && !v.followed); + check('notifies', shouldNotifyDm(v)); +} + +console.log('\n[no contact list published]'); +{ + // A brand new account follows nobody. Filtering on an empty list would + // silence everything, including the welcome message. + const { toPeer } = await makeDM(strangerSk, me, 'welcome!'); + const v = await classifyDm(toPeer, [{ ...inbox, follows: [], hasList: false }]); + check('nothing to filter against', v && !v.followed && !v.hasList); + check('notifies rather than silencing everything', shouldNotifyDm(v)); + const own = (await makeDM(meSk, friend, 'hi')).toSelf; + check('but our own copy is still silent', !shouldNotifyDm(await classifyDm(own, [{ ...inbox, follows: [], hasList: false }]))); +} + +console.log(fails ? `\n${fails} failing` : '\nall passing'); +process.exit(fails ? 1 : 0); |
