From 1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 22:07:13 -0700 Subject: Only people you know get to buzz your phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-17 wrap is signed by a throwaway key with the sender sealed under two layers of nip44, so no server can filter DMs by who sent them. The notifier now ships the wrap inside the push payload and the service worker opens it locally: a message from someone you follow arrives with their name on it, a stranger's is dropped, and your own sent-copy — every DM is wrapped to the sender too — no longer notifies you about what you just typed. That needs the nostr identity key in the worker's IndexedDB, which dm-inbox.js states plainly: for a seed-derived identity it is a second copy of a key localStorage's seed already yields, and it exists only to keep strangers out. A remote signer never hands its key over, so those wallets store no secret and keep getting unfiltered notifications rather than silence. Silence is always the riskier failure here, so the rule leans the other way whenever it isn't sure: a wrap we can't open notifies, an account with no contact list published notifies (otherwise a new user's welcome message would vanish), and anyone you already have a thread with counts as known whether or not you follow them. The server-side DM cooldown drops to a second — with the device discarding pushes, a throttle there would let a discarded one swallow the message behind it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- nwcpush/server.js | 24 ++++++++-- src/dm-inbox.js | 115 +++++++++++++++++++++++++++++++++++++++++++++++ src/features/messages.js | 36 +++++++++++++++ src/sw-nwc.js | 16 ++++++- tools/dm-filter-test.js | 101 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 src/dm-inbox.js create mode 100644 tools/dm-filter-test.js 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); -- cgit v1.2.3