Skip to content

Commit 277ea86

Browse files
committed
fix(crypto): restore encrypted history and sending
1 parent 7919dff commit 277ea86

9 files changed

Lines changed: 246 additions & 18 deletions

File tree

‎src-tauri/Cargo.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ matrix-sdk = { version = "0.18", default-features = false, features = [
6868
], optional = true }
6969
# `qrcode` is off by default upstream; Sable already supports QR device
7070
# verification, so the engine needs it for parity with the wasm backend.
71-
matrix-sdk-crypto = { version = "0.18", features = ["qrcode"], optional = true }
71+
matrix-sdk-crypto = { version = "0.18", features = [
72+
"qrcode",
73+
"experimental-push-secrets",
74+
], optional = true }
7275
# `bundled` compiles SQLite from source. Android's NDK ships no libsqlite3 to link
7376
# against at any API level, so without this the aarch64 build fails at link time.
7477
matrix-sdk-sqlite = { version = "0.18", features = ["crypto-store", "bundled"], optional = true }

‎src-tauri/src/matrix_crypto/cross_signing.rs‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,7 @@ pub async fn invoke(
3838
"exportSecretsBundle" => export_secrets_bundle(machine).await,
3939
"importSecretsBundle" => import_secrets_bundle(machine, args).await,
4040

41-
// Gated in matrix-sdk-crypto 0.18 behind `experimental-push-secrets`.
42-
"pushSecretToVerifiedDevices" => Err("pushSecretToVerifiedDevices: unavailable, \
43-
matrix-sdk-crypto gates push_secret_to_verified_devices behind the \
44-
`experimental-push-secrets` feature"
45-
.to_owned()),
41+
"pushSecretToVerifiedDevices" => push_secret(machine, args).await,
4642

4743
_ => return None,
4844
})
@@ -191,3 +187,20 @@ mod tests {
191187
assert!(!object.contains_key("userSigningKey"), "{value}");
192188
}
193189
}
190+
191+
async fn push_secret(machine: &OlmMachine, args: &Value) -> Result<Value, String> {
192+
let name = opt_str_arg(args, "secretName")
193+
.ok_or_else(|| "pushSecretToVerifiedDevices: missing `secretName`".to_owned())?;
194+
195+
let failures = machine
196+
.push_secret_to_verified_devices(name.as_str().into())
197+
.await
198+
.map_err(|e| format!("pushSecretToVerifiedDevices failed: {e}"))?;
199+
200+
Ok(Value::Array(
201+
failures
202+
.keys()
203+
.map(|device| Value::String(device.to_string()))
204+
.collect(),
205+
))
206+
}

‎src-tauri/src/matrix_crypto/dispatch.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,12 @@ pub async fn invoke(machine: &OlmMachine, method: &str, args: Value) -> Result<V
322322
.encrypt_room_event_raw(&room, &event_type, &content)
323323
.await
324324
.map_err(|e| format!("encryptRoomEvent failed: {e:?}"))?;
325-
serde_json::from_str::<Value>(encrypted.content.json().get())
326-
.map_err(|e| format!("encryptRoomEvent: bad encrypted content json: {e}"))
325+
let encrypted_json = encrypted.content.json().get();
326+
serde_json::from_str::<Value>(encrypted_json)
327+
.map_err(|e| format!("encryptRoomEvent: bad encrypted content json: {e}"))?;
328+
329+
// Match wasm's JSON-string return type.
330+
Ok(Value::String(encrypted_json.to_owned()))
327331
}
328332

