diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js index 5be5dba23..3f39e536d 100644 --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,228 +1,224 @@ // @flow import invariant from 'invariant'; import type { IdentityServiceClient, RawDeviceList, SignedDeviceList, } from '../types/identity-service-types.js'; import { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { composeRawDeviceList, rawDeviceListFromSignedList, } from '../utils/device-list-utils.js'; export type DeviceListVerificationResult = | { +valid: true, +deviceList: RawDeviceList } | DeviceListVerificationFailure; type DeviceListVerificationFailure = | { +valid: false, +reason: 'empty_device_list_history' } | { +valid: false, +reason: 'empty_device_list_update', +timestamp: number } | { +valid: false, +reason: 'invalid_timestamp_order', +timestamp: number } | { +valid: false, +reason: 'invalid_cur_primary_signature', +timestamp: number, } | { +valid: false, +reason: 'invalid_last_primary_signature', +timestamp: number, }; // Verifies all device list updates for given `userID` since // last known (and valid) device list. The updates are fetched // from Identity Service. If `lastKnownDeviceList` is not provided, // the whole device list history will be verified. // Returns latest device list from Identity Service. async function verifyAndGetDeviceList( identityClient: IdentityServiceClient, userID: string, lastKnownDeviceList: ?SignedDeviceList, ): Promise { let since; if (lastKnownDeviceList) { const rawList = rawDeviceListFromSignedList(lastKnownDeviceList); since = rawList.timestamp; } const history = await identityClient.getDeviceListHistoryForUser( userID, since, ); if (history.length < 1) { return { valid: false, reason: 'empty_device_list_history' }; } const [firstUpdate, ...updates] = history; const deviceListUpdates = lastKnownDeviceList ? history : updates; let previousDeviceList = lastKnownDeviceList ?? firstUpdate; const { olmAPI } = getConfig(); for (const deviceList of deviceListUpdates) { const currentPayload = rawDeviceListFromSignedList(deviceList); const previousPayload = rawDeviceListFromSignedList(previousDeviceList); // verify timestamp order const { timestamp } = currentPayload; if (previousPayload.timestamp >= timestamp) { return { valid: false, reason: 'invalid_timestamp_order', timestamp, }; } const currentPrimaryDeviceID = currentPayload.devices[0]; const previousPrimaryDeviceID = previousPayload.devices[0]; if (!currentPrimaryDeviceID || !previousPrimaryDeviceID) { return { valid: false, reason: 'empty_device_list_update', timestamp }; } // verify signatures if (deviceList.curPrimarySignature) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.curPrimarySignature, currentPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_cur_primary_signature', timestamp, }; } } if ( currentPrimaryDeviceID !== previousPrimaryDeviceID && deviceList.lastPrimarySignature ) { // verify signature using previous primary device signature const signatureValid = await olmAPI.verifyMessage( deviceList.rawDeviceList, deviceList.lastPrimarySignature, previousPrimaryDeviceID, ); if (!signatureValid) { return { valid: false, reason: 'invalid_last_primary_signature', timestamp, }; } } previousDeviceList = deviceList; } const deviceList = rawDeviceListFromSignedList(previousDeviceList); return { valid: true, deviceList }; } async function createAndSignSingletonDeviceList( primaryDeviceID: string, ): Promise { const initialDeviceList = composeRawDeviceList([primaryDeviceID]); const rawDeviceList = JSON.stringify(initialDeviceList); const { olmAPI } = getConfig(); const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); return { rawDeviceList, curPrimarySignature, }; } async function signDeviceListUpdate( deviceListPayload: RawDeviceList, ): Promise { const deviceID = await getContentSigningKey(); - const rawDeviceList = JSON.stringify(deviceListPayload); - - // don't sign device list if current device is not a primary one if (deviceListPayload.devices[0] !== deviceID) { - return { - rawDeviceList, - }; + throw new Error('non-primary device tried to sign device list'); } const { olmAPI } = getConfig(); + const rawDeviceList = JSON.stringify(deviceListPayload); const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); return { rawDeviceList, curPrimarySignature, }; } async function fetchLatestDeviceList( identityClient: IdentityServiceClient, userID: string, ): Promise { const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); if (deviceLists.length < 1) { throw new Error('received empty device list history'); } const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; return rawDeviceListFromSignedList(lastSignedDeviceList); } async function addDeviceToDeviceList( identityClient: IdentityServiceClient, userID: string, newDeviceID: string, ) { const { updateDeviceList } = identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined on native. ' + 'Are you calling it on a non-primary device?', ); const { devices } = await fetchLatestDeviceList(identityClient, userID); if (devices.includes(newDeviceID)) { // the device was already on the device list return; } const newDeviceList = composeRawDeviceList([...devices, newDeviceID]); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); } async function removeDeviceFromDeviceList( identityClient: IdentityServiceClient, userID: string, deviceIDToRemove: string, ): Promise { const { updateDeviceList } = identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined on native. ' + 'Are you calling it on a non-primary device?', ); const { devices } = await fetchLatestDeviceList(identityClient, userID); const newDevices = devices.filter(it => it !== deviceIDToRemove); if (devices.length === newDevices.length) { // the device wasn't on the device list return; } const newDeviceList = composeRawDeviceList(newDevices); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); } export { verifyAndGetDeviceList, createAndSignSingletonDeviceList, fetchLatestDeviceList, addDeviceToDeviceList, removeDeviceFromDeviceList, signDeviceListUpdate, }; diff --git a/lib/shared/device-list-utils.test.js b/lib/shared/device-list-utils.test.js index 5eb7bb3ca..b6c2fc4b0 100644 --- a/lib/shared/device-list-utils.test.js +++ b/lib/shared/device-list-utils.test.js @@ -1,286 +1,286 @@ // @flow import { createAndSignSingletonDeviceList, verifyAndGetDeviceList, addDeviceToDeviceList, removeDeviceFromDeviceList, } from './device-list-utils.js'; import type { RawDeviceList, SignedDeviceList, IdentityServiceClient, } from '../types/identity-service-types.js'; import * as config from '../utils/config.js'; import { rawDeviceListFromSignedList } from '../utils/device-list-utils.js'; // mockOlmAPIVerification replaces OlmAPI with a mock // save original to avoid affecting other test suites let originalConfig; beforeAll(() => { if (config.hasConfig()) { originalConfig = config.getConfig(); } }); afterAll(() => { if (originalConfig) { config.registerConfig(originalConfig); } }); describe(verifyAndGetDeviceList, () => { it('succeeds for unsigned device list updates', async () => { mockOlmAPIVerification(jest.fn()); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), createDeviceList({ devices: ['D1', 'D2'], timestamp: 2 }), createDeviceList({ devices: ['D1'], timestamp: 3 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(true); expect(result.deviceList).toMatchObject({ devices: ['D1'], timestamp: 3 }); }); it('fails for empty device list history', async () => { const updates: $ReadOnlyArray = []; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(false); expect(result.reason).toEqual('empty_device_list_history'); }); it('fails for incorrect timestamp order', async () => { mockOlmAPIVerification(jest.fn()); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 2 }), createDeviceList({ devices: ['D2'], timestamp: 1 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(false); expect(result.reason).toEqual('invalid_timestamp_order'); expect(result.timestamp).toEqual(1); }); it('fails for empty device list updates', async () => { mockOlmAPIVerification(jest.fn()); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), createDeviceList({ devices: ['D2'], timestamp: 2 }), createDeviceList({ devices: [], timestamp: 3 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(false); expect(result.reason).toEqual('empty_device_list_update'); expect(result.timestamp).toEqual(3); }); it('verifies primary signature', async () => { const verifySignature = jest .fn() .mockImplementation((_, signature, primaryDeviceID) => Promise.resolve(signature === `${primaryDeviceID}_signature`), ); mockOlmAPIVerification(verifySignature); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), createDeviceList({ devices: ['D1', 'D2'], timestamp: 2 }, 'D1_signature'), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(true); expect(verifySignature).toHaveBeenCalledTimes(1); }); it('fails for invalid primary signature', async () => { const verifySignature = jest .fn() .mockImplementation(() => Promise.resolve(false)); mockOlmAPIVerification(verifySignature); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), createDeviceList({ devices: ['D1', 'D2'], timestamp: 2 }, 'invalid'), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(verifySignature).toBeCalledWith(expect.anything(), 'invalid', 'D1'); expect(result.valid).toEqual(false); expect(result.reason).toEqual('invalid_cur_primary_signature'); expect(result.timestamp).toEqual(2); }); it('verifies both signatures if primary device changes', async () => { const verifySignature = jest .fn() .mockImplementation((_, signature, primaryDeviceID) => Promise.resolve(signature === `${primaryDeviceID}_signature`), ); mockOlmAPIVerification(verifySignature); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), createDeviceList( { devices: ['D2', 'D3'], timestamp: 2 }, 'D2_signature', 'D1_signature', ), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); const result = await verifyAndGetDeviceList(identityClient, 'foo'); expect(result.valid).toEqual(true); expect(verifySignature).toHaveBeenCalledTimes(2); expect(verifySignature).toHaveBeenNthCalledWith( 1, // first it verifies curSignature expect.anything(), 'D2_signature', 'D2', ); expect(verifySignature).toHaveBeenNthCalledWith( 2, // then it verifies lastSignature expect.anything(), 'D1_signature', 'D1', ); }); }); describe(createAndSignSingletonDeviceList, () => { it('creates initial device list', async () => { const signMessage = jest .fn<[string], string>() .mockResolvedValue('mock_signature'); mockOlmAPISign(signMessage); const payload = await createAndSignSingletonDeviceList('device1'); expect(payload.curPrimarySignature).toStrictEqual('mock_signature'); const raw = rawDeviceListFromSignedList(payload); expect(raw.devices).toStrictEqual(['device1']); }); }); describe(addDeviceToDeviceList, () => { it('adds device to device list', async () => { - mockOlmAPISign(); + mockOlmAPISign(defaultOlmAPISignMock, 'D1'); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); await addDeviceToDeviceList(identityClient, 'user1', 'D2'); expect(identityClient.updateDeviceList).toHaveBeenCalledWith( matchingSignedDeviceListWithDevices(['D1', 'D2']), ); }); it('skips adding device to device list if already exists', async () => { const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1', 'D2'], timestamp: 1 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); await addDeviceToDeviceList(identityClient, 'user1', 'D2'); expect(identityClient.updateDeviceList).not.toHaveBeenCalled(); }); }); describe(removeDeviceFromDeviceList, () => { it('removes device from device list', async () => { - mockOlmAPISign(); + mockOlmAPISign(defaultOlmAPISignMock, 'D1'); const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1', 'D2'], timestamp: 1 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); await removeDeviceFromDeviceList(identityClient, 'user1', 'D2'); expect(identityClient.updateDeviceList).toHaveBeenCalledWith( matchingSignedDeviceListWithDevices(['D1']), ); }); it("skips removing device from device list if it doesn't exist", async () => { const updates: $ReadOnlyArray = [ createDeviceList({ devices: ['D1'], timestamp: 1 }), ]; const identityClient = mockIdentityClientWithDeviceListHistory(updates); await removeDeviceFromDeviceList(identityClient, 'user1', 'D2'); expect(identityClient.updateDeviceList).not.toHaveBeenCalled(); }); }); function createDeviceList( rawList: RawDeviceList, curPrimarySignature?: string, lastPrimarySignature?: string, ): SignedDeviceList { return { rawDeviceList: JSON.stringify(rawList), curPrimarySignature, lastPrimarySignature, }; } function mockIdentityClientWithDeviceListHistory( history: $ReadOnlyArray, ): IdentityServiceClient { const client: Partial = { getDeviceListHistoryForUser: jest .fn() .mockResolvedValueOnce(history), updateDeviceList: jest.fn(), }; return client; } function mockOlmAPIVerification(func: typeof jest.fn) { const olmAPI: any = { verifyMessage: func, }; const cfg: any = { olmAPI }; config.registerConfig(cfg); } const defaultOlmAPISignMock = jest .fn<[string], string>() .mockResolvedValue('mock_signature'); function mockOlmAPISign( func: JestMockFn<[string], Promise> = defaultOlmAPISignMock, ed25519: string = 'fake_ed25519_public_key', ) { const olmAPI: any = { initializeCryptoAccount: jest.fn(), getUserPublicKey: jest .fn<[], mixed>() .mockResolvedValue({ primaryIdentityPublicKeys: { ed25519 } }), signMessage: func, }; const cfg: any = { olmAPI }; config.registerConfig(cfg); } function matchingSignedDeviceListWithDevices( devices: $ReadOnlyArray, ): Object { return expect.objectContaining({ rawDeviceList: expect.stringContaining( // slice out closing braces to let it match timestamp JSON.stringify({ devices }).slice(0, -2), ), }); }