diff options
| author | Adam Soltys <[email protected]> | 2026-10-02 23:07:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-02 23:07:09 -0700 |
| commit | 9e12335231f3fa4c4222bea71c4a1737a15a4994 (patch) | |
| tree | ef043ccb2edb285732b6bd5e40ce961328eba859 /src | |
| parent | dccc4a5559f23424d6092009e71375c11efe3784 (diff) | |
| download | coinosv3-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 'src')
| -rw-r--r-- | src/features/messages.js | 90 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/marmot-client.js | 12 | ||||
| -rw-r--r-- | src/marmot.js | 121 |
4 files changed, 203 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); |
