diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js index 488f9c0b0..4d9f1bed5 100644 --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,121 +1,137 @@ // @flow import type { IdentityServiceClient, RawDeviceList, SignedDeviceList, } from '../types/identity-service-types.js'; import { getConfig } from '../utils/config.js'; -import { rawDeviceListFromSignedList } from '../utils/device-list-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 }; } -export { verifyAndGetDeviceList }; +async function createAndSignInitialDeviceList( + primaryDeviceID: string, +): Promise { + const initialDeviceList = composeRawDeviceList([primaryDeviceID]); + const rawDeviceList = JSON.stringify(initialDeviceList); + const { olmAPI } = getConfig(); + const curPrimarySignature = await olmAPI.signMessage(rawDeviceList); + return { + rawDeviceList, + curPrimarySignature, + }; +} + +export { verifyAndGetDeviceList, createAndSignInitialDeviceList }; diff --git a/lib/shared/device-list-utils.test.js b/lib/shared/device-list-utils.test.js index 160c108e7..dad008ca3 100644 --- a/lib/shared/device-list-utils.test.js +++ b/lib/shared/device-list-utils.test.js @@ -1,178 +1,205 @@ // @flow -import { verifyAndGetDeviceList } from './device-list-utils.js'; +import { + createAndSignInitialDeviceList, + verifyAndGetDeviceList, +} 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(createAndSignInitialDeviceList, () => { + it('creates initial device list', async () => { + const signMessage = jest + .fn<[string], string>() + .mockResolvedValue('mock_signature'); + mockOlmAPISign(signMessage); + + const payload = await createAndSignInitialDeviceList('device1'); + expect(payload.curPrimarySignature).toStrictEqual('mock_signature'); + + const raw = rawDeviceListFromSignedList(payload); + expect(raw.devices).toStrictEqual(['device1']); + }); +}); + 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), }; return client; } function mockOlmAPIVerification(func: typeof jest.fn) { const olmAPI: any = { verifyMessage: func, }; const cfg: any = { olmAPI }; config.registerConfig(cfg); } + +function mockOlmAPISign(func: JestMockFn<[string], Promise>) { + const olmAPI: any = { + signMessage: func, + }; + const cfg: any = { olmAPI }; + config.registerConfig(cfg); +} diff --git a/lib/utils/device-list-utils.js b/lib/utils/device-list-utils.js index b61da2b0d..5a06a2ea3 100644 --- a/lib/utils/device-list-utils.js +++ b/lib/utils/device-list-utils.js @@ -1,37 +1,45 @@ // @flow import { assertWithValidator } from './validation-utils.js'; import type { RawDeviceList, SignedDeviceList, UsersRawDeviceLists, UsersSignedDeviceLists, } from '../types/identity-service-types.js'; import { rawDeviceListValidator } from '../types/identity-service-types.js'; +function composeRawDeviceList(devices: $ReadOnlyArray): RawDeviceList { + return { + devices, + timestamp: Date.now(), + }; +} + function convertSignedDeviceListsToRawDeviceLists( signedDeviceLists: UsersSignedDeviceLists, ): UsersRawDeviceLists { let usersRawDeviceLists: UsersRawDeviceLists = {}; for (const userID in signedDeviceLists) { usersRawDeviceLists = { ...usersRawDeviceLists, [userID]: rawDeviceListFromSignedList(signedDeviceLists[userID]), }; } return usersRawDeviceLists; } function rawDeviceListFromSignedList( signedDeviceList: SignedDeviceList, ): RawDeviceList { return assertWithValidator( JSON.parse(signedDeviceList.rawDeviceList), rawDeviceListValidator, ); } export { convertSignedDeviceListsToRawDeviceLists, rawDeviceListFromSignedList, + composeRawDeviceList, }; diff --git a/native/identity-service/identity-service-context-provider.react.js b/native/identity-service/identity-service-context-provider.react.js index dc55ddaaf..efa6c58fc 100644 --- a/native/identity-service/identity-service-context-provider.react.js +++ b/native/identity-service/identity-service-context-provider.react.js @@ -1,655 +1,662 @@ // @flow import * as React from 'react'; import { getOneTimeKeyValues } from 'lib/shared/crypto-utils.js'; +import { createAndSignInitialDeviceList } from 'lib/shared/device-list-utils.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { type IdentityKeysBlob, identityKeysBlobValidator, type OneTimeKeysResultValues, } from 'lib/types/crypto-types.js'; import { type DeviceOlmInboundKeys, deviceOlmInboundKeysValidator, type DeviceOlmOutboundKeys, deviceOlmOutboundKeysValidator, farcasterUsersValidator, identityAuthResultValidator, type IdentityServiceClient, ONE_TIME_KEYS_NUMBER, type SignedDeviceList, signedDeviceListHistoryValidator, type SignedNonce, type UserAuthMetadata, userDeviceOlmInboundKeysValidator, type UserDevicesOlmInboundKeys, type UserDevicesOlmOutboundKeys, type UsersSignedDeviceLists, usersSignedDeviceListsValidator, identitiesValidator, } from 'lib/types/identity-service-types.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import { getCommServicesAuthMetadataEmitter } from '../event-emitters/csa-auth-metadata-emitter.js'; import { commCoreModule, commRustModule } from '../native-modules.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +children: React.Node, }; function IdentityServiceContextProvider(props: Props): React.Node { const { children } = props; const userIDPromiseRef = React.useRef>(); if (!userIDPromiseRef.current) { userIDPromiseRef.current = (async () => { const { userID } = await commCoreModule.getCommServicesAuthMetadata(); return userID; })(); } React.useEffect(() => { const metadataEmitter = getCommServicesAuthMetadataEmitter(); const subscription = metadataEmitter.addListener( 'commServicesAuthMetadata', (authMetadata: UserAuthMetadata) => { userIDPromiseRef.current = Promise.resolve(authMetadata.userID); }, ); return () => subscription.remove(); }, []); const accessToken = useSelector(state => state.commServicesAccessToken); const getAuthMetadata = React.useCallback< () => Promise<{ +deviceID: string, +userID: string, +accessToken: string, }>, >(async () => { const deviceID = await getContentSigningKey(); const userID = await userIDPromiseRef.current; if (!deviceID || !userID || !accessToken) { throw new Error('Identity service client is not initialized'); } return { deviceID, userID, accessToken }; }, [accessToken]); const client = React.useMemo( () => ({ deleteWalletUser: async () => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.deleteWalletUser(userID, deviceID, token); }, deletePasswordUser: async (password: string) => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.deletePasswordUser( userID, deviceID, token, password, ); }, logOut: async () => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.logOut(userID, deviceID, token); }, getKeyserverKeys: async ( keyserverID: string, ): Promise => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.getKeyserverKeys( userID, deviceID, token, keyserverID, ); const resultObject = JSON.parse(result); const payload = resultObject?.payload; const keyserverKeys = { identityKeysBlob: payload ? JSON.parse(payload) : null, contentInitializationInfo: { prekey: resultObject?.contentPrekey, prekeySignature: resultObject?.contentPrekeySignature, oneTimeKey: resultObject?.oneTimeContentPrekey, }, notifInitializationInfo: { prekey: resultObject?.notifPrekey, prekeySignature: resultObject?.notifPrekeySignature, oneTimeKey: resultObject?.oneTimeNotifPrekey, }, payloadSignature: resultObject?.payloadSignature, }; if (!keyserverKeys.contentInitializationInfo.oneTimeKey) { throw new Error('Missing content one time key'); } if (!keyserverKeys.notifInitializationInfo.oneTimeKey) { throw new Error('Missing notif one time key'); } return assertWithValidator( keyserverKeys, deviceOlmOutboundKeysValidator, ); }, getOutboundKeysForUser: async ( targetUserID: string, ): Promise => { const { deviceID: authDeviceID, userID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.getOutboundKeysForUser( userID, authDeviceID, token, targetUserID, ); const resultArray = JSON.parse(result); return resultArray .map(outboundKeysInfo => { try { const payload = outboundKeysInfo?.payload; const identityKeysBlob: IdentityKeysBlob = assertWithValidator( payload ? JSON.parse(payload) : null, identityKeysBlobValidator, ); const deviceID = identityKeysBlob.primaryIdentityPublicKeys.ed25519; if ( !outboundKeysInfo.oneTimeContentPrekey || !outboundKeysInfo.oneTimeNotifPrekey ) { console.log(`Missing one time key for device ${deviceID}`); return { deviceID, keys: null, }; } const deviceKeys = { identityKeysBlob, contentInitializationInfo: { prekey: outboundKeysInfo?.contentPrekey, prekeySignature: outboundKeysInfo?.contentPrekeySignature, oneTimeKey: outboundKeysInfo?.oneTimeContentPrekey, }, notifInitializationInfo: { prekey: outboundKeysInfo?.notifPrekey, prekeySignature: outboundKeysInfo?.notifPrekeySignature, oneTimeKey: outboundKeysInfo?.oneTimeNotifPrekey, }, payloadSignature: outboundKeysInfo?.payloadSignature, }; try { const validatedKeys = assertWithValidator( deviceKeys, deviceOlmOutboundKeysValidator, ); return { deviceID, keys: validatedKeys, }; } catch (e) { console.log(e); return { deviceID, keys: null, }; } } catch (e) { console.log(e); return null; } }) .filter(Boolean); }, getInboundKeysForUser: async ( targetUserID: string, ): Promise => { const { deviceID: authDeviceID, userID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.getInboundKeysForUser( userID, authDeviceID, token, targetUserID, ); const resultArray = JSON.parse(result); const devicesKeys: { [deviceID: string]: ?DeviceOlmInboundKeys, } = {}; resultArray.forEach(inboundKeysInfo => { try { const payload = inboundKeysInfo?.payload; const identityKeysBlob: IdentityKeysBlob = assertWithValidator( payload ? JSON.parse(payload) : null, identityKeysBlobValidator, ); const deviceID = identityKeysBlob.primaryIdentityPublicKeys.ed25519; const deviceKeys = { identityKeysBlob, signedPrekeys: { contentPrekey: inboundKeysInfo?.contentPrekey, contentPrekeySignature: inboundKeysInfo?.contentPrekeySignature, notifPrekey: inboundKeysInfo?.notifPrekey, notifPrekeySignature: inboundKeysInfo?.notifPrekeySignature, }, payloadSignature: inboundKeysInfo?.payloadSignature, }; try { devicesKeys[deviceID] = assertWithValidator( deviceKeys, deviceOlmInboundKeysValidator, ); } catch (e) { console.log(e); devicesKeys[deviceID] = null; } } catch (e) { console.log(e); } }); const device = resultArray?.[0]; const inboundUserKeys = { keys: devicesKeys, username: device?.username, walletAddress: device?.walletAddress, }; return assertWithValidator( inboundUserKeys, userDeviceOlmInboundKeysValidator, ); }, uploadOneTimeKeys: async (oneTimeKeys: OneTimeKeysResultValues) => { const { deviceID: authDeviceID, userID, accessToken: token, } = await getAuthMetadata(); await commRustModule.uploadOneTimeKeys( userID, authDeviceID, token, oneTimeKeys.contentOneTimeKeys, oneTimeKeys.notificationsOneTimeKeys, ); }, registerPasswordUser: async ( username: string, password: string, fid: ?string, ) => { await commCoreModule.initializeCryptoAccount(); const [ { blobPayload, signature, primaryIdentityPublicKeys }, { contentOneTimeKeys, notificationsOneTimeKeys }, prekeys, ] = await Promise.all([ commCoreModule.getUserPublicKey(), commCoreModule.getOneTimeKeys(ONE_TIME_KEYS_NUMBER), commCoreModule.validateAndGetPrekeys(), ]); + const initialDeviceList = await createAndSignInitialDeviceList( + primaryIdentityPublicKeys.ed25519, + ); const registrationResult = await commRustModule.registerPasswordUser( username, password, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), fid ?? '', - '', // initialDeviceList + JSON.stringify(initialDeviceList), ); const { userID, accessToken: token } = JSON.parse(registrationResult); const identityAuthResult = { accessToken: token, userID, username }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, primaryIdentityPublicKeys.ed25519, validatedResult.accessToken, ); return validatedResult; }, logInPasswordUser: async (username: string, password: string) => { await commCoreModule.initializeCryptoAccount(); const [{ blobPayload, signature, primaryIdentityPublicKeys }, prekeys] = await Promise.all([ commCoreModule.getUserPublicKey(), commCoreModule.validateAndGetPrekeys(), ]); const loginResult = await commRustModule.logInPasswordUser( username, password, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, ); const { userID, accessToken: token } = JSON.parse(loginResult); const identityAuthResult = { accessToken: token, userID, username }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, primaryIdentityPublicKeys.ed25519, validatedResult.accessToken, ); return validatedResult; }, registerWalletUser: async ( walletAddress: string, siweMessage: string, siweSignature: string, fid: ?string, ) => { await commCoreModule.initializeCryptoAccount(); const [ { blobPayload, signature, primaryIdentityPublicKeys }, { contentOneTimeKeys, notificationsOneTimeKeys }, prekeys, ] = await Promise.all([ commCoreModule.getUserPublicKey(), commCoreModule.getOneTimeKeys(ONE_TIME_KEYS_NUMBER), commCoreModule.validateAndGetPrekeys(), ]); + const initialDeviceList = await createAndSignInitialDeviceList( + primaryIdentityPublicKeys.ed25519, + ); const registrationResult = await commRustModule.registerWalletUser( siweMessage, siweSignature, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), fid ?? '', - '', // initialDeviceList + JSON.stringify(initialDeviceList), ); const { userID, accessToken: token } = JSON.parse(registrationResult); const identityAuthResult = { accessToken: token, userID, username: walletAddress, }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, primaryIdentityPublicKeys.ed25519, validatedResult.accessToken, ); return validatedResult; }, logInWalletUser: async ( walletAddress: string, siweMessage: string, siweSignature: string, ) => { await commCoreModule.initializeCryptoAccount(); const [{ blobPayload, signature, primaryIdentityPublicKeys }, prekeys] = await Promise.all([ commCoreModule.getUserPublicKey(), commCoreModule.validateAndGetPrekeys(), ]); const loginResult = await commRustModule.logInWalletUser( siweMessage, siweSignature, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, ); const { userID, accessToken: token } = JSON.parse(loginResult); const identityAuthResult = { accessToken: token, userID, username: walletAddress, }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, primaryIdentityPublicKeys.ed25519, validatedResult.accessToken, ); return validatedResult; }, uploadKeysForRegisteredDeviceAndLogIn: async ( userID: string, nonceChallengeResponse: SignedNonce, ) => { await commCoreModule.initializeCryptoAccount(); const [ { blobPayload, signature, primaryIdentityPublicKeys }, { contentOneTimeKeys, notificationsOneTimeKeys }, prekeys, ] = await Promise.all([ commCoreModule.getUserPublicKey(), commCoreModule.getOneTimeKeys(ONE_TIME_KEYS_NUMBER), commCoreModule.validateAndGetPrekeys(), ]); const { nonce, nonceSignature } = nonceChallengeResponse; const registrationResult = await commRustModule.uploadSecondaryDeviceKeysAndLogIn( userID, nonce, nonceSignature, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), ); const { accessToken: token } = JSON.parse(registrationResult); const identityAuthResult = { accessToken: token, userID, username: '' }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, primaryIdentityPublicKeys.ed25519, validatedResult.accessToken, ); return validatedResult; }, generateNonce: commRustModule.generateNonce, getDeviceListHistoryForUser: async ( userID: string, sinceTimestamp?: number, ) => { const { deviceID: authDeviceID, userID: authUserID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.getDeviceListForUser( authUserID, authDeviceID, token, userID, sinceTimestamp, ); const rawPayloads: string[] = JSON.parse(result); const deviceLists: SignedDeviceList[] = rawPayloads.map(payload => JSON.parse(payload), ); return assertWithValidator( deviceLists, signedDeviceListHistoryValidator, ); }, getDeviceListsForUsers: async (userIDs: $ReadOnlyArray) => { const { deviceID: authDeviceID, userID: authUserID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.getDeviceListsForUsers( authUserID, authDeviceID, token, userIDs, ); const rawPayloads: { +[userID: string]: string } = JSON.parse(result); let usersDeviceLists: UsersSignedDeviceLists = {}; for (const userID in rawPayloads) { usersDeviceLists = { ...usersDeviceLists, [userID]: JSON.parse(rawPayloads[userID]), }; } return assertWithValidator( usersDeviceLists, usersSignedDeviceListsValidator, ); }, updateDeviceList: async (newDeviceList: SignedDeviceList) => { const { deviceID: authDeviceID, userID, accessToken: authAccessToken, } = await getAuthMetadata(); const payload = JSON.stringify(newDeviceList); await commRustModule.updateDeviceList( userID, authDeviceID, authAccessToken, payload, ); }, getFarcasterUsers: async (farcasterIDs: $ReadOnlyArray) => { const farcasterUsersJSONString = await commRustModule.getFarcasterUsers(farcasterIDs); const farcasterUsers = JSON.parse(farcasterUsersJSONString); return assertWithValidator(farcasterUsers, farcasterUsersValidator); }, linkFarcasterAccount: async (farcasterID: string) => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.linkFarcasterAccount( userID, deviceID, token, farcasterID, ); }, unlinkFarcasterAccount: async () => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.unlinkFarcasterAccount(userID, deviceID, token); }, findUserIdentities: async (userIDs: $ReadOnlyArray) => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); const result = await commRustModule.findUserIdentities( userID, deviceID, token, userIDs, ); const identities = JSON.parse(result); return assertWithValidator(identities, identitiesValidator); }, }), [getAuthMetadata], ); const value = React.useMemo( () => ({ identityClient: client, getAuthMetadata, }), [client, getAuthMetadata], ); return ( {children} ); } export default IdentityServiceContextProvider; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index 0bc4be61a..288cea0f0 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,332 +1,328 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { parseDataFromDeepLink } from 'lib/facts/links.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { backupKeysValidator, type BackupKeys, } from 'lib/types/backup-types.js'; -import type { RawDeviceList } from 'lib/types/identity-service-types.js'; import { tunnelbrokerMessageTypes, type TunnelbrokerMessage, } from 'lib/types/tunnelbroker/messages.js'; import { peerToPeerMessageTypes, peerToPeerMessageValidator, type PeerToPeerMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessageTypes } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; +import { + composeRawDeviceList, + rawDeviceListFromSignedList, +} from 'lib/utils/device-list-utils.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { getBackupSecret } from '../backup/use-client-backup.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, signDeviceListUpdate, } from '../qr-code/qr-code-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; type Props = { +navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>, +route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>, }; // eslint-disable-next-line no-unused-vars function SecondaryDeviceQRCodeScanner(props: Props): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); const styles = useStyles(unboundStyles); const navigation = useNavigation(); const tunnelbrokerContext = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); const aes256Key = React.useRef(null); const secondaryDeviceID = React.useRef(null); const broadcastDeviceListUpdate = React.useCallback(async () => { invariant(identityContext, 'identity context not set'); const { getAuthMetadata, identityClient } = identityContext; const { userID } = await getAuthMetadata(); if (!userID) { throw new Error('missing auth metadata'); } const deviceLists = await identityClient.getDeviceListHistoryForUser(userID); invariant(deviceLists.length > 0, 'received empty device list history'); const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; - const deviceList: RawDeviceList = JSON.parse( - lastSignedDeviceList.rawDeviceList, - ); + const deviceList = rawDeviceListFromSignedList(lastSignedDeviceList); const promises = deviceList.devices.map(recipient => tunnelbrokerContext.sendMessage({ deviceID: recipient, payload: JSON.stringify({ type: peerToPeerMessageTypes.DEVICE_LIST_UPDATED, userID, signedDeviceList: lastSignedDeviceList, }), }), ); await Promise.all(promises); }, [identityContext, tunnelbrokerContext]); const addDeviceToList = React.useCallback( async (newDeviceID: string) => { const { getDeviceListHistoryForUser, updateDeviceList } = identityContext.identityClient; invariant( updateDeviceList, 'updateDeviceList() should be defined for primary device', ); const authMetadata = await identityContext.getAuthMetadata(); if (!authMetadata?.userID) { throw new Error('missing auth metadata'); } const deviceLists = await getDeviceListHistoryForUser( authMetadata.userID, ); invariant(deviceLists.length > 0, 'received empty device list history'); const lastSignedDeviceList = deviceLists[deviceLists.length - 1]; - const deviceList: RawDeviceList = JSON.parse( - lastSignedDeviceList.rawDeviceList, - ); + const deviceList = rawDeviceListFromSignedList(lastSignedDeviceList); const { devices } = deviceList; if (devices.includes(newDeviceID)) { return; } - const newDeviceList: RawDeviceList = { - devices: [...devices, newDeviceID], - timestamp: Date.now(), - }; + const newDeviceList = composeRawDeviceList([...devices, newDeviceID]); const signedDeviceList = await signDeviceListUpdate(newDeviceList); await updateDeviceList(signedDeviceList); }, [identityContext], ); const tunnelbrokerMessageListener = React.useCallback( async (message: TunnelbrokerMessage) => { const encryptionKey = aes256Key.current; const targetDeviceID = secondaryDeviceID.current; if (!encryptionKey || !targetDeviceID) { return; } if (message.type !== tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { return; } let innerMessage: PeerToPeerMessage; try { innerMessage = JSON.parse(message.payload); } catch { return; } if ( !peerToPeerMessageValidator.is(innerMessage) || innerMessage.type !== peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE ) { return; } const payload = await parseTunnelbrokerQRAuthMessage( encryptionKey, innerMessage, ); if ( payload?.type !== qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS ) { return; } void broadcastDeviceListUpdate(); const backupSecret = await getBackupSecret(); const backupKeysResponse = await commCoreModule.retrieveBackupKeys(backupSecret); const backupKeys = assertWithValidator( JSON.parse(backupKeysResponse), backupKeysValidator, ); const backupKeyMessage = await composeTunnelbrokerQRAuthMessage( encryptionKey, { type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE, ...backupKeys, }, ); await tunnelbrokerContext.sendMessage({ deviceID: targetDeviceID, payload: JSON.stringify(backupKeyMessage), }); Alert.alert('Device added', 'Device registered successfully', [ { text: 'OK' }, ]); }, [tunnelbrokerContext, broadcastDeviceListUpdate], ); React.useEffect(() => { tunnelbrokerContext.addListener(tunnelbrokerMessageListener); return () => { tunnelbrokerContext.removeListener(tunnelbrokerMessageListener); }; }, [tunnelbrokerMessageListener, tunnelbrokerContext]); React.useEffect(() => { void (async () => { const { status } = await BarCodeScanner.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { Alert.alert( 'No access to camera', 'Please allow Comm to access your camera in order to scan the QR code.', [{ text: 'OK' }], ); navigation.goBack(); } })(); }, [navigation]); const onConnect = React.useCallback( async (barCodeEvent: BarCodeEvent) => { const { data } = barCodeEvent; const parsedData = parseDataFromDeepLink(data); const keysMatch = parsedData?.data?.keys; if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } const keys = JSON.parse(decodeURIComponent(keysMatch)); const { aes256, ed25519 } = keys; aes256Key.current = aes256; secondaryDeviceID.current = ed25519; try { const { deviceID: primaryDeviceID, userID } = await identityContext.getAuthMetadata(); if (!primaryDeviceID || !userID) { throw new Error('missing auth metadata'); } await addDeviceToList(ed25519); const message = await composeTunnelbrokerQRAuthMessage(aes256, { type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS, userID, primaryDeviceID, }); await tunnelbrokerContext.sendMessage({ deviceID: ed25519, payload: JSON.stringify(message), }); } catch (err) { console.log('Primary device error:', err); Alert.alert( 'Adding device failed', 'Failed to update the device list', [{ text: 'OK' }], ); navigation.goBack(); } }, [tunnelbrokerContext, addDeviceToList, identityContext, navigation], ); const onCancelScan = React.useCallback(() => setScanned(false), []); const handleBarCodeScanned = React.useCallback( (barCodeEvent: BarCodeEvent) => { setScanned(true); Alert.alert( 'Connect with this device?', 'Are you sure you want to allow this device to log in to your account?', [ { text: 'Cancel', style: 'cancel', onPress: onCancelScan, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [onCancelScan, onConnect], ); if (hasPermission === null) { return ; } // Note: According to the BarCodeScanner Expo docs, we should adhere to two // guidances when using the BarCodeScanner: // 1. We should specify the potential barCodeTypes we want to scan for to // minimize battery usage. // 2. We should set the onBarCodeScanned callback to undefined if it scanned // in order to 'pause' the scanner from continuing to scan while we // process the data from the scan. // See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner return ( ); } const unboundStyles = { container: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, }; export default SecondaryDeviceQRCodeScanner;