329333
"getSecretsFromInbox" => {

‎src-tauri/src/matrix_crypto/rooms.rs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,68 @@ pub async fn invoke(
353353
_ => return None,
354354
})
355355
}
356+
357+
#[cfg(test)]
358+
mod tests {
359+
use serde_json::json;
360+
361+
use matrix_sdk_crypto::CollectStrategy;
362+
363+
use super::{collect_strategy, encryption_settings};
364+
365+
#[test]
366+
fn parses_every_sharing_strategy_the_webview_can_send() {
367+
let cases = [
368+
(
369+
"identityBasedStrategy",
370+
CollectStrategy::IdentityBasedStrategy,
371+
),
372+
("onlyTrustedDevices", CollectStrategy::OnlyTrustedDevices),
373+
(
374+
"errorOnVerifiedUserProblem",
375+
CollectStrategy::ErrorOnVerifiedUserProblem,
376+
),
377+
("allDevices", CollectStrategy::AllDevices),
378+
];
379+
380+
for (name, expected) in cases {
381+
let parsed = collect_strategy(Some(&json!(name)), "shareRoomKey")
382+
.unwrap_or_else(|e| panic!("{name}: {e}"));
383+
assert_eq!(
384+
std::mem::discriminant(&parsed),
385+
std::mem::discriminant(&expected),
386+
"{name} parsed to the wrong strategy"
387+
);
388+
}
389+
}
390+
391+
#[test]
392+
fn rejects_an_unknown_strategy_rather_than_silently_sharing_with_everyone() {
393+
let error = collect_strategy(Some(&json!("somethingElse")), "shareRoomKey").unwrap_err();
394+
assert!(error.contains("somethingElse"), "{error}");
395+
}
396+
397+
#[test]
398+
fn reads_the_settings_shape_the_webview_encodes() {
399+
let settings = encryption_settings(
400+
&json!({
401+
"encryptionSettings": {
402+
"algorithm": 1,
403+
"historyVisibility": 2,
404+
"rotationPeriod": 604_800_000_000u64,
405+
"rotationPeriodMessages": 100,
406+
"sharingStrategy": "onlyTrustedDevices",
407+
}
408+
}),
409+
"shareRoomKey",
410+
)
411+
.unwrap();
412+
413+
assert_eq!(settings.rotation_period_msgs, 100);
414+
assert_eq!(settings.rotation_period.as_secs(), 604_800);
415+
assert_eq!(
416+
std::mem::discriminant(&settings.sharing_strategy),
417+
std::mem::discriminant(&CollectStrategy::OnlyTrustedDevices),
418+
);
419+
}
420+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { OlmMachineProxy } from './proxy';
3+
import { engineInvoke } from './engineInvoke';
4+
5+
vi.mock('./engineInvoke', () => ({
6+
engineInvoke: vi.fn<(...args: never[]) => Promise<unknown>>(async () => ({
7+
importedCount: 0,
8+
totalCount: 0,
9+
})),
10+
}));
11+
vi.mock('$generated/tauri/commands', () => ({ engineClose: vi.fn<() => Promise<void>>() }));
12+
13+
const mockInvoke = vi.mocked(engineInvoke);
14+
15+
const proxy = () =>
16+
new OlmMachineProxy({
17+
userId: '@me:example.org',
18+
deviceId: 'DEVICE',
19+
ed25519Key: 'ed',
20+
curve25519Key: 'curve',
21+
deviceCreationTimeMs: 0,
22+
});
23+
24+
const roomId = (id: string) => ({ toString: () => id });
25+
26+
describe('importBackedUpRoomKeys', () => {
27+
it('keeps every session when one room arrives as several map entries', async () => {
28+
mockInvoke.mockClear();
29+
const keysByRoom = new Map([
30+
[roomId('!room:example.org'), new Map([['session-a', { session_id: 'session-a' }]])],
31+
[roomId('!room:example.org'), new Map([['session-b', { session_id: 'session-b' }]])],
32+
[roomId('!other:example.org'), new Map([['session-c', { session_id: 'session-c' }]])],
33+
]);
34+
35+
await proxy().importBackedUpRoomKeys(keysByRoom, undefined, '7');
36+
37+
const args = mockInvoke.mock.calls[0]?.[2] as {
38+
keys: Record<string, Record<string, unknown>>;
39+
};
40+
expect(Object.keys(args.keys['!room:example.org'] ?? {})).toEqual(['session-a', 'session-b']);
41+
expect(Object.keys(args.keys['!other:example.org'] ?? {})).toEqual(['session-c']);
42+
});
43+
44+
it('passes the backup version through, not the progress listener', async () => {
45+
mockInvoke.mockClear();
46+
const listener = vi.fn<(a: bigint, b: bigint, c: bigint) => void>();
47+
48+
await proxy().importBackedUpRoomKeys(new Map(), listener, '7');
49+
50+
const args = mockInvoke.mock.calls[0]?.[2] as { backupVersion: unknown };
51+
expect(args.backupVersion).toBe('7');
52+
expect(listener).toHaveBeenCalled();
53+
});
54+
});

‎src/app/crypto/olmMachine/ipcContract.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@ describe('engine IPC contract', () => {
3232

3333
expect([...called].filter((method) => !handled(method)).toSorted()).toEqual([]);
3434
});
35+
36+
it('returns encrypted room content using the wasm JSON-string contract', () => {
37+
expect(rust).toMatch(
38+
/"encryptRoomEvent" =>[\s\S]*Ok\(Value::String\(encrypted_json\.to_owned\(\)\)\)/
39+
);
40+
});
3541
});

