diff --git a/lib/shared/device-list-utils.js b/lib/shared/device-list-utils.js index c3fd3b10b..5be5dba23 100644 --- a/lib/shared/device-list-utils.js +++ b/lib/shared/device-list-utils.js @@ -1,228 +1,228 @@ // @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 createAndSignInitialDeviceList( +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, }; } const { olmAPI } = getConfig(); 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, - createAndSignInitialDeviceList, + createAndSignSingletonDeviceList, fetchLatestDeviceList, addDeviceToDeviceList, removeDeviceFromDeviceList, signDeviceListUpdate, }; diff --git a/lib/shared/device-list-utils.test.js b/lib/shared/device-list-utils.test.js index ff9a72c12..5eb7bb3ca 100644 --- a/lib/shared/device-list-utils.test.js +++ b/lib/shared/device-list-utils.test.js @@ -1,286 +1,286 @@ // @flow import { - createAndSignInitialDeviceList, + 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(createAndSignInitialDeviceList, () => { +describe(createAndSignSingletonDeviceList, () => { it('creates initial device list', async () => { const signMessage = jest .fn<[string], string>() .mockResolvedValue('mock_signature'); mockOlmAPISign(signMessage); - const payload = await createAndSignInitialDeviceList('device1'); + 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(); 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(); 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), ), }); } diff --git a/lib/types/identity-service-types.js b/lib/types/identity-service-types.js index d5a37d7aa..d069605d6 100644 --- a/lib/types/identity-service-types.js +++ b/lib/types/identity-service-types.js @@ -1,360 +1,363 @@ // @flow import t, { type TInterface, type TList, type TDict, type TEnums } from 'tcomb'; import { identityKeysBlobValidator, type IdentityKeysBlob, signedPrekeysValidator, type SignedPrekeys, type OneTimeKeysResultValues, } from './crypto-types.js'; import { type OlmSessionInitializationInfo, olmSessionInitializationInfoValidator, } from './request-types.js'; import { currentUserInfoValidator, type CurrentUserInfo, } from './user-types.js'; import { values } from '../utils/objects.js'; import { tUserID, tShape } from '../utils/validation-utils.js'; export type UserAuthMetadata = { +userID: string, +accessToken: string, }; // This type should not be altered without also updating OutboundKeyInfoResponse // in native/native_rust_library/src/identity/x3dh.rs export type OutboundKeyInfoResponse = { +payload: string, +payloadSignature: string, +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, +oneTimeContentPrekey: ?string, +oneTimeNotifPrekey: ?string, }; // This type should not be altered without also updating InboundKeyInfoResponse // in native/native_rust_library/src/identity/x3dh.rs export type InboundKeyInfoResponse = { +payload: string, +payloadSignature: string, +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, +username?: ?string, +walletAddress?: ?string, }; export type DeviceOlmOutboundKeys = { +identityKeysBlob: IdentityKeysBlob, +contentInitializationInfo: OlmSessionInitializationInfo, +notifInitializationInfo: OlmSessionInitializationInfo, +payloadSignature: string, }; export const deviceOlmOutboundKeysValidator: TInterface = tShape({ identityKeysBlob: identityKeysBlobValidator, contentInitializationInfo: olmSessionInitializationInfoValidator, notifInitializationInfo: olmSessionInitializationInfoValidator, payloadSignature: t.String, }); export type UserDevicesOlmOutboundKeys = { +deviceID: string, +keys: ?DeviceOlmOutboundKeys, }; export type DeviceOlmInboundKeys = { +identityKeysBlob: IdentityKeysBlob, +signedPrekeys: SignedPrekeys, +payloadSignature: string, }; export const deviceOlmInboundKeysValidator: TInterface = tShape({ identityKeysBlob: identityKeysBlobValidator, signedPrekeys: signedPrekeysValidator, payloadSignature: t.String, }); export type UserDevicesOlmInboundKeys = { +keys: { +[deviceID: string]: ?DeviceOlmInboundKeys, }, +username?: ?string, +walletAddress?: ?string, }; // This type should not be altered without also updating FarcasterUser in // keyserver/addons/rust-node-addon/src/identity_client/get_farcaster_users.rs export type FarcasterUser = { +userID: string, +username: string, +farcasterID: string, }; export const farcasterUserValidator: TInterface = tShape({ userID: tUserID, username: t.String, farcasterID: t.String, }); export const farcasterUsersValidator: TList> = t.list( farcasterUserValidator, ); export const userDeviceOlmInboundKeysValidator: TInterface = tShape({ keys: t.dict(t.String, t.maybe(deviceOlmInboundKeysValidator)), username: t.maybe(t.String), walletAddress: t.maybe(t.String), }); export interface IdentityServiceClient { // Only a primary device can initiate account deletion, and web cannot be a // primary device +deleteWalletUser?: () => Promise; // Only a primary device can initiate account deletion, and web cannot be a // primary device +deletePasswordUser?: (password: string) => Promise; +logOut: () => Promise; + // This log out type is specific to primary device, and web cannot be a + // primary device + +logOutPrimaryDevice?: () => Promise; +logOutSecondaryDevice: () => Promise; +getKeyserverKeys: string => Promise; // Users cannot register from web +registerPasswordUser?: ( username: string, password: string, fid: ?string, ) => Promise; // Users cannot register from web +registerReservedPasswordUser?: ( username: string, password: string, keyserverMessage: string, keyserverSignature: string, ) => Promise; +logInPasswordUser: ( username: string, password: string, ) => Promise; +getOutboundKeysForUser: ( userID: string, ) => Promise; +getInboundKeysForUser: ( userID: string, ) => Promise; +uploadOneTimeKeys: (oneTimeKeys: OneTimeKeysResultValues) => Promise; +generateNonce: () => Promise; // Users cannot register from web +registerWalletUser?: ( walletAddress: string, siweMessage: string, siweSignature: string, fid: ?string, ) => Promise; +logInWalletUser: ( walletAddress: string, siweMessage: string, siweSignature: string, ) => Promise; // on native, publishing prekeys to Identity is called directly from C++, // there is no need to expose it to JS +publishWebPrekeys?: (prekeys: SignedPrekeys) => Promise; +getDeviceListHistoryForUser: ( userID: string, sinceTimestamp?: number, ) => Promise<$ReadOnlyArray>; +getDeviceListsForUsers: ( userIDs: $ReadOnlyArray, ) => Promise; // updating device list is possible only on Native // web cannot be a primary device, so there's no need to expose it to JS +updateDeviceList?: (newDeviceList: SignedDeviceList) => Promise; +syncPlatformDetails: () => Promise; +uploadKeysForRegisteredDeviceAndLogIn: ( userID: string, signedNonce: SignedNonce, ) => Promise; +getFarcasterUsers: ( farcasterIDs: $ReadOnlyArray, ) => Promise<$ReadOnlyArray>; +linkFarcasterAccount: (farcasterID: string) => Promise; +unlinkFarcasterAccount: () => Promise; +findUserIdentities: (userIDs: $ReadOnlyArray) => Promise; +versionSupported: () => Promise; +changePassword: (oldPassword: string, newPassword: string) => Promise; } export type IdentityServiceAuthLayer = { +userID: string, +deviceID: string, +commServicesAccessToken: string, }; export type IdentityAuthResult = { +userID: string, +accessToken: string, +username: string, +preRequestUserState?: ?CurrentUserInfo, }; export const identityAuthResultValidator: TInterface = tShape({ userID: tUserID, accessToken: t.String, username: t.String, preRequestUserState: t.maybe(currentUserInfoValidator), }); export type IdentityNewDeviceKeyUpload = { +keyPayload: string, +keyPayloadSignature: string, +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, +contentOneTimeKeys: $ReadOnlyArray, +notifOneTimeKeys: $ReadOnlyArray, }; export type IdentityExistingDeviceKeyUpload = { +keyPayload: string, +keyPayloadSignature: string, +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, }; // Device list types export type RawDeviceList = { +devices: $ReadOnlyArray, +timestamp: number, }; export const rawDeviceListValidator: TInterface = tShape({ devices: t.list(t.String), timestamp: t.Number, }); export type UsersRawDeviceLists = { +[userID: string]: RawDeviceList, }; // User Identity types export type EthereumIdentity = { walletAddress: string, siweMessage: string, siweSignature: string, }; export type Identity = { +username: string, +ethIdentity: ?EthereumIdentity, +farcasterID: ?string, }; export type Identities = { +[userID: string]: Identity, }; export const ethereumIdentityValidator: TInterface = tShape({ walletAddress: t.String, siweMessage: t.String, siweSignature: t.String, }); export const identityValidator: TInterface = tShape({ username: t.String, ethIdentity: t.maybe(ethereumIdentityValidator), farcasterID: t.maybe(t.String), }); export const identitiesValidator: TDict = t.dict( t.String, identityValidator, ); export type SignedDeviceList = { // JSON-stringified RawDeviceList +rawDeviceList: string, // Current primary device signature. Absent for Identity Service generated // device lists. +curPrimarySignature?: string, // Previous primary device signature. Present only if primary device // has changed since last update. +lastPrimarySignature?: string, }; export const signedDeviceListValidator: TInterface = tShape({ rawDeviceList: t.String, curPrimarySignature: t.maybe(t.String), lastPrimarySignature: t.maybe(t.String), }); export const signedDeviceListHistoryValidator: TList> = t.list(signedDeviceListValidator); export type UsersSignedDeviceLists = { +[userID: string]: SignedDeviceList, }; export const usersSignedDeviceListsValidator: TDict = t.dict(t.String, signedDeviceListValidator); export type SignedNonce = { +nonce: string, +nonceSignature: string, }; export const ONE_TIME_KEYS_NUMBER = 10; export const identityDeviceTypes = Object.freeze({ KEYSERVER: 0, WEB: 1, IOS: 2, ANDROID: 3, WINDOWS: 4, MAC_OS: 5, }); export type IdentityDeviceType = $Values; export const identityDeviceTypeValidator: TEnums = t.enums.of( values(identityDeviceTypes), ); export type IdentityPlatformDetails = { +deviceType: IdentityDeviceType, +codeVersion: number, +stateVersion?: number, +majorDesktopVersion?: number, }; export const identityPlatformDetailsValidator: TInterface = tShape({ deviceType: identityDeviceTypeValidator, codeVersion: t.Number, stateVersion: t.maybe(t.Number), majorDesktopVersion: t.maybe(t.Number), }); export type UserDevicesPlatformDetails = { +[deviceID: string]: IdentityPlatformDetails, }; export const userDevicesPlatformDetailsValidator: TDict = t.dict(t.String, identityPlatformDetailsValidator); export type UsersDevicesPlatformDetails = { +[userID: string]: UserDevicesPlatformDetails, }; export const usersDevicesPlatformDetailsValidator: TDict = t.dict(t.String, userDevicesPlatformDetailsValidator); export type PeersDeviceLists = { +usersSignedDeviceLists: UsersSignedDeviceLists, +usersDevicesPlatformDetails: UsersDevicesPlatformDetails, }; export const peersDeviceListsValidator: TInterface = tShape({ usersSignedDeviceLists: usersSignedDeviceListsValidator, usersDevicesPlatformDetails: usersDevicesPlatformDetailsValidator, }); diff --git a/native/identity-service/identity-service-context-provider.react.js b/native/identity-service/identity-service-context-provider.react.js index c5f8627f8..b358cf8a8 100644 --- a/native/identity-service/identity-service-context-provider.react.js +++ b/native/identity-service/identity-service-context-provider.react.js @@ -1,693 +1,708 @@ // @flow import * as React from 'react'; import { getOneTimeKeyValues } from 'lib/shared/crypto-utils.js'; -import { createAndSignInitialDeviceList } from 'lib/shared/device-list-utils.js'; +import { createAndSignSingletonDeviceList } 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, identitiesValidator, type UsersDevicesPlatformDetails, peersDeviceListsValidator, } 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 processAuthResult = async (authResult: string, deviceID: string) => { const { userID, accessToken: token, username } = JSON.parse(authResult); const identityAuthResult = { accessToken: token, userID, username, }; const validatedResult = assertWithValidator( identityAuthResult, identityAuthResultValidator, ); await commCoreModule.setCommServicesAuthMetadata( validatedResult.userID, deviceID, validatedResult.accessToken, ); return validatedResult; }; 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); }, + logOutPrimaryDevice: async () => { + const { + deviceID, + userID, + accessToken: token, + } = await getAuthMetadata(); + const signedDeviceList = + await createAndSignSingletonDeviceList(deviceID); + return commRustModule.logOutPrimaryDevice( + userID, + deviceID, + token, + JSON.stringify(signedDeviceList), + ); + }, logOutSecondaryDevice: async () => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.logOutSecondaryDevice(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, }; 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; 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( + const initialDeviceList = await createAndSignSingletonDeviceList( primaryIdentityPublicKeys.ed25519, ); const registrationResult = await commRustModule.registerPasswordUser( username, password, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), fid ?? '', JSON.stringify(initialDeviceList), ); return await processAuthResult( registrationResult, primaryIdentityPublicKeys.ed25519, ); }, registerReservedPasswordUser: async ( username: string, password: string, keyserverMessage: string, keyserverSignature: 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( + const initialDeviceList = await createAndSignSingletonDeviceList( primaryIdentityPublicKeys.ed25519, ); const registrationResult = await commRustModule.registerReservedPasswordUser( username, password, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), keyserverMessage, keyserverSignature, JSON.stringify(initialDeviceList), ); return await processAuthResult( registrationResult, primaryIdentityPublicKeys.ed25519, ); }, 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, ); return await processAuthResult( loginResult, primaryIdentityPublicKeys.ed25519, ); }, 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( + const initialDeviceList = await createAndSignSingletonDeviceList( primaryIdentityPublicKeys.ed25519, ); const registrationResult = await commRustModule.registerWalletUser( siweMessage, siweSignature, blobPayload, signature, prekeys.contentPrekey, prekeys.contentPrekeySignature, prekeys.notifPrekey, prekeys.notifPrekeySignature, getOneTimeKeyValues(contentOneTimeKeys), getOneTimeKeyValues(notificationsOneTimeKeys), fid ?? '', JSON.stringify(initialDeviceList), ); return await processAuthResult( registrationResult, primaryIdentityPublicKeys.ed25519, ); }, 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, ); return await processAuthResult( loginResult, primaryIdentityPublicKeys.ed25519, ); }, 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), ); return await processAuthResult( registrationResult, primaryIdentityPublicKeys.ed25519, ); }, 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: { +usersDeviceLists: { +[userID: string]: string }, +usersDevicesPlatformDetails: UsersDevicesPlatformDetails, } = JSON.parse(result); let usersDeviceLists: UsersSignedDeviceLists = {}; for (const userID in rawPayloads.usersDeviceLists) { usersDeviceLists = { ...usersDeviceLists, [userID]: JSON.parse(rawPayloads.usersDeviceLists[userID]), }; } const peersDeviceLists = { usersSignedDeviceLists: usersDeviceLists, usersDevicesPlatformDetails: rawPayloads.usersDevicesPlatformDetails, }; return assertWithValidator(peersDeviceLists, peersDeviceListsValidator); }, updateDeviceList: async (newDeviceList: SignedDeviceList) => { const { deviceID: authDeviceID, userID, accessToken: authAccessToken, } = await getAuthMetadata(); const payload = JSON.stringify(newDeviceList); await commRustModule.updateDeviceList( userID, authDeviceID, authAccessToken, payload, ); }, syncPlatformDetails: async () => { const { deviceID: authDeviceID, userID, accessToken: authAccessToken, } = await getAuthMetadata(); await commRustModule.syncPlatformDetails( userID, authDeviceID, authAccessToken, ); }, 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); }, versionSupported: () => { return commRustModule.versionSupported(); }, changePassword: async (oldPassword: string, newPassword: string) => { const { deviceID, userID, accessToken: token, } = await getAuthMetadata(); return commRustModule.updatePassword( userID, deviceID, token, oldPassword, newPassword, ); }, }), [getAuthMetadata], ); const value = React.useMemo( () => ({ identityClient: client, getAuthMetadata, }), [client, getAuthMetadata], ); return ( {children} ); } export default IdentityServiceContextProvider;