From af328bdb903d963b4ac5410e9ddec8d6b6862723 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:26:36 +0000 Subject: [PATCH 1/4] File zone: Mind contract; plaintext sha256 in info on send; 'read by ' from org.gamecult.file_received Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 80 +++++++++++++++++++++ src/app/features/albums/AlbumView.tsx | 6 ++ src/app/features/albums/buildAlbums.test.ts | 59 +++++++++++++++ src/app/features/albums/buildAlbums.ts | 41 +++++++++++ src/app/features/albums/fileReceipt.test.ts | 38 ++++++++++ src/app/features/albums/fileReceipt.ts | 29 ++++++++ src/app/features/room/RoomInput.tsx | 22 ++++-- 7 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 docs/file-zone-mind-contract.md create mode 100644 src/app/features/albums/fileReceipt.test.ts create mode 100644 src/app/features/albums/fileReceipt.ts diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md new file mode 100644 index 00000000..2a52132d --- /dev/null +++ b/docs/file-zone-mind-contract.md @@ -0,0 +1,80 @@ +# File zone: what a Mind can read from a file event + +Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's +(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract. +A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one. + +## What the Mind passes + +Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event, +reads the media reference from its content, and enforces the Mind's own membership in that room at fetch +time. A room the Mind is not in is refused by the mouth, whatever the client wrote. + +## What the client writes (this repo, `RoomInput.tsx` send path) + +A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`: + +| field | where | note | +|---|---|---| +| `msgtype` | content | selects the kind from the file's mimetype prefix | +| `body`, `filename` | content | both the file's name (display only; never a path) | +| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | +| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | +| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | +| `info.size` | content | bytes of the file as uploaded | +| `info["org.gamecult.sha256"]` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | + +The hash is computed over the same bytes that are uploaded. A file sent by something other than this +client may lack `info["org.gamecult.sha256"]`; the mouth then logs the hash it computed and the receipt +below is displayed as unchecked. + +Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail +reference; audio carries no duration. A Mind should not rely on any of those. + +## Limits and refusals + +- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap; + its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and + not read by a Mind. +- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted + file, over the cap, content type not matching the magic bytes. The client does not re-check them and + nothing in the client depends on the mouth's log. +- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist + for it. + +## Receipt: `org.gamecult.file_received` + +After a successful fetch the Mind posts one event of type `org.gamecult.file_received` to the room of the +file, as a reply in the album's thread, so it arrives with the album's own listing (no extra request): + +```json +{ + "m.relates_to": { "rel_type": "m.thread", "event_id": "" }, + "org.gamecult.file_received": { + "event_id": "", + "sha256": "", + "size": 123456 + } +} +``` + +No paths, no URLs, no filenames. The Mind is the event's `sender`. The receipt carries no `msgtype`, so +older clients and the album's file listing ignore it. + +The client draws it on the file row as `read by `: + +- one receipt per Mind per file, the latest wins, a second Mind adds a name; +- `sha256` equal to `info["org.gamecult.sha256"]`: plain `read by `; +- both present and different: `read by (hash differs)`; +- either missing: plain `read by `, unverified; +- a receipt whose `event_id` is not a file in that album, a malformed one, or a redacted one is ignored. + +A receipt is a claim by its sender and is shown to anyone in the room; any member could post one. +It proves nothing about who read the file. + +## Not built yet + +- Files sent from the composer carry no thread relation, so they reach an album only if written into a + thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Receipts are + thread replies, so they show only where the file is. +- Encrypted zones are not listed. diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 28e0c477..18673d6e 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,6 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; +import { readLine } from './fileReceipt'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -47,6 +48,11 @@ function FileRow({ room, file, author }: FileRowProps) { {file.name} + {file.readBy.length > 0 && ( + + {readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)} + + )} {audio ? ( } /> ) : ( diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 3a27bed6..48617733 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -214,3 +214,62 @@ describe('buildAlbums', () => { expect(one(root('$q', 1), [threaded]).count).toBe(1); }); }); + +describe('Mind receipts', () => { + const HASH = 'a'.repeat(64); + const file = (id: string, sha?: string) => + reply(id, 10, 'm.audio', id, { info: sha ? { 'org.gamecult.sha256': sha } : {} }); + const receipt = (id: string, ts: number, sender: string, fileId: unknown, sha?: unknown) => + ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha } }, + } as ZoneEvent); + + it('shows who read a file, with the hash agreeing', () => { + const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); + expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20, hash: 'match' }]); + expect(a.count).toBe(1); + }); + it('flags a hash that differs, and leaves one unchecked when either side has none', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + file('$g'), + receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$y', 21, '@void:x', '$g', HASH), + receipt('$z', 22, '@void:x', '$f', undefined), + ]); + expect(a.files.find((f) => f.id === '$g')?.readBy[0].hash).toBe('unchecked'); + expect(a.files.find((f) => f.id === '$f')?.readBy).toEqual([ + { sender: '@void:x', ts: 22, hash: 'unchecked' }, + ]); + }); + it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$1', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$2', 30, '@void:x', '$f', HASH), + receipt('$3', 25, '@eureka:x', '$f', HASH), + ]); + expect(a.files[0].readBy.map((r) => [r.sender, r.hash])).toEqual([ + ['@eureka:x', 'match'], + ['@void:x', 'match'], + ]); + }); + it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { + const redacted = { + ...receipt('$d', 20, '@void:x', '$f', HASH), + unsigned: { redacted_because: {} }, + }; + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$a', 20, '@void:x', '$elsewhere', HASH), + receipt('$b', 21, '@void:x', 42, HASH), + redacted, + ]); + expect(a.files[0].readBy).toEqual([]); + expect(a.count).toBe(1); + }); +}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 8e57262b..23c08543 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,6 +1,7 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. +import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -16,6 +17,9 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; +/** A Mind's receipt for one file. `hash` is whether its sha256 equals the one the file event carries. */ +export type FileReader = { sender: string; ts: number; hash: 'match' | 'differs' | 'unchecked' }; + export type AlbumFile = { id: string; kind: FileKind; @@ -23,6 +27,8 @@ export type AlbumFile = { ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; + /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ + readBy: FileReader[]; }; export type Album = { @@ -70,9 +76,43 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { name: asString(e.content.filename) || asString(e.content.body), ts: tsOf(e.origin_server_ts), content: e.content, + readBy: [], }; }; +// A receipt is a thread reply of type org.gamecult.file_received whose content names a file of this +// album; it carries no msgtype, so it never passes `live`. +const receiptOf = (e: ZoneEvent) => { + if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; + if (e.unsigned?.redacted_because) return undefined; + const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; + if (typeof body?.event_id !== 'string' || typeof e.sender !== 'string') return undefined; + return { + file: body.event_id, + sender: e.sender, + ts: tsOf(e.origin_server_ts), + sha256: body.sha256, + }; +}; + +const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { + const byId = new Map(files.map((f) => [f.id, f])); + replies + .map(receiptOf) + .filter((r): r is NonNullable => !!r) + .sort((a, b) => a.ts - b.ts) + .forEach((r) => { + const file = byId.get(r.file); + if (!file) return; + const sent = (file.content.info as Record | undefined)?.[FILE_HASH_KEY]; + let hash: FileReader['hash'] = 'unchecked'; + if (typeof sent === 'string' && typeof r.sha256 === 'string') + hash = sent === r.sha256 ? 'match' : 'differs'; + const others = file.readBy.filter((x) => x.sender !== r.sender); + file.readBy = [...others, { sender: r.sender, ts: r.ts, hash }]; + }); +}; + export const UNTITLED = 'Untitled album'; // An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is @@ -96,6 +136,7 @@ const toAlbum = (input: AlbumInput): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts); + attachReceipts(files, Array.isArray(replies) ? replies : []); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts new file mode 100644 index 00000000..9612669e --- /dev/null +++ b/src/app/features/albums/fileReceipt.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { hashForSend, readLine, sha256Hex } from './fileReceipt'; + +describe('sha256Hex', () => { + it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { + expect(await sha256Hex(new Blob(['abc']))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + }); + it('hashes empty input', async () => { + expect(await sha256Hex(new Blob([]))).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ); + }); +}); + +describe('hashForSend', () => { + it('gives no hash, and does not throw, when the blob cannot be read', async () => { + const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob; + expect(await hashForSend(broken)).toBeUndefined(); + }); +}); + +describe('readLine', () => { + const name = (s: string) => s.slice(1, s.indexOf(':')); + it('names each reader and flags a hash that differs', () => { + expect( + readLine( + [ + { sender: '@void:x', ts: 1, hash: 'match' }, + { sender: '@eureka:x', ts: 2, hash: 'differs' }, + { sender: '@aqua:x', ts: 3, hash: 'unchecked' }, + ], + name + ) + ).toBe('read by void, eureka (hash differs), aqua'); + }); +}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts new file mode 100644 index 00000000..b6a79178 --- /dev/null +++ b/src/app/features/albums/fileReceipt.ts @@ -0,0 +1,29 @@ +// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes +// into a file event, and the receipt a Mind answers with. + +import type { FileReader } from './buildAlbums'; + +/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ +export const FILE_HASH_KEY = 'org.gamecult.sha256'; + +/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. */ +export const FILE_RECEIVED = 'org.gamecult.file_received'; + +export const sha256Hex = async (data: Blob): Promise => { + const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer()); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */ +export const hashForSend = async (data: Blob): Promise => { + try { + return await sha256Hex(data); + } catch { + return undefined; + } +}; + +export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => + `read by ${readBy + .map((r) => (r.hash === 'differs' ? `${name(r.sender)} (hash differs)` : name(r.sender))) + .join(', ')}`; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 46675428..3f0a45d4 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -99,6 +99,7 @@ import { getImageMsgContent, getVideoMsgContent, } from './msgContent'; +import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; @@ -280,16 +281,23 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); + let content: IContent; if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); + content = await getImageMsgContent(mx, fileItem, upload.mxc); + } else if (fileItem.file.type.startsWith('video')) { + content = await getVideoMsgContent(mx, fileItem, upload.mxc); + } else if (fileItem.file.type.startsWith('audio')) { + content = getAudioMsgContent(fileItem, upload.mxc); + } else { + content = getFileMsgContent(fileItem, upload.mxc); } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, upload.mxc); + // A Mind verifies what it fetched against this. An encrypted file already carries the + // ciphertext hash in file.hashes; a plaintext hash would leak the file's identity. + if (!fileItem.encInfo) { + const sha256 = await hashForSend(fileItem.originalFile); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); - } - return getFileMsgContent(fileItem, upload.mxc); + return content; }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); From c34e536132888fdf621086d6dd145e8050b44f05 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:27:24 +0000 Subject: [PATCH 2/4] File zone: a receipt counts only from a Mind's own sender and with the file's hash; info.sha256 Heimdall's two rules (#minds): trust the Matrix sender, never a field inside; read means the receipt hash equals the file event's. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 51 +++++++++++---------- src/app/features/albums/buildAlbums.test.ts | 47 +++++++++++++------ src/app/features/albums/buildAlbums.ts | 28 ++++++----- src/app/features/albums/fileReceipt.test.ts | 11 ++--- src/app/features/albums/fileReceipt.ts | 8 ++-- 5 files changed, 87 insertions(+), 58 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 2a52132d..0a52ba76 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -14,19 +14,19 @@ time. A room the Mind is not in is refused by the mouth, whatever the client wro A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`: -| field | where | note | -|---|---|---| -| `msgtype` | content | selects the kind from the file's mimetype prefix | -| `body`, `filename` | content | both the file's name (display only; never a path) | -| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | -| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | -| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | -| `info.size` | content | bytes of the file as uploaded | -| `info["org.gamecult.sha256"]` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | +| field | where | note | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `msgtype` | content | selects the kind from the file's mimetype prefix | +| `body`, `filename` | content | both the file's name (display only; never a path) | +| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | +| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | +| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | +| `info.size` | content | bytes of the file as uploaded | +| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | The hash is computed over the same bytes that are uploaded. A file sent by something other than this -client may lack `info["org.gamecult.sha256"]`; the mouth then logs the hash it computed and the receipt -below is displayed as unchecked. +client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by` +for it. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail reference; audio carries no duration. A Mind should not rely on any of those. @@ -44,8 +44,8 @@ reference; audio carries no duration. A Mind should not rely on any of those. ## Receipt: `org.gamecult.file_received` -After a successful fetch the Mind posts one event of type `org.gamecult.file_received` to the room of the -file, as a reply in the album's thread, so it arrives with the album's own listing (no extra request): +The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the +file, as a reply in the album's thread so it arrives with the album's own listing (no extra request): ```json { @@ -58,19 +58,24 @@ file, as a reply in the album's thread, so it arrives with the album's own listi } ``` -No paths, no URLs, no filenames. The Mind is the event's `sender`. The receipt carries no `msgtype`, so -older clients and the album's file listing ignore it. +Two rules, both enforced on each side: -The client draws it on the file row as `read by `: +1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: + no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the + localpart is a Mind in the Persona catalogue; a display name or a lookalike does not count). +2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file + event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed + refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the + file event's `info.sha256`. A file event with no `info.sha256` can show no `read by`. -- one receipt per Mind per file, the latest wins, a second Mind adds a name; -- `sha256` equal to `info["org.gamecult.sha256"]`: plain `read by `; -- both present and different: `read by (hash differs)`; -- either missing: plain `read by `, unverified; -- a receipt whose `event_id` is not a file in that album, a malformed one, or a redacted one is ignored. +The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only +`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's +log is the fuller record for Minds; this event is the public tick. -A receipt is a claim by its sender and is shown to anyone in the room; any member could post one. -It proves nothing about who read the file. +The row draws `read by `: one receipt per Mind per file (the latest), several Minds side by side. A +receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored. +The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond +what the hash and the Mind's own account show. ## Not built yet diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 48617733..db596613 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -218,44 +218,65 @@ describe('buildAlbums', () => { describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => - reply(id, 10, 'm.audio', id, { info: sha ? { 'org.gamecult.sha256': sha } : {} }); - const receipt = (id: string, ts: number, sender: string, fileId: unknown, sha?: unknown) => + reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} }); + const receipt = ( + id: string, + ts: number, + sender: string, + fileId: unknown, + sha?: unknown, + extra: Record = {} + ) => ({ event_id: id, type: 'org.gamecult.file_received', sender, origin_server_ts: ts, - content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha } }, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); - it('shows who read a file, with the hash agreeing', () => { + it('shows who read a file when the receipt carries the file event hash', () => { const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); - expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20, hash: 'match' }]); + expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); expect(a.count).toBe(1); }); - it('flags a hash that differs, and leaves one unchecked when either side has none', () => { + it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { const a = one(root('$r', 1), [ file('$f', HASH), file('$g'), receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), receipt('$y', 21, '@void:x', '$g', HASH), receipt('$z', 22, '@void:x', '$f', undefined), + receipt('$w', 23, '@void:x', '$f', 7), ]); - expect(a.files.find((f) => f.id === '$g')?.readBy[0].hash).toBe('unchecked'); - expect(a.files.find((f) => f.id === '$f')?.readBy).toEqual([ - { sender: '@void:x', ts: 22, hash: 'unchecked' }, + expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); + }); + it('trusts the event sender, never a Mind field inside the content', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), ]); + expect(a.files[0].readBy).toEqual([]); + }); + it('counts only a Mind identity: the localpart, not a display name or a lookalike server part', () => { + const a = one(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@void-fan:x', '$f', HASH), + receipt('$y', 21, '@x:void', '$f', HASH), + receipt('$z', 22, '@aqua:x', '$f', HASH), + ]); + expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { const a = one(root('$r', 1), [ file('$f', HASH), - receipt('$1', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$1', 20, '@void:x', '$f', HASH), receipt('$2', 30, '@void:x', '$f', HASH), receipt('$3', 25, '@eureka:x', '$f', HASH), ]); - expect(a.files[0].readBy.map((r) => [r.sender, r.hash])).toEqual([ - ['@eureka:x', 'match'], - ['@void:x', 'match'], + expect(a.files[0].readBy).toEqual([ + { sender: '@eureka:x', ts: 25 }, + { sender: '@void:x', ts: 30 }, ]); }); it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 23c08543..78c581c2 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,6 +1,7 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. +import { MINDS } from '../../components/message/pose/poses'; import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ @@ -17,8 +18,8 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; -/** A Mind's receipt for one file. `hash` is whether its sha256 equals the one the file event carries. */ -export type FileReader = { sender: string; ts: number; hash: 'match' | 'differs' | 'unchecked' }; +/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ +export type FileReader = { sender: string; ts: number }; export type AlbumFile = { id: string; @@ -86,7 +87,8 @@ const receiptOf = (e: ZoneEvent) => { if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; if (e.unsigned?.redacted_because) return undefined; const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; - if (typeof body?.event_id !== 'string' || typeof e.sender !== 'string') return undefined; + if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; + if (typeof e.sender !== 'string') return undefined; return { file: body.event_id, sender: e.sender, @@ -95,21 +97,25 @@ const receiptOf = (e: ZoneEvent) => { }; }; +// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix +// identity (localpart in the Persona catalogue). "Read" means verified: the receipt's hash must be +// the file event's own. +const isMind = (sender: string) => MINDS.includes(sender.replace(/^@/, '').split(':')[0]); + const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { const byId = new Map(files.map((f) => [f.id, f])); replies .map(receiptOf) - .filter((r): r is NonNullable => !!r) + .filter((r): r is NonNullable => !!r && isMind(r.sender)) .sort((a, b) => a.ts - b.ts) .forEach((r) => { const file = byId.get(r.file); - if (!file) return; - const sent = (file.content.info as Record | undefined)?.[FILE_HASH_KEY]; - let hash: FileReader['hash'] = 'unchecked'; - if (typeof sent === 'string' && typeof r.sha256 === 'string') - hash = sent === r.sha256 ? 'match' : 'differs'; - const others = file.readBy.filter((x) => x.sender !== r.sender); - file.readBy = [...others, { sender: r.sender, ts: r.ts, hash }]; + const sent = (file?.content.info as Record | undefined)?.[FILE_HASH_KEY]; + if (!file || typeof sent !== 'string' || sent !== r.sha256) return; + file.readBy = [ + ...file.readBy.filter((x) => x.sender !== r.sender), + { sender: r.sender, ts: r.ts }, + ]; }); }; diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts index 9612669e..01eb37c0 100644 --- a/src/app/features/albums/fileReceipt.test.ts +++ b/src/app/features/albums/fileReceipt.test.ts @@ -22,17 +22,16 @@ describe('hashForSend', () => { }); describe('readLine', () => { - const name = (s: string) => s.slice(1, s.indexOf(':')); - it('names each reader and flags a hash that differs', () => { + it('names each reader', () => { + const name = (s: string) => s.slice(1, s.indexOf(':')); expect( readLine( [ - { sender: '@void:x', ts: 1, hash: 'match' }, - { sender: '@eureka:x', ts: 2, hash: 'differs' }, - { sender: '@aqua:x', ts: 3, hash: 'unchecked' }, + { sender: '@void:x', ts: 1 }, + { sender: '@eureka:x', ts: 2 }, ], name ) - ).toBe('read by void, eureka (hash differs), aqua'); + ).toBe('read by void, eureka'); }); }); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts index b6a79178..9a4d0ee5 100644 --- a/src/app/features/albums/fileReceipt.ts +++ b/src/app/features/albums/fileReceipt.ts @@ -4,9 +4,9 @@ import type { FileReader } from './buildAlbums'; /** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ -export const FILE_HASH_KEY = 'org.gamecult.sha256'; +export const FILE_HASH_KEY = 'sha256'; -/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. */ +/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */ export const FILE_RECEIVED = 'org.gamecult.file_received'; export const sha256Hex = async (data: Blob): Promise => { @@ -24,6 +24,4 @@ export const hashForSend = async (data: Blob): Promise => { }; export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => - `read by ${readBy - .map((r) => (r.hash === 'differs' ? `${name(r.sender)} (hash differs)` : name(r.sender))) - .join(', ')}`; + `read by ${readBy.map((r) => name(r.sender)).join(', ')}`; From 08ecacd4382740c16c082e9ed6adf744a3f78f8b Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:28:30 +0000 Subject: [PATCH 3/4] File zone: a receipt counts only from @:; doc says what info holds when media fails to load Soul: a federated @void:evil.example passed the localpart check. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 6 +++--- src/app/features/albums/buildAlbums.test.ts | 6 +++++- src/app/features/albums/buildAlbums.ts | 17 +++++++++-------- src/app/features/albums/useAlbums.ts | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 0a52ba76..36bb7a80 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -24,11 +24,11 @@ A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m. | `info.size` | content | bytes of the file as uploaded | | `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | -The hash is computed over the same bytes that are uploaded. A file sent by something other than this +The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `read by` for it. -Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail +`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail reference; audio carries no duration. A Mind should not rely on any of those. ## Limits and refusals @@ -62,7 +62,7 @@ Two rules, both enforced on each side: 1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: no Mind field is read. `read by X` counts only when the sender is X's own Matrix identity (the - localpart is a Mind in the Persona catalogue; a display name or a lookalike does not count). + full id is `@:` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count). 2. **Read means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index db596613..407a7c87 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -235,6 +235,8 @@ describe('Mind receipts', () => { content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); + const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; + it('shows who read a file when the receipt carries the file event hash', () => { const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); @@ -258,12 +260,14 @@ describe('Mind receipts', () => { ]); expect(a.files[0].readBy).toEqual([]); }); - it('counts only a Mind identity: the localpart, not a display name or a lookalike server part', () => { + it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { const a = one(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@void-fan:x', '$f', HASH), receipt('$y', 21, '@x:void', '$f', HASH), receipt('$z', 22, '@aqua:x', '$f', HASH), + receipt('$e', 23, '@void:evil.example', '$f', HASH), + receipt('$b', 24, 'void:x', '$f', HASH), ]); expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 78c581c2..22a872ce 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -98,15 +98,15 @@ const receiptOf = (e: ZoneEvent) => { }; // Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix -// identity (localpart in the Persona catalogue). "Read" means verified: the receipt's hash must be +// identity on this homeserver (`@:`, the mind named in the Persona catalogue). "Read" means verified: the receipt's hash must be // the file event's own. -const isMind = (sender: string) => MINDS.includes(sender.replace(/^@/, '').split(':')[0]); +const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); -const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[]) => { +const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[], server: string) => { const byId = new Map(files.map((f) => [f.id, f])); replies .map(receiptOf) - .filter((r): r is NonNullable => !!r && isMind(r.sender)) + .filter((r): r is NonNullable => !!r && isMind(r.sender, server)) .sort((a, b) => a.ts - b.ts) .forEach((r) => { const file = byId.get(r.file); @@ -130,7 +130,7 @@ const titleOf = (root: ZoneEvent & { content: Record }): string return asString(edited) || asString(root.content.body) || UNTITLED; }; -const toAlbum = (input: AlbumInput): Album | undefined => { +const toAlbum = (input: AlbumInput, server: string): Album | undefined => { const { root, replies } = input ?? {}; if (!live(root)) return undefined; const seen = new Set(); @@ -142,7 +142,7 @@ const toAlbum = (input: AlbumInput): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts); - attachReceipts(files, Array.isArray(replies) ? replies : []); + attachReceipts(files, Array.isArray(replies) ? replies : [], server); return { id: root.event_id, title: titleOf(root), @@ -155,10 +155,11 @@ const toAlbum = (input: AlbumInput): Album | undefined => { }; /** Newest album first; files oldest first. Loose room messages never reach this function. */ -export const buildAlbums = (inputs: AlbumInput[]): Album[] => { +/** `server` is the homeserver's own domain: only its Minds' receipts count. */ +export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { const seen = new Set(); return inputs - .map(toAlbum) + .map((i) => toAlbum(i, server)) .filter((a): a is Album => { if (!a || seen.has(a.id)) return false; seen.add(a.id); diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts index a01f1efb..7c1d5a57 100644 --- a/src/app/features/albums/useAlbums.ts +++ b/src/app/features/albums/useAlbums.ts @@ -59,7 +59,7 @@ export const useAlbums = (room: Room): AlbumsState => { ) ).chunk ?? [], })); - return buildAlbums(inputs); + return buildAlbums(inputs, mx.getDomain() ?? ''); }, retry: 1, refetchInterval: REFRESH_MS, From 3971c72de79ff1987e9e9c120414356a435f4255 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 21:28:49 +0000 Subject: [PATCH 4/4] File zone tests: no shadowed helper name Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/buildAlbums.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 407a7c87..e98e4f51 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -235,15 +235,15 @@ describe('Mind receipts', () => { content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, } as ZoneEvent); - const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; + const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; it('shows who read a file when the receipt carries the file event hash', () => { - const a = one(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); + const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); expect(a.count).toBe(1); }); it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), file('$g'), receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), @@ -254,14 +254,14 @@ describe('Mind receipts', () => { expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); }); it('trusts the event sender, never a Mind field inside the content', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), ]); expect(a.files[0].readBy).toEqual([]); }); it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$x', 20, '@void-fan:x', '$f', HASH), receipt('$y', 21, '@x:void', '$f', HASH), @@ -272,7 +272,7 @@ describe('Mind receipts', () => { expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); }); it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$1', 20, '@void:x', '$f', HASH), receipt('$2', 30, '@void:x', '$f', HASH), @@ -288,7 +288,7 @@ describe('Mind receipts', () => { ...receipt('$d', 20, '@void:x', '$f', HASH), unsigned: { redacted_because: {} }, }; - const a = one(root('$r', 1), [ + const a = oneX(root('$r', 1), [ file('$f', HASH), receipt('$a', 20, '@void:x', '$elsewhere', HASH), receipt('$b', 21, '@void:x', 42, HASH),