‎src/app/crypto/olmMachine/proxy.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { engineClose } from '$generated/tauri/commands';
33
import {
44
encodeDecryptionSettings,
55
encodeEncryptionSettings,
6+
encodeRoomSettings,
67
graftWasmPrototypes,
78
keyToBase64,
89
RustSdkCryptoJs,
10+
toMegolmDecryptionError,
911
} from './wasmClasses';
1012
import { engineInvoke, type EngineIdentity } from './engineInvoke';
1113
import type { HydrationContext } from './hydrate';
@@ -336,11 +338,15 @@ export class OlmMachineProxy {
336338
}
337339

338340
async decryptRoomEvent(event: string, roomId: unknown, ...rest: unknown[]): Promise<unknown> {
339-
return this.#call('decryptRoomEvent', {
340-
event,
341-
roomId: String(roomId),
342-
decryptionSettings: encodeDecryptionSettings(rest.at(-1)),
343-
});
341+
try {
342+
return await this.#call('decryptRoomEvent', {
343+
event,
344+
roomId: String(roomId),
345+
decryptionSettings: encodeDecryptionSettings(rest.at(-1)),
346+
});
347+
} catch (error) {
348+
throw toMegolmDecryptionError(error);
349+
}
344350
}
345351

346352
async encryptRoomEvent(roomId: unknown, eventType: string, content: string): Promise<unknown> {
@@ -391,7 +397,10 @@ export class OlmMachineProxy {
391397
}
392398

393399
async setRoomSettings(roomId: unknown, settings: unknown): Promise<void> {
394-
await this.#call('setRoomSettings', { roomId: String(roomId), settings });
400+
await this.#call('setRoomSettings', {
401+
roomId: String(roomId),
402+
settings: encodeRoomSettings(settings),
403+
});
395404
}
396405

397406
async roomKeyCounts(): Promise<unknown> {
@@ -514,14 +523,14 @@ export class OlmMachineProxy {
514523
progressListener?: (progress: bigint, total: bigint, failures: bigint) => void,
515524
backupVersion?: string
516525
): Promise<unknown> {
526+
// RoomId keys compare by identity, so merge entries by their string value.
517527
const keys: Record<string, Record<string, unknown>> = {};
518528
if (keysByRoom instanceof Map) {
519529
for (const [roomId, sessions] of keysByRoom) {
520-
const room: Record<string, unknown> = {};
530+
const room = (keys[String(roomId)] ??= {});
521531
if (sessions instanceof Map) {
522532
for (const [sessionId, key] of sessions) room[String(sessionId)] = key;
523533
}
524-
keys[String(roomId)] = room;
525534
}
526535
}
527536

‎src/app/crypto/olmMachine/wasmClasses.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import * as RustSdkCryptoJs from '@matrix-org/matrix-sdk-crypto-wasm';
22
import { describe, expect, it } from 'vitest';
33
import {
44
encodeDecryptionSettings,
5+
encodeRoomSettings,
56
encodeEncryptionSettings,
67
graftWasmPrototypes,
78
keyToBase64,
9+
toMegolmDecryptionError,
810
} from './wasmClasses';
911
import type { HydrationContext } from './hydrate';
1012

@@ -22,6 +24,26 @@ describe('keyToBase64', () => {
2224
});
2325
});
2426

