diff --git a/lib/components/keyserver-connection-handler.js b/lib/components/keyserver-connection-handler.js index 90a151977..21dc9968c 100644 --- a/lib/components/keyserver-connection-handler.js +++ b/lib/components/keyserver-connection-handler.js @@ -1,381 +1,394 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { keyserverAuthActionTypes, logOutActionTypes, useKeyserverAuth, useLogOut, } from '../actions/user-actions.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSession } from '../keyserver-conn/keyserver-conn-types.js'; import { resolveKeyserverSessionInvalidation } from '../keyserver-conn/recovery-utils.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { connectionSelector, cookieSelector, deviceTokenSelector, urlPrefixSelector, sessionIDSelector, } from '../selectors/keyserver-selectors.js'; import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; +import { useInitialNotificationsEncryptedMessage } from '../shared/crypto-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { OlmSessionCreatorContext } from '../shared/olm-session-creator-context.js'; import type { BaseSocketProps } from '../socket/socket.react.js'; import { logInActionSources, type RecoveryActionSource, } from '../types/account-types.js'; import { genericCookieInvalidation } from '../types/session-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 { usingCommServicesAccessToken } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; type Props = { ...BaseSocketProps, +socketComponent: React.ComponentType, }; const AUTH_RETRY_DELAY_MS = 60000; const CANCELLED_ERROR = 'cancelled'; function KeyserverConnectionHandler(props: Props) { const { socketComponent: Socket, ...socketProps } = props; const { keyserverID } = props; const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useLogOut(); const keyserverAuth = useKeyserverAuth(); const hasConnectionIssue = useSelector( state => !!connectionSelector(keyserverID)(state)?.connectionIssue, ); const cookie = useSelector(cookieSelector(keyserverID)); const dataLoaded = useSelector(state => state.dataLoaded); const keyserverDeviceToken = useSelector(deviceTokenSelector(keyserverID)); // We have an assumption that we should be always connected to Ashoat's // keyserver. It is possible that a token which it has is correct, so we can // try to use it. In worst case it is invalid and our push-handler will try // to fix it. const ashoatKeyserverDeviceToken = useSelector( deviceTokenSelector(authoritativeKeyserverID()), ); const deviceToken = keyserverDeviceToken ?? ashoatKeyserverDeviceToken; const navInfo = useSelector(state => state.navInfo); const calendarFilters = useSelector(state => state.calendarFilters); const calendarQuery = React.useMemo(() => { const filters = filterThreadIDsInFilterList( calendarFilters, (threadID: string) => extractKeyserverIDFromID(threadID) === keyserverID, ); return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters, }; }, [calendarFilters, keyserverID, navInfo.endDate, navInfo.startDate]); React.useEffect(() => { if (hasConnectionIssue && !usingCommServicesAccessToken) { void dispatchActionPromise(logOutActionTypes, callLogOut()); } }, [callLogOut, hasConnectionIssue, dispatchActionPromise]); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const olmSessionCreator = React.useContext(OlmSessionCreatorContext); invariant(olmSessionCreator, 'Olm session creator should be set'); const [authInProgress, setAuthInProgress] = React.useState(false); const performAuth = React.useCallback(() => { setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const promise = (async () => { try { const keyserverKeys = await identityClient.getKeyserverKeys(keyserverID); if (cancelled) { throw new Error(CANCELLED_ERROR); } const [notifsSession, contentSession, { userID, deviceID }] = await Promise.all([ olmSessionCreator.notificationsSessionCreator( cookie, keyserverKeys.identityKeysBlob.notificationIdentityPublicKeys, keyserverKeys.notifInitializationInfo, keyserverID, ), olmSessionCreator.contentSessionCreator( keyserverKeys.identityKeysBlob.primaryIdentityPublicKeys, keyserverKeys.contentInitializationInfo, ), getAuthMetadata(), ]); invariant(userID, 'userID should be set'); invariant(deviceID, 'deviceID should be set'); const deviceTokenUpdateInput = deviceToken ? { [keyserverID]: { deviceToken } } : {}; if (cancelled) { throw new Error(CANCELLED_ERROR); } await dispatchActionPromise( keyserverAuthActionTypes, (async () => { const result = await keyserverAuth({ userID, deviceID, doNotRegister: false, calendarQuery, deviceTokenUpdateInput, authActionSource: process.env.BROWSER ? logInActionSources.keyserverAuthFromWeb : logInActionSources.keyserverAuthFromNative, keyserverData: { [keyserverID]: { initialContentEncryptedMessage: contentSession, initialNotificationsEncryptedMessage: notifsSession, }, }, }); if (cancelled) { throw new Error(CANCELLED_ERROR); } return result; })(), ); } catch (e) { if (cancelled) { return; } console.log( `Error while authenticating to keyserver with id ${keyserverID}`, e, ); if (!dataLoaded && keyserverID === authoritativeKeyserverID()) { await dispatchActionPromise(logOutActionTypes, callLogOut()); } } finally { if (!cancelled) { await sleep(AUTH_RETRY_DELAY_MS); setAuthInProgress(false); } } })(); return [promise, cancel]; }, [ calendarQuery, callLogOut, cookie, dataLoaded, deviceToken, dispatchActionPromise, getAuthMetadata, identityClient, keyserverAuth, keyserverID, olmSessionCreator, ]); const activeSessionRecovery = useSelector( state => state.keyserverStore.keyserverInfos[keyserverID]?.connection .activeSessionRecovery, ); const preRequestUserInfo = useSelector(state => state.currentUserInfo); const sessionID = useSelector(sessionIDSelector(keyserverID)); const preRequestUserState = React.useMemo( () => ({ currentUserInfo: preRequestUserInfo, cookiesAndSessions: { [keyserverID]: { cookie, sessionID, }, }, }), [preRequestUserInfo, keyserverID, cookie, sessionID], ); // We only need to do a "spot check" on this value below. // - To avoid regenerating performRecovery whenever it changes, we want to // make sure it's not in that function's dep list. // - If we exclude it from that function's dep list, we'll end up binding in // the value of preRequestUserState at the time performRecovery is updated. // Instead, by assigning to a ref, we are able to use the latest value. const preRequestUserStateRef = React.useRef(preRequestUserState); preRequestUserStateRef.current = preRequestUserState; + // This async function asks the keyserver for its keys, whereas performAuth + // above gets the keyserver's keys from the identity service + const getInitialNotificationsEncryptedMessageForRecovery = + useInitialNotificationsEncryptedMessage(keyserverID); + const dispatch = useDispatch(); const urlPrefix = useSelector(urlPrefixSelector(keyserverID)); const performRecovery = React.useCallback( (recoveryActionSource: RecoveryActionSource) => { invariant( urlPrefix, `urlPrefix for ${keyserverID} should be set during performRecovery`, ); setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const promise = (async () => { const userStateBeforeRecovery = preRequestUserStateRef.current; try { const recoverySessionChange = await resolveKeyserverSessionInvalidation( dispatch, cookie, urlPrefix, recoveryActionSource, keyserverID, + getInitialNotificationsEncryptedMessageForRecovery, ); if (cancelled) { // TODO: cancellation won't work because above call handles Redux // dispatch directly throw new Error(CANCELLED_ERROR); } const sessionChange = recoverySessionChange ?? genericCookieInvalidation; if ( sessionChange.cookieInvalidated || !sessionChange.cookie || !sessionChange.cookie.startsWith('user=') ) { setNewSession( dispatch, sessionChange, userStateBeforeRecovery, null, recoveryActionSource, keyserverID, ); } } catch (e) { if (cancelled) { return; } console.log( `Error while recovering session with keyserver id ${keyserverID}`, e, ); setNewSession( dispatch, genericCookieInvalidation, userStateBeforeRecovery, null, recoveryActionSource, keyserverID, ); } finally { if (!cancelled) { setAuthInProgress(false); } } })(); return [promise, cancel]; }, - [dispatch, cookie, urlPrefix, keyserverID], + [ + dispatch, + cookie, + urlPrefix, + keyserverID, + getInitialNotificationsEncryptedMessageForRecovery, + ], ); const cancelPendingAuth = React.useRef void>(null); const prevPerformAuth = React.useRef(performAuth); const isUserAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); const hasAccessToken = useSelector(state => !!state.commServicesAccessToken); const cancelPendingRecovery = React.useRef void>(null); const prevPerformRecovery = React.useRef(performRecovery); React.useEffect(() => { if (activeSessionRecovery && isUserAuthenticated) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; if (prevPerformRecovery.current !== performRecovery) { cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; prevPerformRecovery.current = performRecovery; } if (!authInProgress) { const [, cancel] = performRecovery(activeSessionRecovery); cancelPendingRecovery.current = cancel; } return; } cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; if (!hasAccessToken) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } if ( !usingCommServicesAccessToken || isUserAuthenticated || !hasAccessToken ) { return; } if (prevPerformAuth.current !== performAuth) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } prevPerformAuth.current = performAuth; if (authInProgress) { return; } const [, cancel] = performAuth(); cancelPendingAuth.current = cancel; }, [ activeSessionRecovery, authInProgress, performRecovery, hasAccessToken, isUserAuthenticated, performAuth, ]); return ; } const Handler: React.ComponentType = React.memo( KeyserverConnectionHandler, ); export default Handler; diff --git a/lib/keyserver-conn/recovery-utils.js b/lib/keyserver-conn/recovery-utils.js index 444b31fc9..aa12e6ad6 100644 --- a/lib/keyserver-conn/recovery-utils.js +++ b/lib/keyserver-conn/recovery-utils.js @@ -1,159 +1,159 @@ // @flow import { type ActionTypes, setNewSession } from './keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from '../shared/crypto-utils.js'; import type { RecoveryActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types.js'; import type { Endpoint } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ClientSessionChange } from '../types/session-types.js'; import callSingleKeyServerEndpoint from '../utils/call-single-keyserver-endpoint.js'; import type { CallSingleKeyserverEndpointOptions } from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { promiseAll } from '../utils/promises.js'; import { wrapActionPromise } from '../utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; // This function is a shortcut that tells us whether it's worth even trying to // call resolveKeyserverSessionInvalidation function canResolveKeyserverSessionInvalidation(): boolean { if (usingCommServicesAccessToken) { // We can always try to resolve a keyserver session invalidation // automatically using the Olm auth responder return true; } const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); // If we can't use the Olm auth responder, then we can only resolve a // keyserver session invalidation on native, where we have access to the // user's native credentials. Note that we can't do this for ETH users, but we // don't know if the user is an ETH user from this function return !!resolveKeyserverSessionInvalidationUsingNativeCredentials; } // This function attempts to resolve an invalid keyserver session. A session can // become invalid when a keyserver invalidates it, or due to inconsistent client // state. If the client is usingCommServicesAccessToken, then the invalidation // recovery will try to go through the keyserver's Olm auth responder. // Otherwise, it will attempt to use the user's credentials to log in with the // legacy auth responder, which won't work on web and won't work for ETH users. async function resolveKeyserverSessionInvalidation( dispatch: Dispatch, cookie: ?string, urlPrefix: string, recoveryActionSource: RecoveryActionSource, keyserverID: string, - getInitialNotificationsEncryptedMessage?: ( + getInitialNotificationsEncryptedMessage: ( options?: ?InitialNotifMessageOptions, ) => Promise, ): Promise { const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); if (!resolveKeyserverSessionInvalidationUsingNativeCredentials) { return null; } let newSessionChange = null; let callSingleKeyserverEndpointCallback = null; const boundCallSingleKeyserverEndpoint = async ( endpoint: Endpoint, data: { +[key: string]: mixed }, options?: ?CallSingleKeyserverEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession( dispatch, sessionChange, null, error, recoveryActionSource, keyserverID, ); }; try { const result = await callSingleKeyServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, false, null, null, endpoint, data, dispatch, options, false, keyserverID, ); if (callSingleKeyserverEndpointCallback) { callSingleKeyserverEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callSingleKeyserverEndpointCallback) { callSingleKeyserverEndpointCallback(!!newSessionChange); } throw e; } }; const boundCallKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, options?: ?CallSingleKeyserverEndpointOptions, ) => { if (requests[keyserverID]) { const promises = { [keyserverID]: boundCallSingleKeyserverEndpoint( endpoint, requests[keyserverID], options, ), }; return promiseAll(promises); } return Promise.resolve({}); }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, authActionSource: recoveryActionSource, }; void dispatch( wrapActionPromise(actionTypes, promise, null, startingPayload), ); return new Promise(r => (callSingleKeyserverEndpointCallback = r)); }; await resolveKeyserverSessionInvalidationUsingNativeCredentials( boundCallSingleKeyserverEndpoint, boundCallKeyserverEndpoint, dispatchRecoveryAttempt, recoveryActionSource, keyserverID, getInitialNotificationsEncryptedMessage, ); return newSessionChange; } export { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, }; diff --git a/lib/utils/config.js b/lib/utils/config.js index ff4463421..6a81f7040 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,48 +1,48 @@ // @flow import invariant from 'invariant'; import type { CallSingleKeyserverEndpoint } from './call-single-keyserver-endpoint.js'; import type { DispatchRecoveryAttempt, CallKeyserverEndpoint, } from '../keyserver-conn/keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from '../shared/crypto-utils.js'; import type { RecoveryActionSource } from '../types/account-types.js'; import type { OlmAPI } from '../types/crypto-types.js'; import type { PlatformDetails } from '../types/device-types.js'; export type Config = { +resolveKeyserverSessionInvalidationUsingNativeCredentials: ?( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, recoveryActionSource: RecoveryActionSource, keyserverID: string, - getInitialNotificationsEncryptedMessage?: ( + getInitialNotificationsEncryptedMessage: ( options?: ?InitialNotifMessageOptions, ) => Promise, ) => Promise, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, +authoritativeKeyserverID: string, +olmAPI: OlmAPI, }; let registeredConfig: ?Config = null; const registerConfig = (config: Config) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; const hasConfig = (): boolean => { return !!registeredConfig; }; export { registerConfig, getConfig, hasConfig }; diff --git a/native/account/legacy-recover-keyserver-session.js b/native/account/legacy-recover-keyserver-session.js index d0a2f1e19..e127992ac 100644 --- a/native/account/legacy-recover-keyserver-session.js +++ b/native/account/legacy-recover-keyserver-session.js @@ -1,53 +1,53 @@ // @flow import { logInActionTypes, logInRawAction } from 'lib/actions/user-actions.js'; import type { DispatchRecoveryAttempt, CallKeyserverEndpoint, } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from 'lib/shared/crypto-utils.js'; import type { RecoveryActionSource } from 'lib/types/account-types.js'; import type { CallSingleKeyserverEndpoint } from 'lib/utils/call-single-keyserver-endpoint.js'; import { fetchNativeKeychainCredentials } from './native-credentials.js'; import { store } from '../redux/redux-setup.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; async function resolveKeyserverSessionInvalidationUsingNativeCredentials( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, recoveryActionSource: RecoveryActionSource, keyserverID: string, - getInitialNotificationsEncryptedMessage?: ( + getInitialNotificationsEncryptedMessage: ( ?InitialNotifMessageOptions, ) => Promise, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (!keychainCredentials) { return; } - let extraInfo = await nativeLogInExtraInfoSelector(store.getState())(); - if (getInitialNotificationsEncryptedMessage) { - const initialNotificationsEncryptedMessage = - await getInitialNotificationsEncryptedMessage({ + const [baseExtraInfo, initialNotificationsEncryptedMessage] = + await Promise.all([ + nativeLogInExtraInfoSelector(store.getState())(), + getInitialNotificationsEncryptedMessage({ callSingleKeyserverEndpoint, - }); - extraInfo = { ...extraInfo, initialNotificationsEncryptedMessage }; - } + }), + ]); + const extraInfo = { ...baseExtraInfo, initialNotificationsEncryptedMessage }; const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logInRawAction(callKeyserverEndpoint)({ ...keychainCredentials, ...extraInfo, authActionSource: recoveryActionSource, keyserverIDs: [keyserverID], }), { calendarQuery }, ); } export { resolveKeyserverSessionInvalidationUsingNativeCredentials };