aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--nwcpush/server.js24
-rw-r--r--src/dm-inbox.js115
-rw-r--r--src/features/messages.js36
-rw-r--r--src/sw-nwc.js16
-rw-r--r--tools/dm-filter-test.js101
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);