Skip to content

Commit 8e2188d

Browse files
committed
Add support for fallback keys
MSC: matrix-org/matrix-spec-proposals#2732
1 parent 161829b commit 8e2188d

File tree

5 files changed

+172
-1
lines changed

5 files changed

+172
-1
lines changed

src/MatrixClient.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ import {
2929
DeviceKeyAlgorithm,
3030
DeviceKeyLabel,
3131
EncryptionAlgorithm,
32+
FallbackKey,
3233
MultiUserDeviceListResponse,
3334
OTKAlgorithm,
3435
OTKClaimResponse,
3536
OTKCounts,
36-
OTKs
37+
OTKs,
3738
} from "./models/Crypto";
3839
import { requiresCrypto } from "./e2ee/decorators";
3940
import { ICryptoStorageProvider } from "./storage/ICryptoStorageProvider";
@@ -666,6 +667,19 @@ export class MatrixClient extends EventEmitter {
666667
this.crypto?.updateCounts(raw['device_one_time_keys_count']);
667668
}
668669

670+
let unusedFallbacks: string[] = null;
671+
if (raw['org.matrix.msc2732.device_unused_fallback_key_types']) {
672+
unusedFallbacks = raw['org.matrix.msc2732.device_unused_fallback_key_types'];
673+
} else if (raw['device_unused_fallback_key_types']) {
674+
unusedFallbacks = raw['device_unused_fallback_key_types'];
675+
}
676+
677+
// XXX: We should be able to detect the presence of the array, but Synapse doesn't tell us about
678+
// feature support if we didn't upload one, so assume we're on a latest version of Synapse at least.
679+
if (!unusedFallbacks?.includes(OTKAlgorithm.Signed)) {
680+
await this.crypto?.updateFallbackKey();
681+
}
682+
669683
if (raw['device_lists']) {
670684
const changed = raw['device_lists']['changed'];
671685
const removed = raw['device_lists']['left'];
@@ -1732,6 +1746,24 @@ export class MatrixClient extends EventEmitter {
17321746
.then(r => r['one_time_key_counts']);
17331747
}
17341748

1749+
/**
1750+
* Uploads a fallback One Time Key to the server for usage. This will replace the existing fallback
1751+
* key.
1752+
* @param {FallbackKey} fallbackKey The fallback key.
1753+
* @returns {Promise<OTKCounts>} Resolves to the One Time Key counts.
1754+
*/
1755+
@timedMatrixClientFunctionCall()
1756+
@requiresCrypto()
1757+
public async uploadFallbackKey(fallbackKey: FallbackKey): Promise<OTKCounts> {
1758+
const keyObj = {
1759+
[`${OTKAlgorithm.Signed}:${fallbackKey.keyId}`]: fallbackKey.key,
1760+
};
1761+
return this.doRequest("POST", "/_matrix/client/r0/keys/upload", null, {
1762+
"org.matrix.msc2732.fallback_keys": keyObj,
1763+
"fallback_keys": keyObj,
1764+
}).then(r => r['one_time_key_counts']);
1765+
}
1766+
17351767
/**
17361768
* Gets <b>unverified</b> device lists for the given users. The caller is expected to validate
17371769
* and verify the device lists, including that the returned devices belong to the claimed users.

src/e2ee/CryptoClient.ts

+32
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as anotherJson from "another-json";
66
import {
77
DeviceKeyAlgorithm,
88
EncryptionAlgorithm,
9+
FallbackKey,
910
IMegolmEncrypted,
1011
IMRoomKey,
1112
IOlmEncrypted,
@@ -16,6 +17,7 @@ import {
1617
OTKCounts,
1718
OTKs,
1819
Signatures,
20+
SignedCurve25519OTK,
1921
UserDevice,
2022
} from "../models/Crypto";
2123
import { requiresReady } from "./decorators";
@@ -192,6 +194,36 @@ export class CryptoClient {
192194
}
193195
}
194196

197+
/**
198+
* Updates the client's fallback key.
199+
* @returns {Promise<void>} Resolves when complete.
200+
*/
201+
@requiresReady()
202+
public async updateFallbackKey(): Promise<void> {
203+
const account = await this.getOlmAccount();
204+
try {
205+
account.generate_fallback_key();
206+
207+
const key = JSON.parse(account.fallback_key());
208+
const keyId = Object.keys(key[OTKAlgorithm.Unsigned])[0];
209+
const obj: Partial<SignedCurve25519OTK> = {
210+
key: key[OTKAlgorithm.Unsigned][keyId],
211+
fallback: true,
212+
};
213+
const signatures = await this.sign(obj);
214+
const fallback: FallbackKey = {
215+
keyId: keyId,
216+
key: {
217+
...obj,
218+
signatures: signatures,
219+
} as SignedCurve25519OTK & {fallback: true},
220+
};
221+
await this.client.uploadFallbackKey(fallback);
222+
} finally {
223+
await this.storeAndFreeOlmAccount(account);
224+
}
225+
}
226+
195227
/**
196228
* Signs an object using the device keys.
197229
* @param {object} obj The object to sign.

src/models/Crypto.ts

+10
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ export interface Signatures {
3030
export interface SignedCurve25519OTK {
3131
key: string;
3232
signatures: Signatures;
33+
fallback?: boolean;
34+
}
35+
36+
/**
37+
* A fallback key.
38+
* @category Models
39+
*/
40+
export interface FallbackKey {
41+
keyId: string;
42+
key: SignedCurve25519OTK & {fallback: true};
3343
}
3444

3545
/**

test/MatrixClientTest.ts

+44
Original file line numberDiff line numberDiff line change
@@ -2069,6 +2069,9 @@ describe('MatrixClient', () => {
20692069
const { client } = createTestClient(null, "@user:example.org", true);
20702070
const syncClient = <ProcessSyncClient>(<any>client);
20712071

2072+
// Override to fix test as we aren't testing this here.
2073+
client.crypto.updateFallbackKey = async () => null;
2074+
20722075
const deviceMessage = {
20732076
type: "m.room.encrypted",
20742077
content: {
@@ -2103,10 +2106,51 @@ describe('MatrixClient', () => {
21032106
expect(processSpy.callCount).toBe(1);
21042107
});
21052108

2109+
it('should handle fallback key updates', async () => {
2110+
const { client } = createTestClient(null, "@user:example.org", true);
2111+
const syncClient = <ProcessSyncClient>(<any>client);
2112+
2113+
await client.cryptoStore.setDeviceId(TEST_DEVICE_ID);
2114+
await feedStaticOlmAccount(client);
2115+
client.uploadDeviceKeys = () => Promise.resolve({});
2116+
client.uploadDeviceOneTimeKeys = () => Promise.resolve({});
2117+
client.checkOneTimeKeyCounts = () => Promise.resolve({});
2118+
2119+
await client.crypto.prepare([]);
2120+
2121+
const updateSpy = simple.stub();
2122+
client.crypto.updateFallbackKey = updateSpy;
2123+
2124+
// Test workaround for https://github.com/matrix-org/synapse/issues/10618
2125+
await syncClient.processSync({
2126+
// no content
2127+
});
2128+
expect(updateSpy.callCount).toBe(1);
2129+
updateSpy.reset();
2130+
2131+
// Test "no more fallback keys" state
2132+
await syncClient.processSync({
2133+
"org.matrix.msc2732.device_unused_fallback_key_types": [],
2134+
"device_unused_fallback_key_types": [],
2135+
});
2136+
expect(updateSpy.callCount).toBe(1);
2137+
updateSpy.reset();
2138+
2139+
// Test "has remaining fallback keys"
2140+
await syncClient.processSync({
2141+
"org.matrix.msc2732.device_unused_fallback_key_types": ["signed_curve25519"],
2142+
"device_unused_fallback_key_types": ["signed_curve25519"],
2143+
});
2144+
expect(updateSpy.callCount).toBe(0);
2145+
});
2146+
21062147
it('should decrypt timeline events', async () => {
21072148
const {client: realClient} = await createPreparedCryptoTestClient("@alice:example.org");
21082149
const client = <ProcessSyncClient>(<any>realClient);
21092150

2151+
// Override to fix test as we aren't testing this here.
2152+
realClient.crypto.updateFallbackKey = async () => null;
2153+
21102154
const userId = "@syncing:example.org";
21112155
const roomId = "!testing:example.org";
21122156
const events = [

test/encryption/CryptoClientTest.ts

+53
Original file line numberDiff line numberDiff line change
@@ -3417,4 +3417,57 @@ describe('CryptoClient', () => {
34173417
expect(downloadSpy.callCount).toBe(1);
34183418
});
34193419
});
3420+
3421+
describe('updateFallbackKey', () => {
3422+
const userId = "@alice:example.org";
3423+
let client: MatrixClient;
3424+
3425+
beforeEach(async () => {
3426+
const { client: mclient } = createTestClient(null, userId, true);
3427+
client = mclient;
3428+
3429+
await client.cryptoStore.setDeviceId(TEST_DEVICE_ID);
3430+
await feedStaticOlmAccount(client);
3431+
client.uploadDeviceKeys = () => Promise.resolve({});
3432+
client.uploadDeviceOneTimeKeys = () => Promise.resolve({});
3433+
client.checkOneTimeKeyCounts = () => Promise.resolve({});
3434+
3435+
// client crypto not prepared for the one test which wants that state
3436+
});
3437+
3438+
it('should fail when the crypto has not been prepared', async () => {
3439+
try {
3440+
await client.crypto.updateFallbackKey();
3441+
3442+
// noinspection ExceptionCaughtLocallyJS
3443+
throw new Error("Failed to fail");
3444+
} catch (e) {
3445+
expect(e.message).toEqual("End-to-end encryption has not initialized");
3446+
}
3447+
});
3448+
3449+
it('should create new keys', async () => {
3450+
await client.crypto.prepare([]);
3451+
3452+
const uploadSpy = simple.stub().callFn(async (k) => {
3453+
expect(k).toMatchObject({
3454+
keyId: expect.any(String),
3455+
key: {
3456+
key: expect.any(String),
3457+
fallback: true,
3458+
signatures: {
3459+
[userId]: {
3460+
[`${DeviceKeyAlgorithm.Ed25519}:${TEST_DEVICE_ID}`]: expect.any(String),
3461+
},
3462+
},
3463+
},
3464+
});
3465+
return null; // return not used
3466+
});
3467+
client.uploadFallbackKey = uploadSpy;
3468+
3469+
await client.crypto.updateFallbackKey();
3470+
expect(uploadSpy.callCount).toBe(1);
3471+
});
3472+
});
34203473
});

0 commit comments

Comments
 (0)