aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/dm-inbox.js115
-rw-r--r--src/features/messages.js36
-rw-r--r--src/sw-nwc.js16
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;