27+
describe('toMegolmDecryptionError', () => {
28+
it('preserves the missing-room-key type needed by the backup downloader', () => {
29+
const error = toMegolmDecryptionError(
30+
'decryptRoomEvent failed: MissingRoomKey(None)'
31+
) as RustSdkCryptoJs.MegolmDecryptionError;
32+
33+
expect(error).toBeInstanceOf(RustSdkCryptoJs.MegolmDecryptionError);
34+
expect(error.code).toBe(RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey);
35+
expect(error.maybe_withheld).toBeUndefined();
36+
});
37+
38+
it('uses the generic wasm error code for an unclassified Rust failure', () => {
39+
const error = toMegolmDecryptionError(
40+
'decryptRoomEvent failed: Store error'
41+
) as RustSdkCryptoJs.MegolmDecryptionError;
42+
43+
expect(error.code).toBe(RustSdkCryptoJs.DecryptionErrorCode.UnableToDecrypt);
44+
});
45+
});
46+
2547
describe('encodeEncryptionSettings', () => {
2648
it('loses nothing that JSON.stringify would drop', () => {
2749
const settings = new RustSdkCryptoJs.EncryptionSettings();
@@ -100,3 +122,20 @@ describe('graftWasmPrototypes', () => {
100122
expect(Object.hasOwn(grafted, 'id')).toBe(true);
101123
});
102124
});
125+
126+
describe('encodeRoomSettings', () => {
127+
it('reads the wasm accessors that JSON.stringify would drop', () => {
128+
const settings = new RustSdkCryptoJs.RoomSettings();
129+
settings.onlyAllowTrustedDevices = true;
130+
settings.sessionRotationPeriodMs = 604800000;
131+
settings.sessionRotationPeriodMessages = 100;
132+
133+
expect(JSON.parse(JSON.stringify(settings))).not.toHaveProperty('onlyAllowTrustedDevices');
134+
expect(encodeRoomSettings(settings)).toMatchObject({
135+
algorithm: settings.algorithm,
136+
onlyAllowTrustedDevices: true,
137+
sessionRotationPeriodMs: 604800000,
138+
sessionRotationPeriodMessages: 100,
139+
});
140+
});
141+
});

‎src/app/crypto/olmMachine/wasmClasses.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,23 @@ export const collectStrategyName = (strategy: unknown): string => {
3232
return 'allDevices';
3333
};
3434

35-
// Wasm accessors live on the prototype, so JSON would carry only the internal pointer, and a
36-
// dropped `sharingStrategy` silently means "share room keys with every device".
3735
const num = (value: unknown) => (typeof value === 'bigint' ? Number(value) : value);
3836

37+
// Read wasm prototype getters before crossing the JSON boundary.
38+
export const encodeRoomSettings = (settings: unknown): Record<string, unknown> | null => {
39+
if (settings === null || typeof settings !== 'object') return null;
40+
const s = settings as Record<string, unknown>;
41+
return {
42+
algorithm: s.algorithm,
43+
encryptStateEvents: s.encryptStateEvents,
44+
onlyAllowTrustedDevices: s.onlyAllowTrustedDevices,
45+
sessionRotationPeriodMs: num(s.sessionRotationPeriodMs),
46+
sessionRotationPeriodMessages: num(s.sessionRotationPeriodMessages),
47+
};
48+
};
49+
50+
// Wasm accessors live on the prototype, so JSON would carry only the internal pointer, and a
51+
// dropped `sharingStrategy` silently means "share room keys with every device".
3952
export const encodeEncryptionSettings = (settings: unknown): Record<string, unknown> | null => {
4053
if (settings === null || typeof settings !== 'object') return null;
4154
const s = settings as Record<string, unknown>;
@@ -53,6 +66,28 @@ export const encodeDecryptionSettings = (settings: unknown): Record<string, unkn
5366
return { senderDeviceTrustRequirement: typeof trust === 'number' ? trust : 0 };
5467
};
5568

69+
const errorMessage = (error: unknown): string =>
70+
error instanceof Error ? error.message : String(error);
71+
72+
export const toMegolmDecryptionError = (error: unknown): unknown => {
73+
const description = errorMessage(error);
74+
const codes = RustSdkCryptoJs.DecryptionErrorCode;
75+
let code = codes.UnableToDecrypt;
76+
77+
if (description.includes('MissingRoomKey')) code = codes.MissingRoomKey;
78+
else if (description.includes('UnknownMessageIndex')) code = codes.UnknownMessageIndex;
79+
else if (description.includes('MismatchedIdentityKeys')) code = codes.MismatchedIdentityKeys;
80+
else if (description.includes('VerificationViolation')) {
81+
code = codes.SenderIdentityVerificationViolation;
82+
} else if (description.includes('UnsignedDevice')) code = codes.UnsignedSenderDevice;
83+
else if (description.includes('SenderIdentityNotTrusted(None')) code = codes.UnknownSenderDevice;
84+
else if (description.includes('MismatchedSender')) code = codes.MismatchedSender;
85+
86+
const wrapped = { code, description, maybe_withheld: undefined };
87+
Object.setPrototypeOf(wrapped, RustSdkCryptoJs.MegolmDecryptionError.prototype);
88+
return wrapped;
89+
};
90+
5691
const hasOwnPrototype = (name: string): boolean => {
5792
const candidate = (RustSdkCryptoJs as Record<string, unknown>)[name];
5893
return typeof candidate === 'function' && 'prototype' in candidate;

0 commit comments

Comments
 (0)