diff --git a/README.md b/README.md index 05226eef4..685d7330e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o - Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices) - See who has read each message, and track delivery status (sending / sent / failed) - Bookmark any message and revisit saved messages from the sidebar -- Schedule messages to send at a specific time +- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms) - Click "edited" on any message to see the full edit history - Drafts are saved automatically and survive page reloads - Long messages collapse automatically — click "Read more" to expand diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c037f013d..834fd98f1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -105,6 +105,7 @@ import { settingsAtom, } from '../../state/settings'; import { + buildCompressedUploadItem, getAudioMsgContent, getFileMsgContent, getImageMsgContent, @@ -244,8 +245,11 @@ export const RoomInput = forwardRef( const showLocation = composerToolbarButtons?.showLocation ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true; const showVoice = composerToolbarButtons?.showVoice ?? true; - // Schedule-send is hidden in thread mode (v1 reduction). - const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId; + // Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms: + // MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the + // SDK's encryption pipeline, so scheduling in an E2EE room would leak the body. + const showSchedule = + (composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted; const composerButtonOrder = useMemo( () => normalizeComposerToolbarOrder(composerToolbarButtons?.order), [composerToolbarButtons?.order], @@ -485,22 +489,29 @@ export const RoomInput = forwardRef( const compressedFile = new File([compressionResult.blob], compressedName, { type: compressedType, }); - const uploadRes = await mx.uploadContent(compressedFile, { - name: compressedName, - type: compressedType, - }); + // Compression re-encodes the image, so in an encrypted room the new + // bytes must be encrypted before upload (and the event must carry the + // *new* encInfo) — reusing the original's encInfo would publish the + // image in the clear and yield an undecryptable attachment. + const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined; + const uploadRes = encrypted + ? await mx.uploadContent(encrypted.file) + : await mx.uploadContent(compressedFile, { + name: compressedName, + type: compressedType, + }); const compressedMxc = (uploadRes as { content_uri: string }).content_uri; if (compressedMxc) { // Delete the pre-uploaded original so only one copy lives on the server. tryDeleteMxcContent(mx, upload.mxc); mxc = compressedMxc; - // Build a synthetic fileItem that refers to the compressed file so - // getImageMsgContent picks up the correct dimensions and type. - const compressedItem = { - ...fileItem, - file: compressedFile, - originalFile: compressedFile, - }; + // Synthetic fileItem referring to the compressed file so + // getImageMsgContent picks up the correct dimensions, type and encInfo. + const compressedItem = buildCompressedUploadItem( + fileItem, + compressedFile, + encrypted, + ); return getImageMsgContent(mx, compressedItem, mxc); } } @@ -697,11 +708,14 @@ export const RoomInput = forwardRef( }, [editor, isMarkdown, mx, roomId, replyDraft]); const handleScheduleClick = useCallback(() => { + // Defense in depth: scheduling sends an unencrypted m.room.message, so never + // open the modal for an encrypted room even if the button somehow renders. + if (isEncrypted) return; // Pre-fill from editor if there's content; open blank if editor is empty. const content = buildCurrentTextContent(); setScheduleContent(content); setScheduleOpen(true); - }, [buildCurrentTextContent]); + }, [buildCurrentTextContent, isEncrypted]); const handleScheduled = useCallback( (delayId: string, sendAt: number, content: IContent) => { @@ -823,18 +837,38 @@ export const RoomInput = forwardRef( return; } - const uploadRes = await mx.uploadContent( - new File([blob], 'image.gif', { type: 'image/gif' }), - { type: 'image/gif', name: 'image.gif', includeFilename: false }, - ); - const mxcUrl = (uploadRes as { content_uri: string }).content_uri; - if (!mxcUrl) return; - mx.sendMessage(roomId, threadRootId ?? null, { + const gifFile = new File([blob], 'image.gif', { type: 'image/gif' }); + const baseContent = { msgtype: MsgType.Image, body: 'image.gif', - url: mxcUrl, info: { mimetype: 'image/gif', w, h, size: blob.size }, - }); + }; + + // Mirror the attachment/voice paths: in an encrypted room the media + // itself must be encrypted, otherwise the homeserver (and anyone with + // the mxc URI) can see the GIF even though the event body is encrypted. + if (room.hasEncryptionStateEvent()) { + const { encInfo, file: encBlob } = await encryptFile(gifFile); + const uploadRes = await mx.uploadContent(encBlob); + const mxcUrl = (uploadRes as { content_uri: string }).content_uri; + if (!mxcUrl) return; + mx.sendMessage(roomId, threadRootId ?? null, { + ...baseContent, + file: { ...encInfo, url: mxcUrl }, + } as any); + } else { + const uploadRes = await mx.uploadContent(gifFile, { + type: 'image/gif', + name: 'image.gif', + includeFilename: false, + }); + const mxcUrl = (uploadRes as { content_uri: string }).content_uri; + if (!mxcUrl) return; + mx.sendMessage(roomId, threadRootId ?? null, { + ...baseContent, + url: mxcUrl, + } as any); + } } catch (e) { console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error'); if (!alive()) return; @@ -844,7 +878,7 @@ export const RoomInput = forwardRef( if (alive()) setGifUploading(false); } }, - [mx, roomId, threadRootId, alive], + [mx, room, roomId, threadRootId, alive], ); const handleStickerSelect = useCallback( diff --git a/src/app/features/room/msgContent.compressedItem.test.ts b/src/app/features/room/msgContent.compressedItem.test.ts new file mode 100644 index 000000000..f5bbbab0b --- /dev/null +++ b/src/app/features/room/msgContent.compressedItem.test.ts @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { buildCompressedUploadItem } from './msgContent'; +import { TUploadItem } from '../../state/room/roomInputDrafts'; + +// buildCompressedUploadItem decides which bytes are uploaded and which encInfo +// (if any) the resulting m.image event carries. Getting this wrong either leaks +// a plaintext image into an E2EE room or produces an undecryptable attachment. + +const enc = (tag: string): EncryptedAttachmentInfo => + ({ + v: 'v2', + key: { alg: 'A256CTR', k: tag }, + iv: `iv-${tag}`, + hashes: { sha256: `sha-${tag}` }, + }) as unknown as EncryptedAttachmentInfo; + +const fakeFile = (name: string, size: number): File => + new File([new Uint8Array(size)], name, { type: 'image/jpeg' }); + +const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem => + ({ + file: fakeFile('photo.png', 900), + originalFile: fakeFile('photo.png', 900), + encInfo, + metadata: { markedAsSpoiler: false, compressImage: true }, + }) as unknown as TUploadItem; + +test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => { + const compressed = fakeFile('photo.jpg', 300); + const item = buildCompressedUploadItem(makeItem(), compressed); + + assert.equal(item.file, compressed); + assert.equal(item.originalFile, compressed); + assert.equal(item.encInfo, undefined); +}); + +test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => { + const compressed = fakeFile('photo.jpg', 300); + const encryptedBlob = fakeFile('photo.jpg', 320); + const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, { + file: encryptedBlob, + encInfo: enc('compressed'), + }); + + // The ciphertext is what gets uploaded; the plaintext stays available for + // dimensions/blurhash only. + assert.equal(item.file, encryptedBlob); + assert.equal(item.originalFile, compressed); + assert.deepEqual(item.encInfo, enc('compressed')); + assert.notDeepEqual(item.encInfo, enc('original')); +}); + +test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => { + // Defensive: even if the caller forgets to re-encrypt, we must not emit the + // stale encInfo (that is the bug this helper exists to prevent). + const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300)); + assert.equal(item.encInfo, undefined); +}); + +test('metadata (caption, spoiler) is preserved on the compressed item', () => { + const base = makeItem(); + base.metadata.caption = 'a caption'; + base.metadata.markedAsSpoiler = true; + const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300)); + assert.equal(item.metadata.caption, 'a caption'); + assert.equal(item.metadata.markedAsSpoiler, true); +}); + +// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement). +// Its encInfo branch (content.file vs content.url) is exercised by the sibling +// msgContent.test.ts builders, which share the same shape. diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index d89dfb70f..7a27582a8 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -1,5 +1,6 @@ import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import to from 'await-to-js'; +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME, @@ -43,6 +44,28 @@ const generateThumbnailContent = async ( return thumbnailContent; }; +/** + * Build the synthetic upload item for a *re-encoded* (compressed) image. + * + * The compressed bytes are a brand new payload, so the item must never inherit + * the original's `encInfo` — that key/iv/sha256 describes the pre-compression + * ciphertext and would make receivers fail to decrypt. In an encrypted room the + * caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in + * an unencrypted room both are omitted and the item carries no `encInfo` at all. + */ +export const buildCompressedUploadItem = ( + item: TUploadItem, + compressedFile: File, + encrypted?: { file: File; encInfo: EncryptedAttachmentInfo }, +): TUploadItem => ({ + ...item, + // `file` is what gets uploaded/described, `originalFile` is the plaintext used + // for dimensions + blurhash. + file: encrypted?.file ?? compressedFile, + originalFile: compressedFile, + encInfo: encrypted?.encInfo, +}); + export const getImageMsgContent = async ( mx: MatrixClient, item: TUploadItem, diff --git a/src/app/utils/scheduledMessages.encryption.test.ts b/src/app/utils/scheduledMessages.encryption.test.ts new file mode 100644 index 000000000..d700d29b8 --- /dev/null +++ b/src/app/utils/scheduledMessages.encryption.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { scheduleMessage } from './scheduledMessages'; + +// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling +// must be refused outright for encrypted rooms — the composer hides the button, +// this guard stops any other caller from regressing it. +const makeMx = (encrypted: boolean | undefined) => { + const calls: unknown[][] = []; + const mx = { + getRoom: (_roomId: string) => + encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted }, + http: { + authedRequest: (...args: unknown[]) => { + calls.push(args); + return Promise.resolve({ delay_id: 'delay-1' }); + }, + }, + }; + return { mx: mx as never, calls }; +}; + +test('scheduleMessage throws and sends nothing for an encrypted room', async () => { + const { mx, calls } = makeMx(true); + await assert.rejects( + () => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000), + /encrypted rooms/, + ); + assert.equal(calls.length, 0); +}); + +test('scheduleMessage still sends for an unencrypted room', async () => { + const { mx, calls } = makeMx(false); + const delayId = await scheduleMessage( + mx, + '!plain:lotusguild.org', + { body: 'hi' }, + Date.now() + 60_000, + ); + assert.equal(delayId, 'delay-1'); + assert.equal(calls.length, 1); +}); + +test('scheduleMessage sends when the room is unknown to the client', async () => { + const { mx, calls } = makeMx(undefined); + await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000); + assert.equal(calls.length, 1); +}); diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index af0d140d4..3f157ab1a 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -12,6 +12,13 @@ export async function scheduleMessage( content: IContent, sendAtMs: number, ): Promise { + // MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing + // the SDK's encryptEventIfNeeded pipeline — the body would land on the server + // (and later in the timeline) in the clear. Refuse rather than leak; the + // composer also hides the Schedule button in encrypted rooms. + if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) { + throw new Error('Scheduled messages are not supported in encrypted rooms.'); + } // A past/near target floors at 1000ms (send ~immediately) — an intentional, // tested contract; the ScheduleMessageModal already guards ≥60s in the future. const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));