aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 23:07:00 -0700
committerAdam Soltys <[email protected]>2026-10-02 23:07:09 -0700
commit9e12335231f3fa4c4222bea71c4a1737a15a4994 (patch)
treeef043ccb2edb285732b6bd5e40ce961328eba859 /tools
parentdccc4a5559f23424d6092009e71375c11efe3784 (diff)
downloadcoinosv3-9e12335231f3fa4c4222bea71c4a1737a15a4994.tar.gz
coinosv3-9e12335231f3fa4c4222bea71c4a1737a15a4994.zip
White Noise attachments (encrypted-media-v2)
Pictures and files now travel in White Noise groups and direct chats, both ways. A file is sealed under the group's media secret for the epoch its message goes out in (MLS exporter "encrypted-media" -> HKDF -> ChaCha20-Poly1305, AAD bound to hash, type and name), uploaded to Blossom, and sent as a kind 9 with a v2 imeta tag. A commit landing mid-upload moves the epoch, so the file is sealed again rather than sent under a stale key. Receivers keep each epoch's media secret for as long as a message from it is in the log, so older pictures still open. Groups founded here now carry and require marmot.group.encrypted-media.v2 (blossom-v1 on blossom.ditto.pub and nostr.download). Checked against MDK's wn: our pictures decrypt in wn, wn's in ours, and wn uploads into our groups by our policy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/marmot-chat-test.js28
-rw-r--r--tools/marmot-interop-test.js72
2 files changed, 100 insertions, 0 deletions
diff --git a/tools/marmot-chat-test.js b/tools/marmot-chat-test.js
index f3a25f7..7310fd0 100644
--- a/tools/marmot-chat-test.js
+++ b/tools/marmot-chat-test.js
@@ -24,6 +24,12 @@ import * as nip06 from 'nostr-tools/nip06';
import { finalizeEvent } from 'nostr-tools/pure';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import { mkdtempSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
+import { randomBytes, bytesToHex } from '@noble/hashes/utils';
+
+// a tiny real PNG with a few random bytes after it, so every run's file is new
+const png = () => new Uint8Array([...Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0)), ...randomBytes(8)]);
+const imgShown = () => page.evaluate(() => [...document.querySelectorAll('.chat-log img.note-img')].filter((i) => i.complete && i.naturalWidth > 0 && i.src.startsWith('blob:')).length);
// shots go somewhere every machine has
const shot = (name) => join(tmpdir(), name);
@@ -169,6 +175,28 @@ try {
}
await say('hello from coinos');
check('ours reaches wn', !!await until(() => JSON.stringify(wn(['messages', 'list', gid, '--limit', '20'], alice)).includes('hello from coinos')));
+
+ console.log('\n[pictures]');
+ {
+ const dir = mkdtempSync(join(tmpdir(), 'wn-ui-'));
+ const theirs = join(dir, 'from-wn.png');
+ writeFileSync(theirs, png());
+ const before = await imgShown();
+ const up = wn(['media', 'upload', gid, theirs, '--send'], alice);
+ check('wn sends a picture', !up.error, JSON.stringify(up.error || ''));
+ check('it shows in the group', !!await until(async () => (await imgShown()) > before, SITE ? 90000 : 30000));
+ const ours = join(dir, 'from-coinos.png');
+ const bytes = png();
+ writeFileSync(ours, bytes);
+ const input = await page.$('#msg-file');
+ await input.uploadFile(ours);
+ const plain = bytesToHex((await import('@noble/hashes/sha256')).sha256(bytes));
+ check('ours reaches wn', !!await until(() => JSON.stringify(wn(['media', 'list', gid], alice)).includes(plain), 40000));
+ const out = mkdtempSync(join(tmpdir(), 'wn-dl-'));
+ wn(['media', 'download', gid, plain, '--output', out], alice);
+ const f = readdirSync(out)[0];
+ check('and wn decrypts it', !!f && bytesToHex(readFileSync(join(out, f))) === bytesToHex(bytes));
+ }
await page.screenshot({ path: shot('marmot-group.png') });
console.log('\n[a first message to a White Noise user founds a direct chat]');
diff --git a/tools/marmot-interop-test.js b/tools/marmot-interop-test.js
index 17b3e12..112babb 100644
--- a/tools/marmot-interop-test.js
+++ b/tools/marmot-interop-test.js
@@ -16,6 +16,11 @@
// add and remove members, and commit a wn member's SelfRemove.
import { spawnSync } from 'node:child_process';
+import { mkdtempSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, randomBytes } from '@noble/hashes/utils';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import * as nip19 from 'nostr-tools/nip19';
import { wrapDM, unwrapDM } from '../src/dm.js';
@@ -126,6 +131,25 @@ async function waitFor(fn, label, ms = 15000) {
}
}
+// BUD-02 upload with a kind-24242 auth, as the app does it
+async function upload(cipher, x) {
+ const at = now();
+ const auth = finalizeEvent({ kind: 24242, created_at: at - 5, content: 'upload', tags: [['t', 'upload'], ['x', x], ['expiration', String(at + 600)]] }, sk);
+ for (const server of M.MEDIA_ENDPOINTS) {
+ try {
+ const r = await fetch(server + 'upload', { method: 'PUT', body: cipher, headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/octet-stream' } });
+ if (r.ok) return (await r.json()).url;
+ } catch {}
+ }
+ throw new Error('no blossom host took the blob');
+}
+async function fetchBlob(urls) {
+ for (const u of urls) { try { const r = await fetch(u); if (r.ok) return new Uint8Array(await r.arrayBuffer()); } catch {} }
+ return null;
+}
+// a small real PNG, so every app treats it as a picture
+const PNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
+
async function main() {
try { await connect(); } catch (e) { console.log('SKIP: ' + e.message); return; }
const who = wn(['whoami']);
@@ -170,6 +194,39 @@ async function main() {
const seen = await waitFor(async () => JSON.stringify(wn(['messages', 'list', wnGroup, '--limit', '20'], { account: alice })).includes('hello from js'), 'us → wn application message');
ok(!!seen, 'wn decrypts our message');
+ console.log('\n3b. pictures both ways (encrypted-media-v2)');
+ {
+ const dir = mkdtempSync(join(tmpdir(), 'wn-media-'));
+ const file = join(dir, 'from-wn.png');
+ const body = new Uint8Array([...PNG, ...randomBytes(8)]); // unique bytes per run
+ writeFileSync(file, body);
+ const up = wn(['media', 'upload', wnGroup, file, '--send'], { account: alice });
+ ok(!up.error, 'wn uploads and sends a picture', JSON.stringify(up.error || ''));
+ const got = await waitFor(async () => { await sync(g); return g.log.find((m) => m.tags.some((t) => t[0] === 'imeta')); }, 'picture message arrives');
+ if (got) {
+ const ref = got.tags.map(M.readMediaTag).filter(Boolean)[0];
+ ok(!!ref, 'its imeta tag reads as encrypted-media-v2', JSON.stringify(got.tags).slice(0, 300));
+ const blob = ref && await fetchBlob(ref.urls);
+ ok(!!blob, 'the blob is fetchable from its locator');
+ let plain = null;
+ try { plain = M.openMedia(M.mediaSecretAt(g, got.epoch), blob, ref.ref); } catch (e) { ok(false, 'decrypt', e.message); }
+ ok(plain && bytesToHex(plain) === bytesToHex(body), 'we decrypt wn\'s picture to the original bytes');
+ }
+ // ours to wn
+ const mine = new Uint8Array([...PNG, ...randomBytes(8)]);
+ const sealed = M.sealMedia(M.mediaSecret(g.tip), mine, { type: 'image/png', name: 'from-js.png' });
+ const url = await upload(sealed.cipher, sealed.ref.cipher);
+ await publish(M.send(g, M.appEvent(pk, 9, 'a picture', [M.mediaTag(sealed.ref, [url], '2x2')])));
+ const listed = await waitFor(async () => JSON.stringify(wn(['media', 'list', wnGroup], { account: alice })).includes(sealed.ref.plain), 'wn lists our picture');
+ if (listed) {
+ const out = mkdtempSync(join(tmpdir(), 'wn-dl-'));
+ const dl = wn(['media', 'download', wnGroup, sealed.ref.plain, '--output', out], { account: alice });
+ ok(!dl.error, 'wn downloads our picture', JSON.stringify(dl.error || ''));
+ const f = readdirSync(out)[0];
+ ok(f && bytesToHex(readFileSync(join(out, f))) === bytesToHex(mine), 'wn decrypts it to the original bytes');
+ }
+ }
+
console.log('\n4. our self-update commit, then traffic in the new epoch');
const e0 = g.tip.epoch;
await commit(g, []);
@@ -206,6 +263,21 @@ async function main() {
wn(['messages', 'send', mineId, 'reply from wn'], { account: alice });
await waitFor(async () => { await sync(mine); return texts(mine).includes('reply from wn'); }, 'wn → our group');
ok(texts(mine).includes('reply from wn'), 'we decrypt wn in our group');
+ {
+ // our group carries our media policy: wn must accept it and upload where it says
+ const dir = mkdtempSync(join(tmpdir(), 'wn-media-'));
+ const file = join(dir, 'into-ours.png');
+ const body = new Uint8Array([...PNG, ...randomBytes(8)]);
+ writeFileSync(file, body);
+ const up = wn(['media', 'upload', mineId, file, '--send'], { account: alice });
+ ok(!up.error, 'wn sends a picture into our group (our endpoints)', JSON.stringify(up.error || ''));
+ const got = await waitFor(async () => { await sync(mine); return mine.log.find((m) => m.tags.some((t) => t[0] === 'imeta')); }, 'picture in our group');
+ const ref = got && got.tags.map(M.readMediaTag).filter(Boolean)[0];
+ const blob = ref && await fetchBlob(ref.urls);
+ let plain = null;
+ try { plain = blob && M.openMedia(M.mediaSecretAt(mine, got.epoch), blob, ref.ref); } catch {}
+ ok(plain && bytesToHex(plain) === bytesToHex(body), 'and we decrypt it', ref ? ref.urls.join(' ') : '');
+ }
console.log('\n7. our commits in our group: add, rename, remove');
const bobKp = await fetchKp(bob);