From a21a12db0514ce443d0580537e1bba97d8de346d Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Sun, 25 Oct 2026 23:10:54 +0000 Subject: [PATCH] ops: end-to-end check of encrypted messages between names (NIP-17) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC --- .gitea/workflows/dm-e2e.yml | 120 ++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .gitea/workflows/dm-e2e.yml diff --git a/.gitea/workflows/dm-e2e.yml b/.gitea/workflows/dm-e2e.yml new file mode 100644 index 0000000..358d035 --- /dev/null +++ b/.gitea/workflows/dm-e2e.yml @@ -0,0 +1,120 @@ +name: dm-e2e +# End-to-end check of encrypted messages (NIP-17) between well-known names on production. +# well-known (WK_NSEC) and the factory key (SNAIL_KNOWN, owner of uniqornlabz@ and get@) message each other +# over public relays; the mail Worker only ever stores the gift-wrapped ciphertext. Keys stay in the runner. +on: + workflow_dispatch: + inputs: + base: { description: mail base URL, required: true, default: https://mail.well-known.online } +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - name: run + env: + KF: ${{ secrets.SNAIL_KNOWN }} + KW: ${{ secrets.WK_NSEC }} + B: ${{ inputs.base }} + run: | + mkdir -p /tmp/dm && cd /tmp/dm + npm init -y >/dev/null && npm i --silent nostr-tools@2.25.2 @noble/curves@1.9.7 >/dev/null + cat > t.mjs <<'EOF' + import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, verifyEvent } from 'nostr-tools/pure'; + import * as nip44 from 'nostr-tools/nip44'; + import * as nip19 from 'nostr-tools/nip19'; + import { spawn } from 'node:child_process'; + import { writeFileSync } from 'node:fs'; + const B = process.env.B, RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']; + const hex = (s) => { s = s.trim(); if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); return Buffer.from(nip19.decode(s).data).toString('hex'); }; + const bytes = (h) => Uint8Array.from(Buffer.from(h, 'hex')); + const F = bytes(hex(process.env.KF)), W = bytes(hex(process.env.KW)); + const pkF = getPublicKey(F), pkW = getPublicKey(W); + let fails = 0; const made = []; + const check = (n, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${n}${extra ? ' ' + extra : ''}`); if (!ok) fails++; }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const nip98 = (sk, url, method) => { const ev = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [['u', url.split('?')[0]], ['method', method]], content: '' }, sk); + return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64'); }; + const api = async (sk, method, path, body) => { const url = B + path; const r = await fetch(url, { method, headers: { authorization: nip98(sk, url, method), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); + const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, j, raw: t }; }; + const mb = (m) => `/api/v1/mailboxes/${encodeURIComponent(m)}`; + const UNI = 'uniqornlabz@well-known.online', WKM = 'well-known@well-known.online', GET = 'get@well-known.online'; + const past = () => Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 172800); + function makeDm(sk, to, text) { + const me = getPublicKey(sk); + const rumor = { pubkey: me, created_at: Math.floor(Date.now() / 1000), kind: 14, tags: [['p', to]], content: text }; rumor.id = getEventHash(rumor); + return [to, me].map((t) => { const seal = finalizeEvent({ kind: 13, created_at: past(), tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(sk, t)) }, sk); + const esk = generateSecretKey(); return finalizeEvent({ kind: 1059, created_at: past(), tags: [['p', t]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(esk, t)) }, esk); }); + } + function openDm(sk, w) { const seal = JSON.parse(nip44.decrypt(w.content, nip44.getConversationKey(sk, w.pubkey))); + if (seal.kind !== 13 || !verifyEvent(seal)) throw new Error('seal'); const r = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey))); + if (r.pubkey !== seal.pubkey) throw new Error('sender'); return r; } + const publish = (ev) => Promise.all(RELAYS.map((u) => new Promise((res) => { const ws = new WebSocket(u); const t = setTimeout(() => { try { ws.close(); } catch {} res(false); }, 7000); + ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev])); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x[0] === 'OK' && x[1] === ev.id) { clearTimeout(t); ws.close(); res(!!x[2]); } }; ws.onerror = () => { clearTimeout(t); res(false); }; }))); + async function waitFor(sk, mailbox, pred, label) { + for (let i = 0; i < 24; i++) { + await api(sk, 'POST', `${mb(mailbox)}/dms/poll`); + const r = await api(sk, 'GET', `${mb(mailbox)}/dms`); + for (const w of r.j.wraps || []) { try { const m = openDm(sk, w); if (pred(m)) return { m, raw: r.raw }; } catch {} } + await sleep(5000); + } + return { m: null, raw: '' }; + } + const tag = 'dm-e2e-' + Date.now(); + + // 0. well-known@ mailbox exists (login creates it for an OG handle) + let r = await fetch(B + '/auth/login', { method: 'POST', headers: { authorization: nip98(W, B + '/auth/login', 'POST') } }); + const lj = await r.json(); check('well-known key signs in and owns well-known@', r.status === 200 && (lj.mailboxes || []).includes(WKM), JSON.stringify(lj).slice(0, 120)); + + // 1. well-known -> uniqornlabz over relays + const t1 = `${tag} hello from well-known`; + const oks = await Promise.all(makeDm(W, pkF, t1).map(publish)); + check('gift wraps accepted by relays', oks.every((a) => a.some(Boolean)), JSON.stringify(oks)); + const got1 = await waitFor(F, UNI, (m) => m.content === t1, 't1'); + check('uniqornlabz@ inbox stores it and it decrypts with the factory key', !!got1.m); + check('sender is the well-known key', got1.m?.pubkey === pkW); + check('server holds only ciphertext (no plaintext in /dms)', !!got1.raw && !got1.raw.includes(tag)); + + // 2. reply uniqornlabz -> well-known + const t2 = `${tag} reply from uniqornlabz`; + await Promise.all(makeDm(F, pkW, t2).map(publish)); + const got2 = await waitFor(W, WKM, (m) => m.content === t2, 't2'); + check('reply arrives in well-known@ and decrypts', !!got2.m && got2.m.pubkey === pkF); + + // 3. local signer: list_messages + send_message, crypto on this machine + execSync: { + const src = await (await fetch(B + '/well-known-mcp.mjs')).text(); writeFileSync('/tmp/dm/well-known-mcp.mjs', src); + const p = spawn('node', ['/tmp/dm/well-known-mcp.mjs'], { env: { ...process.env, WK_NSEC: Buffer.from(W).toString('hex'), WK_MAIL_URL: B }, cwd: '/tmp/dm' }); + let buf = ''; const waiting = new Map(); + p.stdout.on('data', (d) => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 1); try { const j = JSON.parse(line); waiting.get(j.id)?.(j); } catch {} } }); + p.stderr.on('data', (d) => process.stderr.write('[signer] ' + d)); + let id = 0; const call = (method, params) => new Promise((res) => { const n = ++id; const t = setTimeout(() => res({ error: 'timeout' }), 90000); waiting.set(n, (j) => { clearTimeout(t); res(j); }); p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: n, method, params }) + '\n'); }); + await call('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dm-e2e', version: '0' } }); + p.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + const tl = await call('tools/list', {}); + const names = (tl.result?.tools || []).map((t) => t.name); + check('signer exposes list_messages + send_message (and messages_info from server)', names.includes('list_messages') && names.includes('send_message') && names.includes('messages_info'), names.join(',')); + const lm = await call('tools/call', { name: 'list_messages', arguments: { with: 'uniqornlabz' } }); + const lt = lm.result?.content?.[0]?.text || JSON.stringify(lm); + check('signer list_messages decrypts the reply', lt.includes(t2), lt.slice(0, 160)); + const t3 = `${tag} sent by the local signer`; + const sm = await call('tools/call', { name: 'send_message', arguments: { to: 'uniqornlabz', text: t3 } }); + check('signer send_message published', /relays_ok/.test(sm.result?.content?.[0]?.text || ''), (sm.result?.content?.[0]?.text || JSON.stringify(sm)).slice(0, 160)); + p.kill(); + const got3 = await waitFor(F, UNI, (m) => m.content === t3, 't3'); + check('signer message arrives at uniqornlabz@', !!got3.m && got3.m.pubkey === pkW); + } + + // 4. mailr bridge: internal mail get@ -> well-known@ triggers a NIP-17 notification (subject only) + const subj = `${tag} mail`; + r = await api(F, 'POST', `${mb(GET)}/emails`, { to: WKM, from: GET, subject: subj, text: 'secret body text ' + tag, html: '

secret body text

' }); + check('internal mail get@ -> well-known@ accepted', r.status === 202, String(r.status)); + const got4 = await waitFor(W, WKM, (m) => m.content.includes(subj), 'notify'); + check('well-known@ owner gets a DM notification with the subject', !!got4.m, got4.m?.content?.slice(0, 120)); + check('notification never carries the body', !!got4.m && !got4.m.content.includes('secret body')); + check('notification comes from a dedicated notify key', !!got4.m && got4.m.pubkey !== pkF && got4.m.pubkey !== pkW); + for (const [m, folder] of [[WKM, 'inbox'], [GET, 'sent']]) { const x = await api(F, 'GET', `${mb(m)}/emails?folder=${folder}&limit=50`); for (const e of (x.j.emails || []).filter((e) => e.subject === subj)) made.push([m, e.id]); } + + let n = 0; for (const [m, id2] of made) { const d = await api(F, 'DELETE', `${mb(m)}/emails/${id2}`); if (d.status < 300) n++; } console.log(`cleanup: deleted ${n}/${made.length} test emails (DMs stay on relays, encrypted)`); + console.log(fails ? `FAILED: ${fails}` : 'ALL PASS'); process.exit(fails ? 1 : 0); + EOF + node t.mjs