Skip to content

Commit cbf2d45

Browse files
authored
Allow setting a specific impersonation device for appservices (#43)
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR --> ## Checklist * [ ] Tests written for all new code * [ ] Linter has been satisfied * [ ] Sign-off given on the changes (see CONTRIBUTING.md)
2 parents db39b0a + 583b1cd commit cbf2d45

File tree

8 files changed

+101
-37
lines changed

8 files changed

+101
-37
lines changed

src/appservice/Intent.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ export class Intent {
9797

9898
/**
9999
* Sets up crypto on the client if it hasn't already been set up.
100+
* @param providedDeviceId Optional device ID. If given, this will used instead of trying to
101+
* masquerade as the first non-key enabled device.
100102
* @returns {Promise<void>} Resolves when complete.
101103
*/
102104
@timedIntentFunctionCall()
103-
public async enableEncryption(): Promise<void> {
105+
public async enableEncryption(providedDeviceId?: string): Promise<void> {
104106
if (!this.cryptoSetupPromise) {
105107
// eslint-disable-next-line no-async-promise-executor
106108
this.cryptoSetupPromise = new Promise(async (resolve, reject) => {
@@ -116,24 +118,38 @@ export class Intent {
116118
throw new Error("Failed to create crypto store");
117119
}
118120

119-
// Try to impersonate a device ID
120-
const ownDevices = await this.client.getOwnDevices();
121121
let deviceId = await cryptoStore.getDeviceId();
122-
if (!deviceId || !ownDevices.some(d => d.device_id === deviceId)) {
123-
const deviceKeys = await this.client.getUserDevices([this.userId]);
124-
const userDeviceKeys = deviceKeys.device_keys[this.userId];
125-
if (userDeviceKeys) {
126-
// We really should be validating signatures here, but we're actively looking
127-
// for devices without keys to impersonate, so it should be fine. In theory,
128-
// those devices won't even be present but we're cautious.
129-
const devicesWithKeys = Array.from(Object.entries(userDeviceKeys))
130-
.filter(d => d[0] === d[1].device_id && !!d[1].keys?.[`${DeviceKeyAlgorithm.Curve25519}:${d[1].device_id}`])
131-
.map(t => t[0]); // grab device ID from tuple
132-
deviceId = ownDevices.find(d => !devicesWithKeys.includes(d.device_id))?.device_id;
122+
if (!providedDeviceId) {
123+
// Try to impersonate a device ID
124+
const ownDevices = await this.client.getOwnDevices();
125+
let deviceId = await cryptoStore.getDeviceId();
126+
if (!deviceId || !ownDevices.some(d => d.device_id === deviceId)) {
127+
const deviceKeys = await this.client.getUserDevices([this.userId]);
128+
const userDeviceKeys = deviceKeys.device_keys[this.userId];
129+
if (userDeviceKeys) {
130+
// We really should be validating signatures here, but we're actively looking
131+
// for devices without keys to impersonate, so it should be fine. In theory,
132+
// those devices won't even be present but we're cautious.
133+
const devicesWithKeys = Array.from(Object.entries(userDeviceKeys))
134+
.filter(d => d[0] === d[1].device_id && !!d[1].keys?.[`${DeviceKeyAlgorithm.Curve25519}:${d[1].device_id}`])
135+
.map(t => t[0]); // grab device ID from tuple
136+
deviceId = ownDevices.find(d => !devicesWithKeys.includes(d.device_id))?.device_id;
137+
}
133138
}
139+
} else {
140+
if (deviceId && deviceId !== providedDeviceId) {
141+
LogService.warn(`Storage already configured with an existing device ${deviceId}. Old storage will be cleared.`);
142+
}
143+
deviceId = providedDeviceId;
134144
}
135145
let prepared = false;
146+
136147
if (deviceId) {
148+
const cryptoStore = this.cryptoStorage?.storageForUser(this.userId);
149+
const existingDeviceId = await cryptoStore.getDeviceId();
150+
if (existingDeviceId && existingDeviceId !== deviceId) {
151+
LogService.warn("Intent", `Device ID has changed for user ${this.userId} from ${existingDeviceId} to ${deviceId}`);
152+
}
137153
this.makeClient(true);
138154
this.client.impersonateUserId(this.userId, deviceId);
139155

src/e2ee/CryptoClient.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,25 @@ export class CryptoClient {
7272
if (this.ready) return; // stop re-preparing here
7373

7474
const storedDeviceId = await this.client.cryptoStore.getDeviceId();
75-
if (storedDeviceId) {
76-
this.deviceId = storedDeviceId;
77-
} else {
78-
const deviceId = (await this.client.getWhoAmI())['device_id'];
79-
if (!deviceId) {
80-
throw new Error("Encryption not possible: server not revealing device ID");
81-
}
82-
this.deviceId = deviceId;
83-
await this.client.cryptoStore.setDeviceId(this.deviceId);
75+
const { user_id: userId, device_id: deviceId } = (await this.client.getWhoAmI());
76+
77+
if (!deviceId) {
78+
throw new Error("Encryption not possible: server not revealing device ID");
79+
}
80+
81+
const storagePath = await this.storage.getMachineStoragePath(deviceId);
82+
83+
if (storedDeviceId !== deviceId) {
84+
this.client.cryptoStore.setDeviceId(deviceId);
8485
}
86+
this.deviceId = deviceId;
8587

86-
LogService.debug("CryptoClient", "Starting with device ID:", this.deviceId);
88+
LogService.debug("CryptoClient", `Starting ${userId} with device ID:`, this.deviceId);
8789

8890
const machine = await OlmMachine.initialize(
89-
new UserId(await this.client.getUserId()),
91+
new UserId(userId),
9092
new DeviceId(this.deviceId),
91-
this.storage.storagePath, "",
93+
storagePath, "",
9294
this.storage.storageType,
9395
);
9496
this.engine = new RustEngine(machine, this.client);

src/storage/IAppserviceStorageProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ export interface IAppserviceStorageProvider {
4747
export interface IAppserviceCryptoStorageProvider {
4848
/**
4949
* Gets a storage provider to use for the given user ID.
50-
* @param {string} userId The user ID.
51-
* @returns {ICryptoStorageProvider} The storage provider.
50+
* @param userId The user ID.
51+
* @returns The storage provider.
5252
*/
5353
storageForUser(userId: string): ICryptoStorageProvider;
5454
}

src/storage/RustSdkCryptoStorageProvider.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@ import * as lowdb from "lowdb";
22
import * as FileSync from "lowdb/adapters/FileSync";
33
import * as mkdirp from "mkdirp";
44
import * as path from "path";
5+
import { stat, rename, mkdir } from "fs/promises";
6+
import { PathLike } from "fs";
57
import * as sha512 from "hash.js/lib/hash/sha/512";
68
import * as sha256 from "hash.js/lib/hash/sha/256";
79
import { StoreType as RustSdkCryptoStoreType } from "@matrix-org/matrix-sdk-crypto-nodejs";
810

911
import { ICryptoStorageProvider } from "./ICryptoStorageProvider";
1012
import { IAppserviceCryptoStorageProvider } from "./IAppserviceStorageProvider";
1113
import { ICryptoRoomInformation } from "../e2ee/ICryptoRoomInformation";
14+
import { LogService } from "../logging/LogService";
1215

1316
export { RustSdkCryptoStoreType };
1417

18+
async function doesFileExist(path: PathLike) {
19+
return stat(path).then(() => true).catch(() => false);
20+
}
21+
1522
/**
1623
* A crypto storage provider for the file-based rust-sdk store.
1724
* @category Storage providers
@@ -40,6 +47,37 @@ export class RustSdkCryptoStorageProvider implements ICryptoStorageProvider {
4047
});
4148
}
4249

50+
public async getMachineStoragePath(deviceId: string): Promise<string> {
51+
const newPath = path.join(this.storagePath, sha256().update(deviceId).digest('hex'));
52+
if (await doesFileExist(newPath)) {
53+
// Already exists, short circuit.
54+
return newPath;
55+
} // else: If the path does NOT exist we might need to perform a migration.
56+
57+
const legacyFilePath = path.join(this.storagePath, 'matrix-sdk-crypto.sqlite3');
58+
// XXX: Slightly gross cross-dependency file name expectations.
59+
if (await doesFileExist(legacyFilePath) === false) {
60+
// No machine files at all, we can skip.
61+
return newPath;
62+
}
63+
const legacyDeviceId = await this.getDeviceId();
64+
// We need to move the file.
65+
const previousDevicePath = path.join(this.storagePath, sha256().update(legacyDeviceId).digest('hex'));
66+
LogService.warn("RustSdkCryptoStorageProvider", `Migrating path for SDK database for legacy device ${legacyDeviceId}`);
67+
await mkdir(previousDevicePath);
68+
await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3')).catch((ex) =>
69+
LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3`, ex),
70+
);
71+
await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3-shm')).catch((ex) =>
72+
LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3-shm`, ex),
73+
);
74+
await rename(legacyFilePath, path.join(previousDevicePath, 'matrix-sdk-crypto.sqlite3-wal')).catch((ex) =>
75+
LogService.warn("RustSdkCryptoStorageProvider", `Could not migrate matrix-sdk-crypto.sqlite3-wal`, ex),
76+
);
77+
78+
return newPath;
79+
}
80+
4381
public async getDeviceId(): Promise<string> {
4482
return this.db.get('deviceId').value();
4583
}
@@ -75,7 +113,7 @@ export class RustSdkAppserviceCryptoStorageProvider extends RustSdkCryptoStorage
75113

76114
public storageForUser(userId: string): ICryptoStorageProvider {
77115
// sha256 because sha512 is a bit big for some operating systems
78-
const key = sha256().update(userId).digest('hex');
79-
return new RustSdkCryptoStorageProvider(path.join(this.baseStoragePath, key), this.storageType);
116+
const storagePath = path.join(this.baseStoragePath, sha256().update(userId).digest('hex'));
117+
return new RustSdkCryptoStorageProvider(storagePath, this.storageType);
80118
}
81119
}

test/MatrixClientTest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ describe('MatrixClient', () => {
10431043
});
10441044

10451045
it('should request the user ID if it is not known', async () => {
1046-
const { client, http } = createTestClient();
1046+
const { client, http } = createTestClient(undefined, undefined, undefined, { handleWhoAmI: false });
10471047

10481048
const userId = "@example:example.org";
10491049
const response = {
@@ -1061,7 +1061,7 @@ describe('MatrixClient', () => {
10611061

10621062
describe('getWhoAmI', () => {
10631063
it('should call the right endpoint', async () => {
1064-
const { client, http } = createTestClient();
1064+
const { client, http } = createTestClient(undefined, undefined, undefined, { handleWhoAmI: false });
10651065

10661066
const response = {
10671067
user_id: "@user:example.org",

test/SynapseAdminApisTest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function createTestSynapseAdminClient(
2424
hsUrl: string;
2525
accessToken: string;
2626
} {
27-
const result = createTestClient(storage);
27+
const result = createTestClient(storage, undefined, undefined, { handleWhoAmI: false });
2828
const mxClient = result.client;
2929
const client = new SynapseAdminApis(mxClient);
3030

test/TestUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function createTestClient(
3131
storage: IStorageProvider = null,
3232
userId: string = null,
3333
cryptoStoreType?: StoreType,
34+
opts = { handleWhoAmI: true },
3435
): {
3536
client: MatrixClient;
3637
http: HttpBackend;
@@ -44,6 +45,11 @@ export function createTestClient(
4445
(<any>client).userId = userId; // private member access
4546
setRequestFn(http.requestFn);
4647

48+
if (opts.handleWhoAmI) {
49+
// Ensure we always respond to a whoami
50+
client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID });
51+
}
52+
4753
return { http, hsUrl, accessToken, client };
4854
}
4955

test/encryption/CryptoClientTest.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ describe('CryptoClient', () => {
88
it('should not have a device ID or be ready until prepared', () => testCryptoStores(async (cryptoStoreType) => {
99
const userId = "@alice:example.org";
1010
const { client, http } = createTestClient(null, userId, cryptoStoreType);
11-
1211
client.getWhoAmI = () => Promise.resolve({ user_id: userId, device_id: TEST_DEVICE_ID });
1312

1413
expect(client.crypto).toBeDefined();
@@ -46,17 +45,20 @@ describe('CryptoClient', () => {
4645
const { client, http } = createTestClient(null, userId, cryptoStoreType);
4746

4847
await client.cryptoStore.setDeviceId(TEST_DEVICE_ID);
48+
const CORRECT_DEVICE = "new_device";
4949

50-
const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: "wrong" }));
50+
const whoamiSpy = simple.stub().callFn(() => Promise.resolve({ user_id: userId, device_id: CORRECT_DEVICE }));
5151
client.getWhoAmI = whoamiSpy;
5252

5353
bindNullEngine(http);
5454
await Promise.all([
5555
client.crypto.prepare(),
5656
http.flushAllExpected(),
5757
]);
58-
expect(whoamiSpy.callCount).toEqual(0);
59-
expect(client.crypto.clientDeviceId).toEqual(TEST_DEVICE_ID);
58+
// This should be called to check
59+
expect(whoamiSpy.callCount).toEqual(1);
60+
expect(client.crypto.clientDeviceId).toEqual(CORRECT_DEVICE);
61+
expect(await client.cryptoStore.getDeviceId()).toEqual(CORRECT_DEVICE);
6062
}));
6163
});
6264

0 commit comments

Comments
 (0)