diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 1c4c8ab69..46f5dfd5e 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,986 +1,1010 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { CallSingleKeyserverEndpoint, CallSingleKeyserverEndpointOptions, } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromID, sortThreadIDsPerKeyserver, sortCalendarQueryPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import { usePreRequestUserState } from '../selectors/account-selectors.js'; import { getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, } from '../shared/crypto-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import threadWatcher from '../shared/thread-watcher.js'; import { permissionsAndAuthRelatedRequestTimeout } from '../shared/timeouts.js'; import type { LegacyLogInInfo, LegacyLogInResult, LegacyRegisterResult, LegacyRegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameResponse, LogInRequest, KeyserverAuthResult, KeyserverAuthInfo, KeyserverAuthRequest, ClientLogInResponse, KeyserverLogOutResult, LogOutResult, } from '../types/account-types.js'; import type { UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from '../types/avatar-types.js'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types.js'; import type { IdentityAuthResult } from '../types/identity-service-types.js'; import type { RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; import type { GetOlmSessionInitializationDataResponse } from '../types/request-types.js'; import type { UserSearchResult, ExactUserSearchResult, } from '../types/search-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types.js'; import type { RawThreadInfos } from '../types/thread-types'; import type { CurrentUserInfo, UserInfo, PasswordUpdate, LoggedOutUserInfo, } from '../types/user-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; const loggedOutUserInfo: LoggedOutUserInfo = { anonymous: true, }; export type KeyserverLogOutInput = { +preRequestUserState: PreRequestUserState, +keyserverIDs?: $ReadOnlyArray, }; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const keyserverLogOut = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } let response = null; try { response = await Promise.race([ callKeyserverEndpoint('log_out', requests), (async () => { await sleep(500); throw new Error('keyserver log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? loggedOutUserInfo : null; return { currentUserInfo, preRequestUserState, keyserverIDs }; }; function useLogOut(): ( keyserverIDs?: $ReadOnlyArray, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const callKeyserverLogOut = useKeyserverCall(keyserverLogOut); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback( async (keyserverIDs?: $ReadOnlyArray) => { const identityPromise = (async () => { if (!usingCommServicesAccessToken || !commServicesAccessToken) { return; } if (!identityClient) { throw new Error('Identity service client is not initialized'); } try { await Promise.race([ identityClient.logOut(), (async () => { await sleep(500); throw new Error('identity log_out took more than 500ms'); })(), ]); } catch {} })(); const [{ keyserverIDs: _, ...result }] = await Promise.all([ callKeyserverLogOut({ preRequestUserState, keyserverIDs, }), identityPromise, ]); return { ...result, preRequestUserState: { ...result.preRequestUserState, commServicesAccessToken, }, }; }, [ callKeyserverLogOut, commServicesAccessToken, identityClient, preRequestUserState, ], ); } function useIdentityLogOut(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback(async () => { invariant( usingCommServicesAccessToken, 'identityLogOut can only be called when usingCommServicesAccessToken', ); if (!identityClient) { throw new Error('Identity service client is not initialized'); } try { await Promise.race([ identityClient.logOut(), (async () => { await sleep(500); throw new Error('identity log_out took more than 500ms'); })(), ]); } catch {} return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; }, [commServicesAccessToken, identityClient, preRequestUserState]); } const claimUsernameActionTypes = Object.freeze({ started: 'CLAIM_USERNAME_STARTED', success: 'CLAIM_USERNAME_SUCCESS', failed: 'CLAIM_USERNAME_FAILED', }); const claimUsernameCallSingleKeyserverEndpointOptions = { timeout: 500 }; const claimUsername = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (() => Promise) => async () => { const requests = { [authoritativeKeyserverID()]: {} }; const responses = await callKeyserverEndpoint('claim_username', requests, { ...claimUsernameCallSingleKeyserverEndpointOptions, }); const response = responses[authoritativeKeyserverID()]; return { message: response.message, signature: response.signature, }; }; function useClaimUsername(): () => Promise { return useKeyserverCall(claimUsername); } const deleteKeyserverAccountActionTypes = Object.freeze({ started: 'DELETE_KEYSERVER_ACCOUNT_STARTED', success: 'DELETE_KEYSERVER_ACCOUNT_SUCCESS', failed: 'DELETE_KEYSERVER_ACCOUNT_FAILED', }); const deleteKeyserverAccount = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } await callKeyserverEndpoint('delete_account', requests); return { currentUserInfo: loggedOutUserInfo, preRequestUserState, keyserverIDs, }; }; function useDeleteKeyserverAccount(): ( keyserverIDs?: $ReadOnlyArray, ) => Promise { const preRequestUserState = usePreRequestUserState(); const callKeyserverDeleteAccount = useKeyserverCall(deleteKeyserverAccount); return React.useCallback( (keyserverIDs?: $ReadOnlyArray) => callKeyserverDeleteAccount({ preRequestUserState, keyserverIDs }), [callKeyserverDeleteAccount, preRequestUserState], ); } const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); function useDeleteAccount(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const callKeyserverDeleteAccount = useKeyserverCall(deleteKeyserverAccount); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback(async () => { const identityPromise = (async () => { if (!usingCommServicesAccessToken) { return undefined; } if (!identityClient) { throw new Error('Identity service client is not initialized'); } if (!identityClient.deleteWalletUser) { throw new Error('Delete wallet user method unimplemented'); } return await identityClient.deleteWalletUser(); })(); const [keyserverResult] = await Promise.all([ callKeyserverDeleteAccount({ preRequestUserState, }), identityPromise, ]); const { keyserverIDs: _, ...result } = keyserverResult; return { ...result, preRequestUserState: { ...result.preRequestUserState, commServicesAccessToken, }, }; }, [ callKeyserverDeleteAccount, commServicesAccessToken, identityClient, preRequestUserState, ]); } // Unlike useDeleteAccount, we always dispatch a success here (never throw). // That's because useDeleteAccount is used in a scenario where the user is // visibly logged-in, and we don't want to log them out unless we succeeded in // deleting their account. On the other hand, useDeleteDiscardedIdentityAccount // is used in a scenario where the user is visibly logged-out, and it's only // used to reset state (eg. Redux, SQLite) to a logged-out state. The state // reset only occurs when a success action is dispatched, so we always dispatch // a success. function useDeleteDiscardedIdentityAccount(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback(async () => { invariant( usingCommServicesAccessToken, 'deleteDiscardedIdentityAccount can only be called when ' + 'usingCommServicesAccessToken', ); if (!identityClient) { throw new Error('Identity service client is not initialized'); } if (!identityClient.deleteWalletUser) { throw new Error('Delete wallet user method unimplemented'); } try { await Promise.race([ identityClient.deleteWalletUser(), (async () => { await sleep(500); throw new Error('identity delete_wallet_user took more than 500ms'); })(), ]); } catch {} return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; }, [commServicesAccessToken, identityClient, preRequestUserState]); } const legacyKeyserverRegisterActionTypes = Object.freeze({ started: 'LEGACY_KEYSERVER_REGISTER_STARTED', success: 'LEGACY_KEYSERVER_REGISTER_SUCCESS', failed: 'LEGACY_KEYSERVER_REGISTER_FAILED', }); const legacyKeyserverRegisterCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const legacyKeyserverRegister = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( registerInfo: LegacyRegisterInfo, options?: CallSingleKeyserverEndpointOptions, ) => Promise) => async (registerInfo, options) => { const deviceTokenUpdateRequest = registerInfo.deviceTokenUpdateRequest[authoritativeKeyserverID()]; const { preRequestUserInfo, ...rest } = registerInfo; const response = await callSingleKeyserverEndpoint( 'create_account', { ...rest, deviceTokenUpdateRequest, platformDetails: getConfig().platformDetails, }, { ...legacyKeyserverRegisterCallSingleKeyserverEndpointOptions, ...options, }, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; export type KeyserverAuthInput = $ReadOnly<{ ...KeyserverAuthInfo, +preRequestUserInfo: ?CurrentUserInfo, }>; const keyserverAuthActionTypes = Object.freeze({ started: 'KEYSERVER_AUTH_STARTED', success: 'KEYSERVER_AUTH_SUCCESS', failed: 'KEYSERVER_AUTH_FAILED', }); const keyserverAuthCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const keyserverAuth = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: KeyserverAuthInput) => Promise) => async keyserverAuthInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { authActionSource, calendarQuery, keyserverData, deviceTokenUpdateInput, preRequestUserInfo, ...restLogInInfo } = keyserverAuthInfo; const keyserverIDs = Object.keys(keyserverData); const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: KeyserverAuthRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: deviceTokenUpdateInput[keyserverID], watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, initialContentEncryptedMessage: keyserverData[keyserverID].initialContentEncryptedMessage, initialNotificationsEncryptedMessage: keyserverData[keyserverID].initialNotificationsEncryptedMessage, source: authActionSource, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'keyserver_auth', requests, keyserverAuthCallSingleKeyserverEndpointOptions, ); const userInfosArrays = []; let threadInfos: RawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: keyserverAuthInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; userInfosArrays.push(responses[keyserverID].userInfos); userInfosArrays.push(responses[keyserverID].cookieChange.userInfos); } const userInfos = mergeUserInfos(...userInfosArrays); return { threadInfos, currentUserInfo: responses[authoritativeKeyserverID()]?.currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, authActionSource: keyserverAuthInfo.authActionSource, notAcknowledgedPolicies: responses[authoritativeKeyserverID()]?.notAcknowledgedPolicies, preRequestUserInfo, }; }; const identityRegisterActionTypes = Object.freeze({ started: 'IDENTITY_REGISTER_STARTED', success: 'IDENTITY_REGISTER_SUCCESS', failed: 'IDENTITY_REGISTER_FAILED', }); function useIdentityPasswordRegister(): ( username: string, password: string, fid: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); if (!identityClient.registerPasswordUser) { throw new Error('Register password user method unimplemented'); } return identityClient.registerPasswordUser; } function useIdentityWalletRegister(): ( walletAddress: string, siweMessage: string, siweSignature: string, fid: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); if (!identityClient.registerWalletUser) { throw new Error('Register wallet user method unimplemented'); } return identityClient.registerWalletUser; } const identityGenerateNonceActionTypes = Object.freeze({ started: 'IDENTITY_GENERATE_NONCE_STARTED', success: 'IDENTITY_GENERATE_NONCE_SUCCESS', failed: 'IDENTITY_GENERATE_NONCE_FAILED', }); function useIdentityGenerateNonce(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); return identityClient.generateNonce; } function mergeUserInfos( ...userInfoArrays: Array<$ReadOnlyArray> ): UserInfo[] { const merged: { [string]: UserInfo } = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } type WritableGenericMessagesResult = { messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: string[], currentAsOf: { [keyserverID: string]: number }, }; type WritableCalendarResult = { rawEntryInfos: RawEntryInfo[], calendarQuery: CalendarQuery, }; const identityLogInActionTypes = Object.freeze({ started: 'IDENTITY_LOG_IN_STARTED', success: 'IDENTITY_LOG_IN_SUCCESS', failed: 'IDENTITY_LOG_IN_FAILED', }); function useIdentityPasswordLogIn(): ( username: string, password: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = useSelector(state => state.currentUserInfo); return React.useCallback( (username, password) => { if (!identityClient) { throw new Error('Identity service client is not initialized'); } return (async () => { const result = await identityClient.logInPasswordUser( username, password, ); return { ...result, preRequestUserState, }; })(); }, [identityClient, preRequestUserState], ); } function useIdentityWalletLogIn(): ( walletAddress: string, siweMessage: string, siweSignature: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); return identityClient.logInWalletUser; } +function useIdentitySecondaryDeviceLogIn(): ( + userID: string, +) => Promise { + const client = React.useContext(IdentityClientContext); + const identityClient = client?.identityClient; + invariant(identityClient, 'Identity client should be set'); + const { generateNonce, uploadKeysForRegisteredDeviceAndLogIn } = + identityClient; + + const { signMessage } = getConfig().olmAPI; + + return React.useCallback( + async (userID: string) => { + const nonce = await generateNonce(); + const nonceSignature = await signMessage(nonce); + return await uploadKeysForRegisteredDeviceAndLogIn(userID, { + nonce, + nonceSignature, + }); + }, + [generateNonce, signMessage, uploadKeysForRegisteredDeviceAndLogIn], + ); +} const legacyLogInActionTypes = Object.freeze({ started: 'LEGACY_LOG_IN_STARTED', success: 'LEGACY_LOG_IN_SUCCESS', failed: 'LEGACY_LOG_IN_FAILED', }); const legacyLogInCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const legacyLogIn = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LegacyLogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { authActionSource, calendarQuery, keyserverIDs: inputKeyserverIDs, preRequestUserInfo, ...restLogInInfo } = logInInfo; // Eventually the list of keyservers will be fetched from the // identity service const keyserverIDs = inputKeyserverIDs ?? [authoritativeKeyserverID()]; const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: LogInRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: logInInfo.deviceTokenUpdateRequest[keyserverID], source: authActionSource, watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'log_in', requests, legacyLogInCallSingleKeyserverEndpointOptions, ); const userInfosArrays = []; let threadInfos: RawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; userInfosArrays.push(responses[keyserverID].userInfos); userInfosArrays.push(responses[keyserverID].cookieChange.userInfos); } const userInfos = mergeUserInfos(...userInfosArrays); return { threadInfos, currentUserInfo: responses[authoritativeKeyserverID()].currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, authActionSource: logInInfo.authActionSource, notAcknowledgedPolicies: responses[authoritativeKeyserverID()].notAcknowledgedPolicies, preRequestUserInfo, }; }; function useLegacyLogIn(): ( input: LegacyLogInInfo, ) => Promise { return useKeyserverCall(legacyLogIn); } const changeKeyserverUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_KEYSERVER_USER_PASSWORD_STARTED', success: 'CHANGE_KEYSERVER_USER_PASSWORD_SUCCESS', failed: 'CHANGE_KEYSERVER_USER_PASSWORD_FAILED', }); const changeKeyserverUserPassword = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((passwordUpdate: PasswordUpdate) => Promise) => async passwordUpdate => { await callSingleKeyserverEndpoint('update_account', passwordUpdate); }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((usernamePrefix: string) => Promise) => async usernamePrefix => { const response = await callSingleKeyserverEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const exactSearchUserActionTypes = Object.freeze({ started: 'EXACT_SEARCH_USER_STARTED', success: 'EXACT_SEARCH_USER_SUCCESS', failed: 'EXACT_SEARCH_USER_FAILED', }); const exactSearchUser = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((username: string) => Promise) => async username => { const response = await callSingleKeyserverEndpoint('exact_search_user', { username, }); return { userInfo: response.userInfo, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: SubscriptionUpdateRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'update_user_subscription', requests, ); const response = responses[keyserverID]; return { threadID: input.threadID, subscription: response.threadSubscription, }; }; function useUpdateSubscription(): ( input: SubscriptionUpdateRequest, ) => Promise { return useKeyserverCall(updateSubscription); } const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: UpdateUserSettingsRequest) => Promise) => async input => { const requests: { [string]: UpdateUserSettingsRequest } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = input; } await callKeyserverEndpoint('update_user_settings', requests); }; function useSetUserSettings(): ( input: UpdateUserSettingsRequest, ) => Promise { return useKeyserverCall(setUserSettings); } const getOlmSessionInitializationDataActionTypes = Object.freeze({ started: 'GET_OLM_SESSION_INITIALIZATION_DATA_STARTED', success: 'GET_OLM_SESSION_INITIALIZATION_DATA_SUCCESS', failed: 'GET_OLM_SESSION_INITIALIZATION_DATA_FAILED', }); const getOlmSessionInitializationData = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( options?: ?CallSingleKeyserverEndpointOptions, ) => Promise) => async options => { const olmInitData = await callSingleKeyserverEndpoint( 'get_olm_session_initialization_data', {}, options, ); return { signedIdentityKeysBlob: olmInitData.signedIdentityKeysBlob, contentInitializationInfo: { ...olmInitData.contentInitializationInfo, oneTimeKey: getOneTimeKeyValuesFromBlob( olmInitData.contentInitializationInfo.oneTimeKey, )[0], prekey: getPrekeyValueFromBlob( olmInitData.contentInitializationInfo.prekey, ), }, notifInitializationInfo: { ...olmInitData.notifInitializationInfo, oneTimeKey: getOneTimeKeyValuesFromBlob( olmInitData.notifInitializationInfo.oneTimeKey, )[0], prekey: getPrekeyValueFromBlob( olmInitData.notifInitializationInfo.prekey, ), }, }; }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); const policyAcknowledgment = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((policyRequest: PolicyAcknowledgmentRequest) => Promise) => async policyRequest => { await callSingleKeyserverEndpoint('policy_acknowledgment', policyRequest); }; const updateUserAvatarActionTypes = Object.freeze({ started: 'UPDATE_USER_AVATAR_STARTED', success: 'UPDATE_USER_AVATAR_SUCCESS', failed: 'UPDATE_USER_AVATAR_FAILED', }); const updateUserAvatar = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( avatarDBContent: UpdateUserAvatarRequest, ) => Promise) => async avatarDBContent => { const { updates }: UpdateUserAvatarResponse = await callSingleKeyserverEndpoint('update_user_avatar', avatarDBContent); return { updates }; }; export { changeKeyserverUserPasswordActionTypes, changeKeyserverUserPassword, claimUsernameActionTypes, useClaimUsername, useDeleteKeyserverAccount, deleteKeyserverAccountActionTypes, getOlmSessionInitializationDataActionTypes, getOlmSessionInitializationData, mergeUserInfos, legacyLogIn as legacyLogInRawAction, identityLogInActionTypes, useIdentityPasswordLogIn, useIdentityWalletLogIn, + useIdentitySecondaryDeviceLogIn, useLegacyLogIn, legacyLogInActionTypes, useLogOut, useIdentityLogOut, logOutActionTypes, legacyKeyserverRegister, legacyKeyserverRegisterActionTypes, searchUsers, searchUsersActionTypes, exactSearchUser, exactSearchUserActionTypes, useSetUserSettings, setUserSettingsActionTypes, useUpdateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, updateUserAvatarActionTypes, updateUserAvatar, deleteAccountActionTypes, useDeleteAccount, useDeleteDiscardedIdentityAccount, keyserverAuthActionTypes, keyserverAuth as keyserverAuthRawAction, identityRegisterActionTypes, useIdentityPasswordRegister, useIdentityWalletRegister, identityGenerateNonceActionTypes, useIdentityGenerateNonce, }; diff --git a/lib/hooks/login-hooks.js b/lib/hooks/login-hooks.js index 02ab13003..f55f0fbe0 100644 --- a/lib/hooks/login-hooks.js +++ b/lib/hooks/login-hooks.js @@ -1,177 +1,197 @@ // @flow import * as React from 'react'; import { setDataLoadedActionType } from '../actions/client-db-store-actions.js'; import { identityLogInActionTypes, useIdentityPasswordLogIn, useIdentityWalletLogIn, + useIdentitySecondaryDeviceLogIn, logOutActionTypes, useIdentityLogOut, } from '../actions/user-actions.js'; import { useKeyserverAuthWithRetry } from '../keyserver-conn/keyserver-auth.js'; import { logInActionSources } from '../types/account-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; import { waitUntilDatabaseDeleted } from '../utils/wait-until-db-deleted.js'; // We can't just do everything in one async callback, since the server calls // would get bound to Redux state from before the login. In order to pick up the // updated CSAT and currentUserInfo from Redux, we break the login into two // steps. type CurrentStep = | { +step: 'inactive' } | { +step: 'identity_login_dispatched', +resolve: () => void, +reject: Error => void, }; const inactiveStep = { step: 'inactive' }; function useLogIn(): (identityAuthPromise: Promise) => Promise { const [currentStep, setCurrentStep] = React.useState(inactiveStep); const returnedFunc = React.useCallback( (promise: Promise) => new Promise( // eslint-disable-next-line no-async-promise-executor async (resolve, reject) => { if (currentStep.step !== 'inactive') { return; } try { await promise; setCurrentStep({ step: 'identity_login_dispatched', resolve, reject, }); } catch (e) { reject(e); } }, ), [currentStep], ); const keyserverAuth = useKeyserverAuthWithRetry(authoritativeKeyserverID()); const isRegisteredOnIdentity = useSelector( state => !!state.commServicesAccessToken && !!state.currentUserInfo && !state.currentUserInfo.anonymous, ); // We call identityLogOut in order to reset state if identity auth succeeds // but authoritative keyserver auth fails const identityLogOut = useIdentityLogOut(); const registeringOnAuthoritativeKeyserverRef = React.useRef(false); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { if ( !isRegisteredOnIdentity || currentStep.step !== 'identity_login_dispatched' || registeringOnAuthoritativeKeyserverRef.current ) { return; } registeringOnAuthoritativeKeyserverRef.current = true; const { resolve, reject } = currentStep; void (async () => { try { await keyserverAuth({ authActionSource: process.env.BROWSER ? logInActionSources.keyserverAuthFromWeb : logInActionSources.keyserverAuthFromNative, setInProgress: () => {}, hasBeenCancelled: () => false, doNotRegister: false, }); dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); resolve(); } catch (e) { void dispatchActionPromise(logOutActionTypes, identityLogOut()); await waitUntilDatabaseDeleted(); reject(e); } finally { setCurrentStep(inactiveStep); registeringOnAuthoritativeKeyserverRef.current = false; } })(); }, [ currentStep, isRegisteredOnIdentity, keyserverAuth, dispatch, dispatchActionPromise, identityLogOut, ]); return returnedFunc; } function usePasswordLogIn(): ( username: string, password: string, ) => Promise { const identityPasswordLogIn = useIdentityPasswordLogIn(); const dispatchActionPromise = useDispatchActionPromise(); const identityPasswordAuth = React.useCallback( (username: string, password: string) => { const promise = identityPasswordLogIn(username, password); void dispatchActionPromise(identityLogInActionTypes, promise); return promise; }, [identityPasswordLogIn, dispatchActionPromise], ); const logIn = useLogIn(); return React.useCallback( (username: string, password: string) => logIn(identityPasswordAuth(username, password)), [logIn, identityPasswordAuth], ); } function useWalletLogIn(): ( walletAddress: string, siweMessage: string, siweSignature: string, ) => Promise { const identityWalletLogIn = useIdentityWalletLogIn(); const dispatchActionPromise = useDispatchActionPromise(); const identityWalletAuth = React.useCallback( (walletAddress: string, siweMessage: string, siweSignature: string) => { const promise = identityWalletLogIn( walletAddress, siweMessage, siweSignature, ); void dispatchActionPromise(identityLogInActionTypes, promise); return promise; }, [identityWalletLogIn, dispatchActionPromise], ); const logIn = useLogIn(); return React.useCallback( (walletAddress: string, siweMessage: string, siweSignature: string) => logIn(identityWalletAuth(walletAddress, siweMessage, siweSignature)), [logIn, identityWalletAuth], ); } -export { usePasswordLogIn, useWalletLogIn }; +function useSecondaryDeviceLogIn(): (userID: string) => Promise { + const identitySecondaryDeviceLogIn = useIdentitySecondaryDeviceLogIn(); + const dispatchActionPromise = useDispatchActionPromise(); + const identitySecondaryDeviceAuth = React.useCallback( + (userID: string) => { + const promise = identitySecondaryDeviceLogIn(userID); + void dispatchActionPromise(identityLogInActionTypes, promise); + return promise; + }, + [identitySecondaryDeviceLogIn, dispatchActionPromise], + ); + + const logIn = useLogIn(); + return React.useCallback( + (userID: string) => logIn(identitySecondaryDeviceAuth(userID)), + [logIn, identitySecondaryDeviceAuth], + ); +} + +export { usePasswordLogIn, useWalletLogIn, useSecondaryDeviceLogIn }; diff --git a/native/qr-code/qr-code-screen.react.js b/native/qr-code/qr-code-screen.react.js index 209b65854..f36fb7493 100644 --- a/native/qr-code/qr-code-screen.react.js +++ b/native/qr-code/qr-code-screen.react.js @@ -1,183 +1,167 @@ // @flow -import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import { qrCodeLinkURL } from 'lib/facts/links.js'; +import { useSecondaryDeviceLogIn } from 'lib/hooks/login-hooks.js'; import { useQRAuth } from 'lib/hooks/qr-auth.js'; import { uintArrayToHexString } from 'lib/media/data-utils.js'; -import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { BackupKeys } from 'lib/types/backup-types.js'; -import type { SignedNonce } from 'lib/types/identity-service-types.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import type { QRCodeSignInNavigationProp } from './qr-code-sign-in-navigator.react.js'; import { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage, } from './qr-code-utils.js'; -import { olmAPI } from '../crypto/olm-api.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import * as AES from '../utils/aes-crypto-module.js'; import Alert from '../utils/alert.js'; type QRCodeScreenProps = { +navigation: QRCodeSignInNavigationProp<'QRCodeScreen'>, +route: NavigationRoute<'QRCodeScreen'>, }; function performBackupRestore(backupKeys: BackupKeys): Promise { const { backupID, backupDataKey, backupLogDataKey } = backupKeys; return commCoreModule.restoreBackupData( backupID, backupDataKey, backupLogDataKey, ); } // eslint-disable-next-line no-unused-vars function QRCodeScreen(props: QRCodeScreenProps): React.Node { const [qrData, setQRData] = React.useState(); const { setUnauthorizedDeviceID } = useTunnelbroker(); - const identityContext = React.useContext(IdentityClientContext); - const identityClient = identityContext?.identityClient; - + const logInSecondaryDevice = useSecondaryDeviceLogIn(); const performRegistration = React.useCallback( async (userID: string) => { - invariant(identityClient, 'identity context not set'); try { - const nonce = await identityClient.generateNonce(); - const nonceSignature = await olmAPI.signMessage(nonce); - const challengeResponse: SignedNonce = { - nonce, - nonceSignature, - }; - - await identityClient.uploadKeysForRegisteredDeviceAndLogIn( - userID, - challengeResponse, - ); + await logInSecondaryDevice(userID); setUnauthorizedDeviceID(null); } catch (err) { console.error('Secondary device registration error:', err); Alert.alert('Registration failed', 'Failed to upload device keys', [ { text: 'OK' }, ]); } }, - [setUnauthorizedDeviceID, identityClient], + [logInSecondaryDevice, setUnauthorizedDeviceID], ); const generateQRCode = React.useCallback(async () => { try { const [ed25519, rawAESKey] = await Promise.all([ getContentSigningKey(), AES.generateKey(), ]); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); setUnauthorizedDeviceID(ed25519); setQRData({ deviceID: ed25519, aesKey: aesKeyAsHexString }); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [setUnauthorizedDeviceID]); React.useEffect(() => { void generateQRCode(); }, [generateQRCode]); const qrCodeURL = React.useMemo( () => (qrData ? qrCodeLinkURL(qrData.aesKey, qrData.deviceID) : undefined), [qrData], ); const qrAuthInput = React.useMemo( () => ({ secondaryDeviceID: qrData?.deviceID, aesKey: qrData?.aesKey, performSecondaryDeviceRegistration: performRegistration, composeMessage: composeTunnelbrokerQRAuthMessage, processMessage: parseTunnelbrokerQRAuthMessage, performBackupRestore, }), [qrData, performRegistration], ); useQRAuth(qrAuthInput); const styles = useStyles(unboundStyles); return ( Log in to Comm Open the Comm app on your phone and scan the QR code below How to find the scanner: Go to Profile Select Linked devices Click Add on the top right ); } const unboundStyles = { container: { flex: 1, alignItems: 'center', marginTop: 125, }, heading: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 12, }, headingSubtext: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 30, }, instructionsBox: { alignItems: 'center', width: 300, marginTop: 40, padding: 15, borderColor: 'panelForegroundLabel', borderWidth: 2, borderRadius: 8, }, instructionsTitle: { fontSize: 12, color: 'panelForegroundLabel', paddingBottom: 15, }, instructionsStep: { fontSize: 12, padding: 1, color: 'panelForegroundLabel', }, instructionsBold: { fontWeight: 'bold', }, }; export default QRCodeScreen; diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index c47b2dc23..363833c57 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,164 +1,142 @@ // @flow -import invariant from 'invariant'; import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; -import { identityLogInActionTypes } from 'lib/actions/user-actions.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; +import { useSecondaryDeviceLogIn } from 'lib/hooks/login-hooks.js'; import { useQRAuth } from 'lib/hooks/qr-auth.js'; import { generateKeyCommon } from 'lib/media/aes-crypto-utils-common.js'; import * as AES from 'lib/media/aes-crypto-utils-common.js'; import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js'; -import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; -import type { SignedNonce } from 'lib/types/identity-service-types.js'; import { peerToPeerMessageTypes, type QRCodeAuthMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessagePayloadValidator, type QRCodeAuthMessagePayload, } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; -import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import css from './qr-code-login.css'; -import { olmAPI } from '../crypto/olm-api.js'; import { base64DecodeBuffer, base64EncodeBuffer, } from '../utils/base64-utils.js'; import { convertBytesToObj, convertObjToBytes, } from '../utils/conversion-utils.js'; async function composeTunnelbrokerMessage( encryptionKey: string, obj: QRCodeAuthMessagePayload, ): Promise { const objBytes = convertObjToBytes(obj); const keyBytes = hexToUintArray(encryptionKey); const encryptedBytes = await AES.encryptCommon(crypto, keyBytes, objBytes); const encryptedContent = base64EncodeBuffer(encryptedBytes); return { type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, encryptedContent, }; } async function parseTunnelbrokerMessage( encryptionKey: string, message: QRCodeAuthMessage, ): Promise { const encryptedData = base64DecodeBuffer(message.encryptedContent); const decryptedData = await AES.decryptCommon( crypto, hexToUintArray(encryptionKey), new Uint8Array(encryptedData), ); const payload = convertBytesToObj(decryptedData); if (!qrCodeAuthMessagePayloadValidator.is(payload)) { return null; } return payload; } function QRCodeLogin(): React.Node { const [qrData, setQRData] = React.useState(); const { setUnauthorizedDeviceID } = useTunnelbroker(); - const identityContext = React.useContext(IdentityClientContext); - const identityClient = identityContext?.identityClient; - - const dispatchActionPromise = useDispatchActionPromise(); + const logInSecondaryDevice = useSecondaryDeviceLogIn(); const performRegistration = React.useCallback( async (userID: string) => { - invariant(identityClient, 'identity context not set'); try { - const nonce = await identityClient.generateNonce(); - const nonceSignature = await olmAPI.signMessage(nonce); - const challengeResponse: SignedNonce = { - nonce, - nonceSignature, - }; - - await dispatchActionPromise( - identityLogInActionTypes, - identityClient.uploadKeysForRegisteredDeviceAndLogIn( - userID, - challengeResponse, - ), - ); + await logInSecondaryDevice(userID); setUnauthorizedDeviceID(null); } catch (err) { console.error('Secondary device registration error:', err); } }, - [dispatchActionPromise, identityClient, setUnauthorizedDeviceID], + [logInSecondaryDevice, setUnauthorizedDeviceID], ); const generateQRCode = React.useCallback(async () => { try { const [ed25519, rawAESKey] = await Promise.all([ getContentSigningKey(), generateKeyCommon(crypto), ]); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); setUnauthorizedDeviceID(ed25519); setQRData({ deviceID: ed25519, aesKey: aesKeyAsHexString }); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [setUnauthorizedDeviceID]); React.useEffect(() => { void generateQRCode(); }, [generateQRCode]); const qrCodeURL = React.useMemo( () => (qrData ? qrCodeLinkURL(qrData.aesKey, qrData.deviceID) : undefined), [qrData], ); const qrAuthInput = React.useMemo( () => ({ secondaryDeviceID: qrData?.deviceID, aesKey: qrData?.aesKey, performSecondaryDeviceRegistration: performRegistration, composeMessage: composeTunnelbrokerMessage, processMessage: parseTunnelbrokerMessage, }), [qrData, performRegistration], ); useQRAuth(qrAuthInput); return (
Log in to Comm
Open the Comm app on your phone and scan the QR code below
How to find the scanner:
Go to Profile
Select Linked devices
Click Add on the top right
); } export default QRCodeLogin;