Files
teahub/.gitea/workflows/dm-e2e.yml
T

121 lines
10 KiB
YAML

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: '<p>secret body text</p>' });
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