aboutsummaryrefslogtreecommitdiffstats
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
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]>
-rw-r--r--src/features/messages.js90
-rw-r--r--src/i18n.js1
-rw-r--r--src/marmot-client.js12
-rw-r--r--src/marmot.js121
-rw-r--r--tools/marmot-chat-test.js28
-rw-r--r--tools/marmot-interop-test.js72
6 files changed, 303 insertions, 21 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index d874865..1c0dab8 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6540,7 +6540,7 @@ export function messagesFeature(ctx) {
};
async function loadAttachment(a) {
const entry = { state: 'loading' };
- attachCache.set(a.url, entry);
+ attachCache.set(a.key || a.url, entry);
try {
if (+a.size > ATTACH_MAX) throw new Error('too big');
let buf = null;
@@ -6556,7 +6556,12 @@ export function messagesFeature(ctx) {
if (!buf) throw new Error('unreachable');
let bytes = new Uint8Array(buf);
const alg = (a['encryption-algorithm'] || '').toLowerCase();
- if (alg) {
+ if (a.wn) {
+ // White Noise: the key is the group's, for the epoch the message came in
+ const g = wn && wn.groups.get(a.wn.gid);
+ if (!g) throw new Error('group gone');
+ bytes = wn.media.open(g, a.wn.epoch, bytes, a.wn.ref);
+ } else if (alg) {
if (alg !== 'aes-gcm' || !a['decryption-key'] || !a['decryption-nonce']) throw new Error('unsupported');
const key = await crypto.subtle.importKey('raw', hexToBytes(a['decryption-key']), 'AES-GCM', false, ['decrypt']);
bytes = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: hexToBytes(a['decryption-nonce']) }, key, bytes));
@@ -6576,13 +6581,25 @@ export function messagesFeature(ctx) {
// The nodes for a rumor's attachments: a picture inline (tap to view),
// any other file as a download by name, a grey box the picture's shape
// while it loads, and a quiet note when it can't be had.
- function attachmentNodes(rumor) {
- return attachmentsOf(rumor).map((a) => {
+ // A White Noise message's attachments (encrypted-media-v2): the blob is
+ // found by its locators, then by its hash on the usual Blossom hosts.
+ const WN_BLOB_HOSTS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net', 'https://blossom.divine.video', 'https://cdn.hzrd149.com'];
+ const wnAttachmentsOf = (rumor, gid) => (wn ? (rumor.tags || []).map(wn.media.read).filter(Boolean) : []).map((x) => ({
+ key: 'wn:' + x.ref.cipher,
+ url: x.urls[0] || WN_BLOB_HOSTS[0] + '/' + x.ref.cipher,
+ fallback: [...x.urls.slice(1), ...WN_BLOB_HOSTS.map((u) => u + '/' + x.ref.cipher)].filter((u) => u !== x.urls[0]),
+ m: x.ref.m, name: x.ref.name, dim: x.dim,
+ wn: { gid, epoch: rumor.epoch, ref: x.ref },
+ }));
+ // `gid`: the White Noise group the message came through, if it did
+ function attachmentNodes(rumor, gid) {
+ return (gid ? wnAttachmentsOf(rumor, gid) : attachmentsOf(rumor)).map((a) => {
const isImg = /^image\//i.test(a.m || '') || /\.(png|jpe?g|gif|webp|avif)$/i.test(a.name || a.url.split('?')[0]);
- const encrypted = !!a['encryption-algorithm'];
+ const encrypted = !!a['encryption-algorithm'] || !!a.wn;
if (!encrypted && isImg) return urlNode(a.url, { isImage: true });
- let entry = attachCache.get(a.url);
- if (!entry && isImg) { loadAttachment(a); entry = attachCache.get(a.url); }
+ const ck = a.key || a.url;
+ let entry = attachCache.get(ck);
+ if (!entry && isImg) { loadAttachment(a); entry = attachCache.get(ck); }
const [w, hgt] = String(a.dim || '').split('x').map(Number);
const ratio = w > 0 && hgt > 0 ? `${w}/${hgt}` : '4/3';
if (entry && entry.state === 'ready') {
@@ -6595,7 +6612,7 @@ export function messagesFeature(ctx) {
}
if (entry && entry.state === 'error') return h('div', { class: 'small muted' }, t('msgAttachFailed'));
if (!isImg) return h('button', {
- class: 'chat-attach linklike', onClick: (e) => { e.stopPropagation(); if (!attachCache.has(a.url)) loadAttachment(a); },
+ class: 'chat-attach linklike', onClick: (e) => { e.stopPropagation(); if (!attachCache.has(ck)) loadAttachment(a); },
}, '๐Ÿ“Ž ' + (a.name || 'file') + (a.size ? ` ยท ${Math.round(+a.size / 1024)} KB` : ''));
return h('div', { class: 'chat-attach-ph', style: `aspect-ratio:${ratio}` });
});
@@ -6687,13 +6704,52 @@ export function messagesFeature(ctx) {
} catch (e) { toast(e.message || String(e)); }
ui.msgUploading = false; render();
}
+ // A file into a White Noise group (encrypted-media-v2): sealed under the
+ // group's media secret for the epoch it goes out in, uploaded, and sent as
+ // a kind 9 with an imeta tag. A commit landing during the upload moves the
+ // epoch and would leave the key behind it โ€” then it is sealed again.
+ // `road`: a group record, or 'new' (a direct chat with `peer` to found).
+ async function wnSendFile(road, file, peer) {
+ const id = await identity();
+ if (!id) { noIdToast(); return; }
+ if (file.size > MEDIA_MAX) { toast(t('msgAttachTooBig')); return; }
+ ui.msgUploading = true; render();
+ try {
+ const plain = new Uint8Array(await file.arrayBuffer());
+ let dim = '';
+ if (/^image\//.test(file.type)) {
+ try { const bmp = await createImageBitmap(file); dim = `${bmp.width}x${bmp.height}`; bmp.close(); } catch {}
+ }
+ const g = road === 'new' ? await wn.create({ members: [peer] }) : road;
+ const thread = peer ? threadOf(peer) : null;
+ const replyTo = ui.msgReplyTo && (thread ? thread.has(ui.msgReplyTo) : wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo)) ? ui.msgReplyTo : null;
+ for (let tries = 0; ; tries++) {
+ const sealed = wn.media.seal(g, plain, { type: file.type, name: file.name });
+ const urls = await uploadEncrypted(id, { cipher: sealed.cipher, x: sealed.ref.cipher });
+ if (g.tip.epoch !== sealed.epoch && tries < 2) continue;
+ // the local copy shows at once, from the plaintext we still hold
+ const blob = new Blob([file], { type: sealed.ref.m });
+ attachCache.set('wn:' + sealed.ref.cipher, { state: 'ready', blob, src: URL.createObjectURL(blob) });
+ const inner = wn.event(9, '', [wn.media.tag(sealed.ref, urls, dim), ...(replyTo ? [['e', replyTo]] : [])]);
+ ui.msgReplyTo = null;
+ if (!(await wn.send(g, inner))) throw new Error(t('msgSendFailed'));
+ if (thread) thread.set(inner.id, { rumor: g.log.find((m) => m.id === inner.id) || inner, mine: true, wn: g.id });
+ wnFolds.delete(g.id);
+ break;
+ }
+ ui.msgStick = true;
+ } catch (e) { toast(e.message === 'no signer' ? t('msgNoIdentity') : (e.message || String(e))); }
+ ui.msgUploading = false; render();
+ }
+
// A DM picture is a NIP-17 kind-15 file message: url as content, the
// same fields as flat tags, wrapped to the peer and to ourselves.
async function sendDMFile(peer, file) {
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
- if (await wnRoute(peer).catch(() => null)) { toast(t('msgGroupNoFiles')); return; }
+ const road = await wnRoute(peer).catch(() => null);
+ if (road) return wnSendFile(road, file, peer);
ui.msgUploading = true; render();
try {
const enc = await encryptFile(file);
@@ -9877,7 +9933,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'row between' },
h('span', { class: 'chat-name' }, displayName(peer)),
h('span', { class: 'chat-time thread-when' }, timeLabel(last.rumor.created_at * 1000))),
- h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 ? '๐Ÿ“Ž ' + t('msgPhoto') : last.rumor.content))),
+ h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 || (!last.rumor.content && last.wn) ? '๐Ÿ“Ž ' + t('msgPhoto') : last.rumor.content))),
// the row's own dot, centred and inset like the communities' below
unread ? h('i', { class: 'thread-dot' }) : null))(row.dm)))
: h('div', { class: 'muted small' }, t('msgNoDms')));
@@ -10439,9 +10495,13 @@ export function messagesFeature(ctx) {
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
const replyId = (m.rumor.tags.find((x) => x[0] === 'e') || [])[1];
const src = replyId && byId.get(replyId);
- const body = m.rumor.content
- ? noteBody(m.rumor.content, 0, em)
- : [h('span', { class: 'muted' }, '๐Ÿ“Ž ' + t('msgGroupAttachment'))];
+ const files = attachmentNodes(m.rumor, g.id);
+ const body = [
+ ...(m.rumor.content ? noteBody(m.rumor.content, 0, em) : []),
+ ...files,
+ // an attachment in a shape we can't read (another format, a malformed tag)
+ ...(!m.rumor.content && !files.length ? [h('span', { class: 'muted' }, '๐Ÿ“Ž ' + t('msgGroupAttachment'))] : []),
+ ];
return h('div', { class: 'chat-row' + (m.mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.rumor.pubkey),
h('div', { class: 'chat-body' },
@@ -10511,7 +10571,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, 'ร—')) : null,
live
- ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, null)
+ ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, (f) => wnSendFile(g, f))
: h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')),
ui.msgSheet ? wnSheet(g) : null);
}
@@ -10649,7 +10709,7 @@ export function messagesFeature(ctx) {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
- }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor), dmChips(m)),
+ }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
diff --git a/src/i18n.js b/src/i18n.js
index a4c3f50..47369c2 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -476,7 +476,6 @@ const STR = {
msgGroupRemove: 'Remove',
msgGroupRename: 'Rename',
msgGroupGone: 'You are no longer in this group',
- msgGroupNoFiles: "Attachments aren't available in this chat yet",
msgGroupAttachment: 'Attachment',
msgGroupFailed: "Couldn't update the group. Try again",
msgGroupSetup: 'Your signer is needed once to set up group chats',
diff --git a/src/marmot-client.js b/src/marmot-client.js
index 7352e05..d63cb04 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -400,6 +400,18 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, welcome, accept,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
+ // attachments: seal for the epoch a message is about to go out in, and
+ // open with the secret of the epoch it came in
+ media: {
+ read: M.readMediaTag,
+ tag: M.mediaTag,
+ seal: (g, plain, meta) => ({ epoch: g.tip.epoch, ...M.sealMedia(M.mediaSecret(g.tip), plain, meta) }),
+ open(g, epoch, cipher, ref) {
+ const secret = M.mediaSecretAt(g, epoch) || (g.tip.epoch === epoch ? M.mediaSecret(g.tip) : null);
+ if (!secret) throw new Error('media key for this epoch is gone');
+ return M.openMedia(secret, cipher, ref);
+ },
+ },
view: (g) => M.groupView(g.tip),
peer: (g) => M.directPeer(g),
accounts: (g) => M.accounts(g.tip),
diff --git a/src/marmot.js b/src/marmot.js
index bf6f620..7dd55d6 100644
--- a/src/marmot.js
+++ b/src/marmot.js
@@ -8,6 +8,7 @@
// no relay I/O. The messages feature drives it and owns storage and sockets.
import { sha256 } from "@noble/hashes/sha256";
+import { expand } from "@noble/hashes/hkdf";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex, hexToBytes, concatBytes, randomBytes } from "@noble/hashes/utils";
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
@@ -600,12 +601,13 @@ export function directPeer(g) {
// the invitees (nobody else exists yet, so nothing has to be published).
// Returns the record and the Welcome to deliver to each invitee.
export function found(device, { name = "", about = "", relays, invitees = [], adminKeys = [] }) {
- const reqComps = [COMP.ADMINS, COMP.ROUTING, COMP.PROOF, COMP.LIFECYCLE, ...(name || about ? [COMP.PROFILE] : [])];
+ const reqComps = [COMP.ADMINS, COMP.ROUTING, COMP.PROOF, COMP.LIFECYCLE, COMP.MEDIA_V2, ...(name || about ? [COMP.PROFILE] : [])];
const entries = [
{ id: COMP.APP_COMPONENTS, data: idList.enc(reqComps) },
{ id: COMP.ADMINS, data: admins.enc([device.pubkey, ...adminKeys]) },
{ id: COMP.ROUTING, data: routing.enc({ id: bytesToHex(randomBytes(32)), relays }) },
{ id: COMP.LIFECYCLE, data: Uint8Array.of(0) },
+ { id: COMP.MEDIA_V2, data: mediaPolicy.enc(MEDIA_ENDPOINTS) },
...(name || about ? [{ id: COMP.PROFILE, data: profile.enc({ name, about }) }] : []),
];
const required = {
@@ -683,11 +685,23 @@ function changes(before, after, actor, at) {
return out;
}
-function deliver(g, out, ev, epoch) {
+function deliver(g, out, ev, epoch, state) {
if (g.log.some((m) => m.id === ev.id)) return;
ev = { ...ev, epoch };
+ // an attachment is keyed to the epoch its message came in: keep that
+ // epoch's media secret for as long as the message is in the log
+ if (state && ev.tags.some(isMediaTag)) {
+ g.media ||= {};
+ g.media[epoch] ||= bytesToHex(mediaSecret(state));
+ }
g.log.push(ev);
- if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX);
+ if (g.log.length > LOG_MAX) {
+ g.log.splice(0, g.log.length - LOG_MAX);
+ if (g.media) {
+ const live = new Set(g.log.map((m) => String(m.epoch)));
+ for (const e of Object.keys(g.media)) if (!live.has(e)) delete g.media[e];
+ }
+ }
out.push(ev);
}
@@ -759,7 +773,7 @@ function take(g, ev, out) {
const r = mls.processMessage(src, bytes);
swap(g, src, r.state);
const inner = readAppEvent(r.data, accountAt(src, r.sender));
- if (inner) deliver(g, out, inner, src.epoch);
+ if (inner) deliver(g, out, inner, src.epoch, src);
} else if (peek.contentType === "proposal") {
if (src !== g.tip) return true; // proposals are epoch-bound: stale
const r = mls.processMessage(src, bytes);
@@ -813,6 +827,7 @@ function swap(g, old, next) {
// An app event out. The ratchet has moved once this returns: persist the
// record before the event leaves.
export function send(g, inner) {
+ const at = g.tip;
const r = mls.encryptApplication(g.tip, utf8(JSON.stringify(inner)));
const v = groupView(g.tip);
const ev = seal(g.tip, r.message, v.retention ? inner.created_at + v.retention : 0);
@@ -820,7 +835,7 @@ export function send(g, inner) {
see(g, ev.id);
see(g, bytesToHex(sha256(r.message)));
const out = [];
- deliver(g, out, inner, g.tip.epoch);
+ deliver(g, out, inner, at.epoch, at);
return ev;
}
@@ -884,3 +899,99 @@ export function leave(g) {
see(g, bytesToHex(sha256(r.message)));
return ev;
}
+
+// ---- encrypted media (encrypted-media-v2) ----
+// An attachment is a blob on a Blossom server, encrypted under a key that
+// only members can derive: the group's media secret at the epoch the
+// carrying message is sent in, stretched by the file's own hash, type and
+// name. The message carries an imeta tag that points at the blob.
+
+const MEDIA_V2 = "encrypted-media-v2";
+// where members of the groups we found upload: hosts that take an anonymous
+// encrypted blob and serve it with CORS
+export const MEDIA_ENDPOINTS = ["https://blossom.ditto.pub/", "https://nostr.download/"];
+
+const mediaPolicy = {
+ enc: (endpoints) => concatBytes(
+ vec(utf8(MEDIA_V2)),
+ vec(vec(utf8("blossom-v1"))),
+ vec(...endpoints.map((u) => concatBytes(vec(utf8("blossom-v1")), vec(utf8(u)))))),
+};
+
+export const mediaSecret = (state) => mls.exportSecret(state, "marmot", utf8("encrypted-media"), 32);
+
+// the shared Marmot media-type algorithm (canonical-encoding.md)
+const TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
+export function mediaType(value) {
+ let m = String(value || "").split(";")[0].replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "").replace(/[A-Z]/g, (c) => c.toLowerCase());
+ const parts = m.split("/");
+ if (parts.length !== 2 || !parts[0] || !parts[1] || parts[0].length > 64 || parts[1].length > 64 || m.length > 128 ||
+ !TOKEN.test(parts[0]) || !TOKEN.test(parts[1])) return null;
+ return m === "image/jpg" ? "image/jpeg" : m;
+}
+const nameOk = (n) => typeof n === "string" && !n.includes("\0") && utf8(n).length >= 1 && utf8(n).length <= 255;
+
+const nul = Uint8Array.of(0);
+const mediaAad = (hash, m, name) => concatBytes(utf8(MEDIA_V2), nul, hash, nul, utf8(m), nul, utf8(name));
+const fileKey = (secret, hash, m, name) => expand(sha256, secret, concatBytes(mediaAad(hash, m, name), nul, utf8("key")), 32);
+
+// Encrypt one file for this epoch. Returns the blob to upload and the
+// reference the imeta tag is made from once the blob has a URL.
+export function sealMedia(secret, plain, { type, name }) {
+ const m = mediaType(type) || "application/octet-stream";
+ name = String(name || "file").replace(/\0/g, "");
+ while (utf8(name).length > 255) name = name.slice(0, -1);
+ if (!nameOk(name)) name = "file";
+ const hash = sha256(plain);
+ const nonce = randomBytes(12);
+ const cipher = chacha20poly1305(fileKey(secret, hash, m, name), nonce, mediaAad(hash, m, name)).encrypt(plain);
+ return { cipher, ref: { plain: bytesToHex(hash), cipher: bytesToHex(sha256(cipher)), nonce: bytesToHex(nonce), m, name } };
+}
+
+export function mediaTag(ref, urls, dim) {
+ return ["imeta", "v " + MEDIA_V2, ...urls.map((u) => "locator blossom-v1 " + u),
+ "ciphertext_sha256 " + ref.cipher, "plaintext_sha256 " + ref.plain, "nonce " + ref.nonce,
+ "m " + ref.m, "filename " + ref.name, ...(dim ? ["dim " + dim] : [])];
+}
+
+const isMediaTag = (t) => t[0] === "imeta" && t.includes("v " + MEDIA_V2);
+const HEX32 = /^[0-9a-f]{64}$/;
+
+// A v2 imeta tag, validated; null when it is not one or is malformed
+// (which drops that attachment only, never the message).
+export function readMediaTag(tag) {
+ if (!Array.isArray(tag) || tag[0] !== "imeta") return null;
+ const one = {}, urls = [];
+ for (const f of tag.slice(1)) {
+ const i = f.indexOf(" ");
+ if (i < 1) return null;
+ const k = f.slice(0, i), v = f.slice(i + 1);
+ if (k === "locator") {
+ const j = v.indexOf(" ");
+ const kind = v.slice(0, j), url = v.slice(j + 1);
+ if (j < 1 || !url) return null;
+ if (kind === "blossom-v1") {
+ try { if (!/^https?:$/.test(new URL(url).protocol)) return null; } catch { return null; }
+ urls.push(url);
+ }
+ continue;
+ }
+ if (k in one) return null; // every other field appears once
+ one[k] = v;
+ }
+ if (one.v !== MEDIA_V2 || "blurhash" in one || !HEX32.test(one.ciphertext_sha256 || "") || !HEX32.test(one.plaintext_sha256 || "") ||
+ !/^[0-9a-f]{24}$/.test(one.nonce || "") || !one.m || mediaType(one.m) !== one.m || !nameOk(one.filename)) return null;
+ return { urls, ref: { plain: one.plaintext_sha256, cipher: one.ciphertext_sha256, nonce: one.nonce, m: one.m, name: one.filename }, dim: one.dim || "" };
+}
+
+// Decrypt a fetched blob. Throws when the blob is not the one referenced.
+export function openMedia(secret, cipher, ref) {
+ if (bytesToHex(sha256(cipher)) !== ref.cipher) throw new Error("blob hash mismatch");
+ const hash = hexToBytes(ref.plain);
+ const plain = chacha20poly1305(fileKey(secret, hash, ref.m, ref.name), hexToBytes(ref.nonce), mediaAad(hash, ref.m, ref.name)).decrypt(cipher);
+ if (bytesToHex(sha256(plain)) !== ref.plain) throw new Error("plaintext hash mismatch");
+ return plain;
+}
+
+// The media secret for an epoch this group record has kept, or null.
+export const mediaSecretAt = (g, epoch) => (g.media && g.media[epoch] ? hexToBytes(g.media[epoch]) : null);
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);