- Image compression in an encrypted room re-encoded the *plaintext* original, uploaded it unencrypted, and reused the original's encInfo, so the media sat on the server in the clear AND the attachment was undecryptable. The compressed bytes are now run through encryptFile and the synthetic upload item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can never inherit the stale encInfo). - Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the SDK encryption pipeline. The Schedule button is now hidden in encrypted rooms, handleScheduleClick no-ops there, and scheduleMessage() itself refuses with a clear error so no caller can regress this. README notes the limitation. - The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now mirrors the voice/attachment path (encryptFile -> upload ciphertext -> content.file). Fixes #6 Fixes #7 Fixes #11 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
74 lines
3.0 KiB
TypeScript
74 lines
3.0 KiB
TypeScript
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.
|