atagen

macltok

public
Star0
Watch0

Commit 5b21b919d1e6

tarball

atagen <boss@atagen.co>2026-09-27 12:08 UTC

7 files changed+350-65
media: let a host locate media and stream it itself

ClientHandle::media buffers the whole file and checks its hash before answering, which a long video can't afford, least of all in wasm memory that never shrinks. media_source hands over the url and attachment keys media would have used, picked by the same choose_source, and access_token the bearer to fetch them with, so a host can serve Range requests and decrypt with AES-CTR on its own. Streaming decryption inside macltok was declined since a browser service worker is a separate context and every chunk would still cross wasm memory.
Modified file: README.md+6 additions
@@ -47,6 +47,12 @@ with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported
4747 as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it
4848 lands. stop applying, call `resync`, and pick up from the next `TimelineReset`
4949
50+`media` answers with the whole file, decrypted and checked, which is wrong for a
51+long video. `media_source` says where the same bytes live instead, the url and,
52+for an encrypted file, its AES-CTR key, iv and hash. `access_token` is what to
53+send with it. a host can then fetch and decrypt ranges itself, but the hash
54+covers the whole ciphertext, so a range read plays bytes nobody checked
55+
5056 with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its
5157 store. `EncryptedSessionStore` seals the record with the store's passphrase, in a
5258 file you pick natively or an IndexedDB database beside the store in a browser.
Modified file: docs/design-decisions.md+7 additions
@@ -175,6 +175,13 @@ checks the cached bytes against the file's hash and decrypts them if they match
175175 media ids are registered as timelines render and forgotten when their timeline
176176 closes, least recently seen first once the cap is hit
177177
178+streaming decryption inside macltok was declined. in a browser the service
179+worker answering range requests is a separate context from the worker running
180+macltok, and streaming through macltok would carry every chunk through wasm
181+memory, the very thing the host streams to avoid. `media_source` hands over the
182+url and keys `media` would have used, picked the same way, and `access_token`
183+the rest. the byte cap doesn't apply, nothing is buffered
184+
178185 uploads are commands that answer once the send queue has them. progress only
179186 goes out on a room's own send queue channel, so each room that uploads gets a
180187 forwarder, subscribed before the upload is queued and restarted for uploads the
Modified file: src/engine/media.rs+142 additions−44 deletions
@@ -22,7 +22,7 @@ use matrix_sdk::{
2222 client::{authenticated_media, media},
2323 },
2424 events::room::{
25- MediaSource,
25+ EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,
2626 message::{AddMentions, ReplyWithinThread},
2727 },
2828 },
@@ -33,7 +33,8 @@ use tokio::sync::Mutex;
3333
3434 use super::executor::EngineBoxFuture;
3535 use crate::{
36- AttachmentUpload, Error, ErrorKind, Media, MediaId, MediaKind, MediaSize, TimelineId,
36+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,
37+ MediaLocation, MediaSize, TimelineId,
3738 normalise::{MediaRegistration, MediaScope},
3839 upload::prepare_upload,
3940 };
@@ -84,8 +85,59 @@ impl MediaService {
8485 media_id: &MediaId,
8586 size: MediaSize,
8687 ) -> Result<Media, Error> {
87- let media = self
88- .registry
88+ let chosen = choose_source(self.registered(media_id).await?, size)?;
89+ if chosen
90+ .declared_size
91+ .is_some_and(|size| size > self.max_bytes as u64)
92+ {
93+ return Err(too_large("media exceeds the configured byte limit"));
94+ }
95+ self.fetch_or_file(chosen.request, chosen.mimetype).await
96+ }
97+
98+ pub(crate) async fn media_location(
99+ &self,
100+ media_id: &MediaId,
101+ size: MediaSize,
102+ ) -> Result<MediaLocation, Error> {
103+ let media = self.registered(media_id).await?;
104+ let chosen = choose_source(media.clone(), size)?;
105+ if is_local(&chosen.request.source) {
106+ return Ok(MediaLocation::Local);
107+ }
108+ if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {
109+ return Ok(MediaLocation::File(
110+ media_file(
111+ &self.client,
112+ chosen.request,
113+ chosen.mimetype,
114+ chosen.declared_size,
115+ )
116+ .await?,
117+ ));
118+ }
119+ let url = media_http_request(&self.client, &chosen.request)
120+ .await?
121+ .uri()
122+ .to_string();
123+ let fallback = MediaRequestParameters {
124+ source: media.full.source,
125+ format: MediaFormat::File,
126+ };
127+ Ok(MediaLocation::ServerThumbnail {
128+ url,
129+ fallback: media_file(
130+ &self.client,
131+ fallback,
132+ media.full.mimetype,
133+ media.full.declared_size,
134+ )
135+ .await?,
136+ })
137+ }
138+
139+ async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {
140+ self.registry
89141 .lock()
90142 .await
91143 .get(media_id)
@@ -95,46 +147,7 @@ impl MediaService {
95147 ErrorKind::MediaUnknown,
96148 "media is not registered in a retained timeline",
97149 )
98- })?;
99- let (source, format, mimetype, declared_size) = match size {
100- MediaSize::Full => (
101- media.full.source,
102- MediaFormat::File,
103- media.full.mimetype,
104- media.full.declared_size,
105- ),
106- MediaSize::Thumbnail { width, height } => match media.thumbnail {
107- Some(thumbnail) => (
108- thumbnail.source,
109- MediaFormat::File,
110- thumbnail.mimetype,
111- thumbnail.declared_size,
112- ),
113- // the server can only resize what it can read
114- None if matches!(media.kind, MediaKind::Image | MediaKind::Video)
115- && matches!(media.full.source, MediaSource::Plain(_)) =>
116- {
117- // the server picks the thumbnail's type, so the original's would be a lie
118- let format = if is_local(&media.full.source) {
119- MediaFormat::File
120- } else {
121- thumbnail_format(width, height)
122- };
123- (media.full.source, format, None, None)
124- }
125- None => {
126- return Err(Error::new(
127- ErrorKind::MediaUnavailable,
128- "the requested media size is unavailable",
129- ));
130- }
131- },
132- };
133- if declared_size.is_some_and(|size| size > self.max_bytes as u64) {
134- return Err(too_large("media exceeds the configured byte limit"));
135- }
136- self.fetch_or_file(MediaRequestParameters { source, format }, mimetype)
137- .await
150+ })
138151 }
139152
140153 pub(crate) async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {
@@ -214,6 +227,91 @@ impl MediaService {
214227 }
215228 }
216229
230+struct ChosenSource {
231+ request: MediaRequestParameters,
232+ mimetype: Option<String>,
233+ declared_size: Option<u64>,
234+}
235+
236+fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {
237+ let (source, format, mimetype, declared_size) = match size {
238+ MediaSize::Full => (
239+ media.full.source,
240+ MediaFormat::File,
241+ media.full.mimetype,
242+ media.full.declared_size,
243+ ),
244+ MediaSize::Thumbnail { width, height } => match media.thumbnail {
245+ Some(thumbnail) => (
246+ thumbnail.source,
247+ MediaFormat::File,
248+ thumbnail.mimetype,
249+ thumbnail.declared_size,
250+ ),
251+ // the server can only resize what it can read
252+ None if matches!(media.kind, MediaKind::Image | MediaKind::Video)
253+ && matches!(media.full.source, MediaSource::Plain(_)) =>
254+ {
255+ // the server picks the thumbnail's type, so the original's would be a lie
256+ let format = if is_local(&media.full.source) {
257+ MediaFormat::File
258+ } else {
259+ thumbnail_format(width, height)
260+ };
261+ (media.full.source, format, None, None)
262+ }
263+ None => {
264+ return Err(Error::new(
265+ ErrorKind::MediaUnavailable,
266+ "the requested media size is unavailable",
267+ ));
268+ }
269+ },
270+ };
271+ Ok(ChosenSource {
272+ request: MediaRequestParameters { source, format },
273+ mimetype,
274+ declared_size,
275+ })
276+}
277+
278+async fn media_file(
279+ client: &Client,
280+ request: MediaRequestParameters,
281+ mimetype: Option<String>,
282+ declared_size: Option<u64>,
283+) -> Result<MediaFile, Error> {
284+ let url = media_http_request(client, &request)
285+ .await?
286+ .uri()
287+ .to_string();
288+ let encryption = match &request.source {
289+ MediaSource::Plain(_) => None,
290+ MediaSource::Encrypted(file) => {
291+ let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (
292+ &file.info,
293+ file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),
294+ ) else {
295+ return Err(Error::new(
296+ ErrorKind::MediaUnavailable,
297+ "encrypted media is not v2 with a sha256 hash",
298+ ));
299+ };
300+ Some(AttachmentEncryption {
301+ key: info.k.encode(),
302+ iv: info.iv.encode(),
303+ sha256: sha256.encode(),
304+ })
305+ }
306+ };
307+ Ok(MediaFile {
308+ url,
309+ mimetype,
310+ declared_size,
311+ encryption,
312+ })
313+}
314+
217315 fn thumbnail_format(width: u32, height: u32) -> MediaFormat {
218316 MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))
219317 }
Modified file: src/lib.rs+15 additions−14 deletions
@@ -42,20 +42,21 @@ pub use mirror::{
4242 TimelineState, Update, UploadFailure, UploadState,
4343 };
4444 pub use model::{
45- AccountCommand, AttachmentUpload, Change, Command, CommandOutput, ConnectionState,
46- CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event,
47- EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand,
48- MediaDescriptor, MediaId, MediaKind, MediaSize, MemberFilter, MemberMembership, MemberSummary,
49- MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,
50- NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
51- PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
52- Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,
53- RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
54- RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
55- Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
56- TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
57- UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
58- VerificationFailure, VerificationState, VerificationTarget,
45+ AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
46+ ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,
47+ EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,
48+ Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
49+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,
50+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,
51+ PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,
52+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,
53+ ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,
54+ RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
55+ RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,
56+ ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
57+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
58+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
59+ VerificationState, VerificationTarget,
5960 };
6061 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
6162 pub use uiaa::{
Modified file: src/model.rs+39 additions
@@ -1681,6 +1681,45 @@ pub struct Media {
16811681 pub mimetype: String,
16821682 }
16831683
1684+/// where media lives, for a host that fetches and decrypts it itself
1685+#[derive(Clone, Debug, Eq, PartialEq)]
1686+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1687+#[non_exhaustive]
1688+pub enum MediaLocation {
1689+ File(MediaFile),
1690+ /// a resize of plaintext, so never encrypted. plenty of servers refuse some, and `media`
1691+ /// then fetches `fallback` instead, so should you
1692+ ServerThumbnail {
1693+ url: String,
1694+ fallback: MediaFile,
1695+ },
1696+ /// still in the send queue with no url yet. `media` has the bytes
1697+ Local,
1698+}
1699+
1700+#[derive(Clone, Debug, Eq, PartialEq)]
1701+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1702+#[non_exhaustive]
1703+pub struct MediaFile {
1704+ pub url: String,
1705+ pub mimetype: Option<String>,
1706+ pub declared_size: Option<u64>,
1707+ pub encryption: Option<AttachmentEncryption>,
1708+}
1709+
1710+/// AES-256-CTR, the only attachment cipher matrix has
1711+#[derive(Clone, Debug, Eq, PartialEq)]
1712+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1713+#[non_exhaustive]
1714+pub struct AttachmentEncryption {
1715+ /// the JWK `k`, unpadded url-safe base64
1716+ pub key: String,
1717+ /// unpadded base64, the initial counter block
1718+ pub iv: String,
1719+ /// unpadded base64 SHA-256 of the whole ciphertext, so a partial read goes unchecked
1720+ pub sha256: String,
1721+}
1722+
16841723 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
16851724 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16861725 #[non_exhaustive]
Modified file: src/transport.rs+22 additions−6 deletions
@@ -28,12 +28,12 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828 use crate::{
2929 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
3030 EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,
31- InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaSize,
32- MemberFilter, MemberSummary, MessageContent, MessageEditContent, NotificationCommand,
33- OwnDevice, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
34- PushNotificationStatus, RoomCommand, RoomListFilter, RoomNotificationMode,
35- RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,
36- TimelineId, VerificationAction, VerificationTarget,
31+ InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,
32+ MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,
33+ NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
34+ PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
35+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,
36+ TimelineCommand, TimelineId, VerificationAction, VerificationTarget,
3737 encryption::encryption_state,
3838 engine::{MediaService, StoreLock},
3939 model::{PushNotificationRoom, PushNotificationSender},
@@ -83,6 +83,12 @@ impl ClientHandle {
8383 crate::account::session_record(&self.client)
8484 }
8585
86+ /// the bearer token. it rotates on every refresh so ask again after a 401 rather than
87+ /// keeping it, and keep it in memory and away from all but the homeserver's media endpoints
88+ pub fn access_token(&self) -> Option<String> {
89+ self.client.access_token()
90+ }
91+
8692 /// queue a command, waiting for room in the queue if there isn't any
8793 pub async fn send(&self, command: Command) -> Result<Pending, Error> {
8894 let (request, pending) = Request::new(command);
@@ -554,6 +560,16 @@ impl ClientHandle {
554560 self.media.timeline_media(media_id, size).await
555561 }
556562
563+ /// where `media` would fetch from, for a host streaming big files itself. the byte cap
564+ /// doesn't apply since nothing is buffered
565+ pub async fn media_source(
566+ &self,
567+ media_id: &MediaId,
568+ size: MediaSize,
569+ ) -> Result<MediaLocation, Error> {
570+ self.media.media_location(media_id, size).await
571+ }
572+
557573 /// bytes for any plain mxc uri, avatars mostly. same byte cap as everything else
558574 pub async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {
559575 self.media.mxc_media(uri, size).await
Modified file: tests/media.rs+119 additions−1 deletions
@@ -4,7 +4,8 @@ use std::{sync::Arc, time::Duration};
44
55 use macltok::{
66 AttachmentUpload, ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, Event,
7- EventStream, MediaId, MediaSize, UploadDetails, UploadOutcome, UploadThumbnail,
7+ EventStream, MediaFile, MediaId, MediaLocation, MediaSize, UploadDetails, UploadOutcome,
8+ UploadThumbnail,
89 };
910 use matrix_sdk::{
1011 media::{MediaFormat, MediaRequestParameters},
@@ -16,6 +17,7 @@ use matrix_sdk::{
1617 message::{
1718 AudioMessageEventContent, FileInfo, FileMessageEventContent,
1819 ImageMessageEventContent, MessageType, RoomMessageEventContent,
20+ VideoMessageEventContent,
1921 },
2022 },
2123 media::Method,
@@ -282,6 +284,89 @@ async fn audio_thumbnail_fetch_fails_with_media_unavailable() {
282284 }
283285
284286 #[tokio::test]
287+async fn media_source_locates_what_media_would_fetch() {
288+ let keys =
289+ matrix_sdk_crypto::AttachmentEncryptor::new(&mut std::io::Cursor::new(b"secret")).finish();
290+ let file = EncryptedFile::new(
291+ mxc_uri!("mxc://localhost/secret").to_owned(),
292+ keys.encryption_info,
293+ keys.hashes,
294+ );
295+ let wire = serde_json::to_value(&file).unwrap();
296+ let mut harness = start(RoomMessageEventContent::new(MessageType::File(
297+ FileMessageEventContent::encrypted("secret.bin".to_owned(), file),
298+ )))
299+ .await;
300+
301+ let MediaLocation::File(MediaFile {
302+ url,
303+ encryption: Some(encryption),
304+ ..
305+ }) = harness
306+ .handle
307+ .media_source(&harness.media_id, MediaSize::Full)
308+ .await
309+ .unwrap()
310+ else {
311+ panic!("an encrypted file should locate as one");
312+ };
313+ assert_eq!(
314+ url,
315+ format!(
316+ "{}/_matrix/client/v1/media/download/localhost/secret",
317+ harness.server.uri()
318+ )
319+ );
320+ assert_eq!(encryption.key, wire["key"]["k"]);
321+ assert_eq!(encryption.iv, wire["iv"]);
322+ assert_eq!(encryption.sha256, wire["hashes"]["sha256"]);
323+
324+ let video = sync_media(
325+ &mut harness,
326+ "video.mp4",
327+ RoomMessageEventContent::new(MessageType::Video(VideoMessageEventContent::plain(
328+ "video.mp4".to_owned(),
329+ mxc_uri!("mxc://localhost/video").to_owned(),
330+ ))),
331+ )
332+ .await;
333+ let MediaLocation::ServerThumbnail { url, fallback } = harness
334+ .handle
335+ .media_source(&video, thumbnail_size())
336+ .await
337+ .unwrap()
338+ else {
339+ panic!("a video with no thumbnail of its own should ask the server for one");
340+ };
341+ let url = matrix_sdk::reqwest::Url::parse(&url).unwrap();
342+ assert_eq!(
343+ url.path(),
344+ "/_matrix/client/v1/media/thumbnail/localhost/video"
345+ );
346+ let query: Vec<_> = url.query_pairs().into_owned().collect();
347+ for pair in [("width", "800"), ("height", "800"), ("method", "scale")] {
348+ assert!(query.contains(&(pair.0.to_owned(), pair.1.to_owned())));
349+ }
350+ assert_eq!(
351+ MediaLocation::File(fallback),
352+ harness
353+ .handle
354+ .media_source(&video, MediaSize::Full)
355+ .await
356+ .unwrap()
357+ );
358+
359+ let error = harness
360+ .handle
361+ .media_source(&MediaId::from("made up".to_owned()), MediaSize::Full)
362+ .await
363+ .unwrap_err();
364+ assert_eq!(error.kind, ErrorKind::MediaUnknown);
365+
366+ support::shut_down(&harness.handle, &mut harness.events, harness.running).await;
367+}
368+
369+#[tokio::test]
285370 async fn media_upload_is_queued_and_sent() {
286371 let mut harness = start(plain_image()).await;
287372 harness
@@ -373,6 +458,14 @@ async fn queued_offline_attachment_exposes_fetchable_local_media() {
373458 .await
374459 .unwrap();
375460 assert_eq!(&*fetched.bytes, &original[..]);
461+ assert_eq!(
462+ harness
463+ .handle
464+ .media_source(&media_id, MediaSize::Full)
465+ .await
466+ .unwrap(),
467+ MediaLocation::Local
468+ );
376469
377470 support::shut_down(&harness.handle, &mut harness.events, harness.running).await;
378471 }
@@ -796,6 +889,31 @@ async fn start_with_config(content: RoomMessageEventContent, config: EngineConfi
796889 }
797890 }
798891
892+async fn sync_media(
893+ harness: &mut MediaHarness,
894+ filename: &str,
895+ content: RoomMessageEventContent,
896+) -> MediaId {
897+ harness
898+ .server
899+ .sync_room(
900+ &harness.client,
901+ JoinedRoomBuilder::new(room_id!("!media:localhost"))
902+ .add_timeline_event(EventFactory::new().event(content).sender(&ALICE)),
903+ )
904+ .await;
905+ let media_id = |event: &Event| {
906+ support::event_messages(event)
907+ .into_iter()
908+ .filter_map(support::media_of)
909+ .find(|media| media.filename == filename)
910+ .map(|media| media.id.clone())
911+ };
912+ let event =
913+ support::receive_until(&mut harness.events, |event| media_id(event).is_some()).await;
914+ media_id(&event).unwrap()
915+}
916+
799917 async fn mount_downloads(server: &MatrixMockServer) {
800918 server.mock_media_download().ok_image().mount().await;
801919 server.mock_authed_media_download().ok_image().mount().await;