Files
cinny/src/app/features/room/msgContent.ts
T
jaredandClaude Fable 5.1 dac74f098e fix(composer): stop three E2EE plaintext leaks (compress, schedule, GIF)
- 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
2026-09-12 02:08:03 -04:00

197 lines
5.7 KiB
TypeScript

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,
MATRIX_SPOILER_PROPERTY_NAME,
} from '../../../types/matrix/common';
import {
getImageFileUrl,
getThumbnail,
getThumbnailDimensions,
getVideoFileUrl,
loadImageElement,
loadVideoElement,
} from '../../utils/dom';
import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
import { TUploadItem } from '../../state/room/roomInputDrafts';
import { encodeBlurHash } from '../../utils/blurHash';
import { scaleYDimension } from '../../utils/common';
const generateThumbnailContent = async (
mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement,
dimensions: [number, number],
encrypt: boolean,
): Promise<IThumbnailContent> => {
const thumbnail = await getThumbnail(img, ...dimensions);
if (!thumbnail) throw new Error('Can not create thumbnail!');
const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
const thumbnailFile = encThumbData?.file ?? thumbnail;
if (!thumbnailFile) throw new Error('Can not create thumbnail!');
const data = await mx.uploadContent(thumbnailFile);
const thumbMxc = data?.content_uri;
if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
const thumbnailContent = getThumbnailContent({
thumbnail: thumbnailFile,
encInfo: encThumbData?.encInfo,
mxc: thumbMxc,
width: dimensions[0],
height: dimensions[1],
});
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,
mxc: string,
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn('Failed to load image element:', imgError.name, imgError.message);
const content: IContent = {
msgtype: MsgType.Image,
filename: (file as File).name,
body: metadata.caption?.trim() || (file as File).name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (imgEl) {
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
content.info = {
...getImageInfo(imgEl, file),
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
};
}
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getVideoMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string,
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError)
console.warn('Failed to load video element:', videoError.name, videoError.message);
const content: IContent = {
msgtype: MsgType.Video,
filename: (file as File).name,
body: metadata.caption?.trim() || (file as File).name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
const [thumbError, thumbContent] = await to(
generateThumbnailContent(
mx,
videoEl,
getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
!!encInfo,
),
);
if (thumbContent && thumbContent.thumbnail_info) {
thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
videoEl,
512,
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
);
}
if (thumbError)
console.warn('Failed to generate video thumbnail:', thumbError.name, thumbError.message);
content.info = {
...getVideoInfo(videoEl, file),
...thumbContent,
};
}
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.Audio,
filename: (file as File).name,
body: (file as File).name,
info: {
mimetype: file.type,
size: file.size,
},
};
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.File,
body: (file as File).name,
filename: (file as File).name,
info: {
mimetype: file.type,
size: file.size,
},
};
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};