diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 22:07:13 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 22:07:13 -0700 |
| commit | 1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3 (patch) | |
| tree | 04bb4bae281d9255ca45f9100c6903191b5c7b74 /tools | |
| parent | dc8f4b841ff57f4ef74597c2f80b6f90b352db13 (diff) | |
| download | coinosv3-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.js | 101 |
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); |
