diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/dm-inbox.js | 115 | ||||
| -rw-r--r-- | src/features/messages.js | 36 | ||||
| -rw-r--r-- | src/sw-nwc.js | 16 |
3 files changed, 166 insertions, 1 deletions
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; |
