aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 22:07:13 -0700
committerAdam Soltys <[email protected]>2026-08-03 22:07:13 -0700
commit1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3 (patch)
tree04bb4bae281d9255ca45f9100c6903191b5c7b74 /tools
parentdc8f4b841ff57f4ef74597c2f80b6f90b352db13 (diff)
downloadcoinosv3-1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3.tar.gz
coinosv3-1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3.zip
Only people you know get to buzz your phone
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'tools')
-rw-r--r--tools/dm-filter-test.js101
1 files changed, 101 insertions, 0 deletions
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);