mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
fix(matrix): encrypt thumbnails in E2EE rooms using thumbnail_file (#54711)
Merged via squash.
Prepared head SHA: 92be0e1ac2
Co-authored-by: frischeDaten <5878058+frischeDaten@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.
|
||||
- Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)
|
||||
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
|
||||
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export type FileWithThumbnailInfo = {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_file?: EncryptedFile;
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
|
||||
@@ -172,19 +172,52 @@ describe("sendMessageMatrix media", () => {
|
||||
expect(content.file?.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
|
||||
const { client, uploadContent } = makeEncryptedMediaClient();
|
||||
it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
const isRoomEncrypted = vi.fn().mockResolvedValue(true);
|
||||
const encryptMedia = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("encrypted-thumb"),
|
||||
file: {
|
||||
key: { kty: "oct", key_ops: ["encrypt", "decrypt"], alg: "A256CTR", k: "tkey", ext: true },
|
||||
iv: "tiv",
|
||||
hashes: { sha256: "thash" },
|
||||
v: "v2",
|
||||
},
|
||||
});
|
||||
(client as { crypto?: object }).crypto = {
|
||||
isRoomEncrypted,
|
||||
encryptMedia,
|
||||
};
|
||||
// Return image metadata so thumbnail generation is triggered (image > 800px)
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
.mockResolvedValueOnce({ width: 800, height: 600 });
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
|
||||
.mockResolvedValueOnce({ width: 1920, height: 1080 }) // original image
|
||||
.mockResolvedValueOnce({ width: 800, height: 450 }); // thumbnail
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb-bytes"));
|
||||
// Two uploadContent calls: one for the main encrypted image, one for the encrypted thumbnail
|
||||
uploadContent
|
||||
.mockResolvedValueOnce("mxc://example/main")
|
||||
.mockResolvedValueOnce("mxc://example/thumb");
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
expect(uploadContent).toHaveBeenCalledTimes(1);
|
||||
// encryptMedia called twice: once for main media, once for thumbnail
|
||||
expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
|
||||
expect(encryptMedia).toHaveBeenCalledTimes(2);
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
file?: { url?: string };
|
||||
info?: { thumbnail_url?: string; thumbnail_file?: { url?: string } };
|
||||
};
|
||||
// Main media encrypted correctly
|
||||
expect(content.url).toBeUndefined();
|
||||
expect(content.file?.url).toBe("mxc://example/main");
|
||||
// Thumbnail must use thumbnail_file (encrypted), NOT thumbnail_url (unencrypted)
|
||||
expect(content.info?.thumbnail_url).toBeUndefined();
|
||||
expect(content.info?.thumbnail_file?.url).toBe("mxc://example/thumb");
|
||||
});
|
||||
|
||||
it("keeps reply context on voice transcript follow-ups outside threads", async () => {
|
||||
@@ -246,7 +279,7 @@ describe("sendMessageMatrix media", () => {
|
||||
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uploads thumbnail metadata for unencrypted large images", async () => {
|
||||
it("keeps thumbnail_url metadata for unencrypted large images", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
@@ -262,6 +295,7 @@ describe("sendMessageMatrix media", () => {
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
info?: {
|
||||
thumbnail_url?: string;
|
||||
thumbnail_file?: { url?: string };
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
@@ -271,6 +305,7 @@ describe("sendMessageMatrix media", () => {
|
||||
};
|
||||
};
|
||||
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
|
||||
expect(content.info?.thumbnail_file).toBeUndefined();
|
||||
expect(content.info?.thumbnail_info).toMatchObject({
|
||||
w: 800,
|
||||
h: 600,
|
||||
|
||||
@@ -122,10 +122,6 @@ export async function prepareImageInfo(params: {
|
||||
return undefined;
|
||||
}
|
||||
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
||||
if (params.encrypted) {
|
||||
// For E2EE media, avoid uploading plaintext thumbnails.
|
||||
return imageInfo;
|
||||
}
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||
try {
|
||||
@@ -138,12 +134,16 @@ export async function prepareImageInfo(params: {
|
||||
const thumbMeta = await getCore()
|
||||
.media.getImageMetadata(thumbBuffer)
|
||||
.catch(() => null);
|
||||
const thumbUri = await params.client.uploadContent(
|
||||
thumbBuffer,
|
||||
"image/jpeg",
|
||||
"thumbnail.jpg",
|
||||
);
|
||||
imageInfo.thumbnail_url = thumbUri;
|
||||
const result = await uploadMediaWithEncryption(params.client, thumbBuffer, {
|
||||
contentType: "image/jpeg",
|
||||
filename: "thumbnail.jpg",
|
||||
encrypted: params.encrypted === true,
|
||||
});
|
||||
if (result.file) {
|
||||
imageInfo.thumbnail_file = result.file;
|
||||
} else {
|
||||
imageInfo.thumbnail_url = result.url;
|
||||
}
|
||||
if (thumbMeta) {
|
||||
imageInfo.thumbnail_info = {
|
||||
w: thumbMeta.width,
|
||||
@@ -202,6 +202,29 @@ async function uploadFile(
|
||||
return await client.uploadContent(file, params.contentType, params.filename);
|
||||
}
|
||||
|
||||
async function uploadMediaWithEncryption(
|
||||
client: MatrixClient,
|
||||
buffer: Buffer,
|
||||
params: {
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
encrypted: boolean;
|
||||
},
|
||||
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||
if (params.encrypted && client.crypto) {
|
||||
const encrypted = await client.crypto.encryptMedia(buffer);
|
||||
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
||||
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
||||
return {
|
||||
url: mxc,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
const mxc = await uploadFile(client, buffer, params);
|
||||
return { url: mxc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media with optional encryption for E2EE rooms.
|
||||
*/
|
||||
@@ -215,20 +238,9 @@ export async function uploadMediaMaybeEncrypted(
|
||||
},
|
||||
): Promise<{ url: string; file?: EncryptedFile }> {
|
||||
// Check if room is encrypted and crypto is available
|
||||
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
|
||||
|
||||
if (isEncrypted && client.crypto) {
|
||||
// Encrypt the media before uploading
|
||||
const encrypted = await client.crypto.encryptMedia(buffer);
|
||||
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
|
||||
const file: EncryptedFile = { url: mxc, ...encrypted.file };
|
||||
return {
|
||||
url: mxc,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload unencrypted
|
||||
const mxc = await uploadFile(client, buffer, params);
|
||||
return { url: mxc };
|
||||
const isEncrypted = Boolean(client.crypto && (await client.crypto.isRoomEncrypted(roomId)));
|
||||
return await uploadMediaWithEncryption(client, buffer, {
|
||||
...params,
|
||||
encrypted: isEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user