diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index 7e1057340..b7316b845 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,484 +1,484 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Keyboard, Platform } from 'react-native'; import { legacyLogInActionTypes, useLegacyLogIn, getOlmSessionInitializationDataActionTypes, } from 'lib/actions/user-actions.js'; import { usePasswordLogIn } from 'lib/hooks/login-hooks.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import { type LegacyLogInInfo, type LegacyLogInExtraInfo, type LegacyLogInResult, type LegacyLogInStartingPayload, logInActionSources, } from 'lib/types/account-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { TextInput } from './modal-components.react.js'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials.js'; import { PanelButton, Panel } from './panel-components.react.js'; import PasswordInput from './password-input.react.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLegacyLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import type { KeyPressEvent } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; import { appOutOfDateAlertDetails, unknownErrorAlertDetails, userNotFoundAlertDetails, } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; import type { StateContainer } from '../utils/state-container.js'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityStyle: ViewStyle, +logInState: StateContainer, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +legacyLogInExtraInfo: () => Promise, +dispatchActionPromise: DispatchActionPromise, +legacyLogIn: (logInInfo: LegacyLogInInfo) => Promise, +identityPasswordLogIn: (username: string, password: string) => Promise, +getInitialNotificationsEncryptedMessage: () => Promise, }; type State = { +logInPending: boolean, }; class LogInPanel extends React.PureComponent { usernameInput: ?TextInput; passwordInput: ?PasswordInput; state: State = { logInPending: false }; componentDidMount() { void this.attemptToFetchCredentials(); } get usernameInputText(): string { return this.props.logInState.state.usernameInputText || ''; } get passwordInputText(): string { return this.props.logInState.state.passwordInputText || ''; } async attemptToFetchCredentials() { if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } const credentials = await fetchNativeCredentials(); if (!credentials) { return; } if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } this.props.logInState.setState({ usernameInputText: credentials.username, passwordInputText: credentials.password, }); } render(): React.Node { return ( ); } getLoadingStatus(): LoadingStatus { if (this.props.loadingStatus === 'loading') { return 'loading'; } if (this.state.logInPending) { return 'loading'; } return 'inactive'; } usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => { this.usernameInput = usernameInput; if (Platform.OS === 'ios' && usernameInput) { setTimeout(() => usernameInput.focus()); } }; focusUsernameInput: () => void = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; passwordInputRef: (passwordInput: ?PasswordInput) => void = passwordInput => { this.passwordInput = passwordInput; }; focusPasswordInput: () => void = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameInputText: (text: string) => void = text => { this.props.logInState.setState({ usernameInputText: text.trim() }); }; onUsernameKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText: (text: string) => void = text => { this.props.logInState.setState({ passwordInputText: text }); }; onSubmit: () => Promise = async () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( 'Can’t log in with email', 'You need to log in with your username now', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) { Alert.alert( 'Invalid username', 'Alphanumeric usernames only', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); if (usingCommServicesAccessToken) { await this.identityPasswordLogIn(); return; } const extraInfo = await this.props.legacyLogInExtraInfo(); const initialNotificationsEncryptedMessage = await this.props.getInitialNotificationsEncryptedMessage(); void this.props.dispatchActionPromise( legacyLogInActionTypes, this.legacyLogInAction({ ...extraInfo, initialNotificationsEncryptedMessage, }), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LegacyLogInStartingPayload), ); }; async legacyLogInAction( extraInfo: LegacyLogInExtraInfo, ): Promise { try { const result = await this.props.legacyLogIn({ ...extraInfo, username: this.usernameInputText, password: this.passwordInputText, authActionSource: logInActionSources.logInFromNativeForm, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( userNotFoundAlertDetails.title, userNotFoundAlertDetails.message, [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } async identityPasswordLogIn(): Promise { if (this.state.logInPending) { return; } this.setState({ logInPending: true }); try { await this.props.identityPasswordLogIn( this.usernameInputText, this.passwordInputText, ); this.props.setActiveAlert(false); await setNativeCredentials({ username: this.usernameInputText, password: this.passwordInputText, }); } catch (e) { const messageForException = getMessageForException(e); if ( - messageForException === 'user not found' || + messageForException === 'user_not_found' || messageForException === 'login failed' ) { Alert.alert( userNotFoundAlertDetails.title, userNotFoundAlertDetails.message, [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if ( messageForException === 'Unsupported version' || messageForException === 'client_version_unsupported' ) { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } finally { this.setState({ logInPending: false }); } } onUnsuccessfulLoginAlertAckowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; onPasswordAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { passwordInputText: '', }, this.focusPasswordInput, ); }; onUnknownErrorAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onAppOutOfDateAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ footer: { flexDirection: 'row', justifyContent: 'flex-end', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, row: { marginHorizontal: 24, }, }); const logInLoadingStatusSelector = createLoadingStatusSelector( legacyLogInActionTypes, ); const olmSessionInitializationDataLoadingStatusSelector = createLoadingStatusSelector(getOlmSessionInitializationDataActionTypes); const ConnectedLogInPanel: React.ComponentType = React.memo(function ConnectedLogInPanel(props: BaseProps) { const logInLoadingStatus = useSelector(logInLoadingStatusSelector); const olmSessionInitializationDataLoadingStatus = useSelector( olmSessionInitializationDataLoadingStatusSelector, ); const loadingStatus = combineLoadingStatuses( logInLoadingStatus, olmSessionInitializationDataLoadingStatus, ); const legacyLogInExtraInfo = useSelector( nativeLegacyLogInExtraInfoSelector, ); const dispatchActionPromise = useDispatchActionPromise(); const callLegacyLogIn = useLegacyLogIn(); const callIdentityPasswordLogIn = usePasswordLogIn(); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(authoritativeKeyserverID); return ( ); }); export default ConnectedLogInPanel; diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js index 64897696e..950ef1019 100644 --- a/native/account/registration/registration-server-call.js +++ b/native/account/registration/registration-server-call.js @@ -1,611 +1,611 @@ // @flow import * as React from 'react'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { setSyncedMetadataEntryActionType } from 'lib/actions/synced-metadata-actions.js'; import { legacyKeyserverRegisterActionTypes, legacyKeyserverRegister, useIdentityPasswordRegister, identityRegisterActionTypes, deleteAccountActionTypes, useDeleteDiscardedIdentityAccount, } from 'lib/actions/user-actions.js'; import { useKeyserverAuthWithRetry } from 'lib/keyserver-conn/keyserver-auth.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { usePreRequestUserState } from 'lib/selectors/account-selectors.js'; import { isLoggedInToKeyserver } from 'lib/selectors/user-selectors.js'; import { type LegacyLogInStartingPayload, logInActionSources, type LogOutResult, } from 'lib/types/account-types.js'; import { syncedMetadataNames } from 'lib/types/synced-metadata-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; import { waitUntilDatabaseDeleted } from 'lib/utils/wait-until-db-deleted.js'; import type { RegistrationServerCallInput, UsernameAccountSelection, EthereumAccountSelection, AvatarData, } from './registration-types.js'; import { authoritativeKeyserverID } from '../../authoritative-keyserver.js'; import { useNativeSetUserAvatar, useUploadSelectedMedia, } from '../../avatars/avatar-hooks.js'; import { commCoreModule } from '../../native-modules.js'; import { persistConfig } from '../../redux/persist.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nativeLegacyLogInExtraInfoSelector } from '../../selectors/account-selectors.js'; import { appOutOfDateAlertDetails, usernameReservedAlertDetails, usernameTakenAlertDetails, unknownErrorAlertDetails, } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; import { defaultURLPrefix } from '../../utils/url-utils.js'; import { setNativeCredentials } from '../native-credentials.js'; import { useLegacySIWEServerCall, useIdentityWalletRegisterCall, } from '../siwe-hooks.js'; // We can't just do everything in one async callback, since the server calls // would get bound to Redux state from before the registration. The registration // flow has multiple steps where critical Redux state is changed, where // subsequent steps depend on accessing the updated Redux state. // To address this, we break the registration process up into multiple steps. // When each step completes we update the currentStep state, and we have Redux // selectors that trigger useEffects for subsequent steps when relevant data // starts to appear in Redux. type CurrentStep = | { +step: 'inactive' } | { +step: 'identity_registration_dispatched', +clearCachedSelections: () => void, +onAlertAcknowledged: ?() => mixed, +avatarData: ?AvatarData, +credentialsToSave: ?{ +username: string, +password: string }, +resolve: () => void, +reject: Error => void, } | { +step: 'authoritative_keyserver_registration_dispatched', +clearCachedSelections: () => void, +avatarData: ?AvatarData, +credentialsToSave: ?{ +username: string, +password: string }, +resolve: () => void, +reject: Error => void, }; const inactiveStep = { step: 'inactive' }; function useRegistrationServerCall(): RegistrationServerCallInput => Promise { const [currentStep, setCurrentStep] = React.useState(inactiveStep); // STEP 1: ACCOUNT REGISTRATION const legacyLogInExtraInfo = useSelector(nativeLegacyLogInExtraInfoSelector); const dispatchActionPromise = useDispatchActionPromise(); const callLegacyKeyserverRegister = useLegacyAshoatKeyserverCall( legacyKeyserverRegister, ); const callIdentityPasswordRegister = useIdentityPasswordRegister(); const identityRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, farcasterID: ?string, onAlertAcknowledged: ?() => mixed, ) => { const identityRegisterPromise = (async () => { try { return await callIdentityPasswordRegister( accountSelection.username, accountSelection.password, farcasterID, ); } catch (e) { const messageForException = getMessageForException(e); - if (messageForException === 'username reserved') { + if (messageForException === 'username_reserved') { Alert.alert( usernameReservedAlertDetails.title, usernameReservedAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); - } else if (messageForException === 'username already exists') { + } else if (messageForException === 'username_already_exists') { Alert.alert( usernameTakenAlertDetails.title, usernameTakenAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else if (messageForException === 'Unsupported version') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } throw e; } })(); void dispatchActionPromise( identityRegisterActionTypes, identityRegisterPromise, ); await identityRegisterPromise; }, [callIdentityPasswordRegister, dispatchActionPromise], ); const legacyKeyserverRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, keyserverURL: string, onAlertAcknowledged: ?() => mixed, ) => { const extraInfo = await legacyLogInExtraInfo(); const legacyKeyserverRegisterPromise = (async () => { try { return await callLegacyKeyserverRegister( { ...extraInfo, username: accountSelection.username, password: accountSelection.password, }, { urlPrefixOverride: keyserverURL, }, ); } catch (e) { const messageForException = getMessageForException(e); if (messageForException === 'username_reserved') { Alert.alert( usernameReservedAlertDetails.title, usernameReservedAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else if (messageForException === 'username_taken') { Alert.alert( usernameTakenAlertDetails.title, usernameTakenAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else if (messageForException === 'client_version_unsupported') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } throw e; } })(); void dispatchActionPromise( legacyKeyserverRegisterActionTypes, legacyKeyserverRegisterPromise, undefined, ({ calendarQuery: extraInfo.calendarQuery, }: LegacyLogInStartingPayload), ); await legacyKeyserverRegisterPromise; }, [legacyLogInExtraInfo, callLegacyKeyserverRegister, dispatchActionPromise], ); const legacySiweServerCall = useLegacySIWEServerCall(); const legacyKeyserverRegisterEthereumAccount = React.useCallback( async ( accountSelection: EthereumAccountSelection, keyserverURL: string, onAlertAcknowledged: ?() => mixed, ) => { try { await legacySiweServerCall(accountSelection, { urlPrefixOverride: keyserverURL, }); } catch (e) { const messageForException = getMessageForException(e); if (messageForException === 'client_version_unsupported') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } throw e; } }, [legacySiweServerCall], ); const identityWalletRegisterCall = useIdentityWalletRegisterCall(); const identityRegisterEthereumAccount = React.useCallback( async ( accountSelection: EthereumAccountSelection, farcasterID: ?string, onNonceExpired: () => mixed, onAlertAcknowledged: ?() => mixed, ) => { try { await identityWalletRegisterCall({ address: accountSelection.address, message: accountSelection.message, signature: accountSelection.signature, fid: farcasterID, }); } catch (e) { const messageForException = getMessageForException(e); if (messageForException === 'nonce expired') { onNonceExpired(); } else if (messageForException === 'Unsupported version') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } throw e; } }, [identityWalletRegisterCall], ); const dispatch = useDispatch(); const returnedFunc = React.useCallback( (input: RegistrationServerCallInput) => new Promise( // eslint-disable-next-line no-async-promise-executor async (resolve, reject) => { try { if (currentStep.step !== 'inactive') { return; } const { accountSelection, avatarData, keyserverURL: passedKeyserverURL, farcasterID, siweBackupSecrets, clearCachedSelections, onNonceExpired, onAlertAcknowledged, } = input; const keyserverURL = passedKeyserverURL ?? defaultURLPrefix; if ( accountSelection.accountType === 'username' && !usingCommServicesAccessToken ) { await legacyKeyserverRegisterUsernameAccount( accountSelection, keyserverURL, onAlertAcknowledged, ); } else if (accountSelection.accountType === 'username') { await identityRegisterUsernameAccount( accountSelection, farcasterID, onAlertAcknowledged, ); } else if (!usingCommServicesAccessToken) { await legacyKeyserverRegisterEthereumAccount( accountSelection, keyserverURL, onAlertAcknowledged, ); } else { await identityRegisterEthereumAccount( accountSelection, farcasterID, onNonceExpired, onAlertAcknowledged, ); } if (passedKeyserverURL) { dispatch({ type: setURLPrefix, payload: passedKeyserverURL, }); } if (farcasterID) { dispatch({ type: setSyncedMetadataEntryActionType, payload: { name: syncedMetadataNames.CURRENT_USER_FID, data: farcasterID, }, }); } if (siweBackupSecrets) { await commCoreModule.setSIWEBackupSecrets(siweBackupSecrets); } const credentialsToSave = accountSelection.accountType === 'username' ? { username: accountSelection.username, password: accountSelection.password, } : null; if (usingCommServicesAccessToken) { setCurrentStep({ step: 'identity_registration_dispatched', avatarData, clearCachedSelections, onAlertAcknowledged, credentialsToSave, resolve, reject, }); } else { setCurrentStep({ step: 'authoritative_keyserver_registration_dispatched', avatarData, clearCachedSelections, credentialsToSave, resolve, reject, }); } } catch (e) { reject(e); } }, ), [ currentStep, legacyKeyserverRegisterUsernameAccount, identityRegisterUsernameAccount, legacyKeyserverRegisterEthereumAccount, identityRegisterEthereumAccount, dispatch, ], ); // STEP 2: REGISTERING ON AUTHORITATIVE KEYSERVER const keyserverAuth = useKeyserverAuthWithRetry(authoritativeKeyserverID); const isRegisteredOnIdentity = useSelector( state => !!state.commServicesAccessToken && !!state.currentUserInfo && !state.currentUserInfo.anonymous, ); // We call deleteDiscardedIdentityAccount in order to reset state if identity // registration succeeds but authoritative keyserver auth fails const deleteDiscardedIdentityAccount = useDeleteDiscardedIdentityAccount(); const preRequestUserState = usePreRequestUserState(); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); const registeringOnAuthoritativeKeyserverRef = React.useRef(false); React.useEffect(() => { if ( !isRegisteredOnIdentity || currentStep.step !== 'identity_registration_dispatched' || registeringOnAuthoritativeKeyserverRef.current ) { return; } registeringOnAuthoritativeKeyserverRef.current = true; const { avatarData, clearCachedSelections, onAlertAcknowledged, credentialsToSave, resolve, reject, } = currentStep; void (async () => { try { await keyserverAuth({ authActionSource: process.env.BROWSER ? logInActionSources.keyserverAuthFromWeb : logInActionSources.keyserverAuthFromNative, setInProgress: () => {}, hasBeenCancelled: () => false, doNotRegister: false, }); setCurrentStep({ step: 'authoritative_keyserver_registration_dispatched', avatarData, clearCachedSelections, credentialsToSave, resolve, reject, }); } catch (keyserverAuthException) { const messageForException = getMessageForException( keyserverAuthException, ); const discardIdentityAccountPromise: Promise = (async () => { try { const deletionResult = await deleteDiscardedIdentityAccount( credentialsToSave?.password, ); if (messageForException === 'client_version_unsupported') { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); } return deletionResult; } catch (deleteException) { // We swallow the exception here because // discardIdentityAccountPromise 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 by // swallowing exceptions we ensure that we always dispatch a // success. Alert.alert( 'Account created but login failed', 'We were able to create your account, but were unable to log ' + 'you in. Try going back to the login screen and logging in ' + 'with your new credentials.', [{ text: 'OK', onPress: onAlertAcknowledged }], { cancelable: !onAlertAcknowledged }, ); return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; } })(); void dispatchActionPromise( deleteAccountActionTypes, discardIdentityAccountPromise, ); await waitUntilDatabaseDeleted(); reject(keyserverAuthException); setCurrentStep(inactiveStep); } finally { registeringOnAuthoritativeKeyserverRef.current = false; } })(); }, [ currentStep, isRegisteredOnIdentity, keyserverAuth, dispatchActionPromise, deleteDiscardedIdentityAccount, preRequestUserState, commServicesAccessToken, ]); // STEP 3: SETTING AVATAR const uploadSelectedMedia = useUploadSelectedMedia(); const nativeSetUserAvatar = useNativeSetUserAvatar(); const isLoggedInToAuthoritativeKeyserver = useSelector( isLoggedInToKeyserver(authoritativeKeyserverID), ); const avatarBeingSetRef = React.useRef(false); React.useEffect(() => { if ( !isLoggedInToAuthoritativeKeyserver || currentStep.step !== 'authoritative_keyserver_registration_dispatched' || avatarBeingSetRef.current ) { return; } avatarBeingSetRef.current = true; const { avatarData, resolve, clearCachedSelections, credentialsToSave } = currentStep; void (async () => { try { if (!avatarData) { return; } let updateUserAvatarRequest; if (!avatarData.needsUpload) { ({ updateUserAvatarRequest } = avatarData); } else { const { mediaSelection } = avatarData; updateUserAvatarRequest = await uploadSelectedMedia(mediaSelection); if (!updateUserAvatarRequest) { return; } } await nativeSetUserAvatar(updateUserAvatarRequest); } finally { dispatch({ type: setSyncedMetadataEntryActionType, payload: { name: syncedMetadataNames.DB_VERSION, data: `${persistConfig.version}`, }, }); dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); clearCachedSelections(); if (credentialsToSave) { void setNativeCredentials(credentialsToSave); } setCurrentStep(inactiveStep); avatarBeingSetRef.current = false; resolve(); } })(); }, [ currentStep, isLoggedInToAuthoritativeKeyserver, uploadSelectedMedia, nativeSetUserAvatar, dispatch, ]); return returnedFunc; } export { useRegistrationServerCall }; diff --git a/services/identity/src/client_service.rs b/services/identity/src/client_service.rs index 61c7a198e..7f71d2bb9 100644 --- a/services/identity/src/client_service.rs +++ b/services/identity/src/client_service.rs @@ -1,1195 +1,1211 @@ // Standard library imports use std::str::FromStr; // External crate imports use comm_lib::aws::DynamoDBError; use comm_lib::shared::reserved_users::RESERVED_USERNAME_SET; use comm_opaque2::grpc::protocol_error_to_grpc_status; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use siwe::eip55; use tonic::Response; use tracing::{debug, error, info, warn}; // Workspace crate imports use crate::config::CONFIG; -use crate::constants::error_types; +use crate::constants::{error_types, tonic_status_messages}; use crate::database::{ DBDeviceTypeInt, DatabaseClient, DeviceType, KeyPayload }; use crate::device_list::SignedDeviceList; use crate::error::{DeviceListError, Error as DBError}; use crate::grpc_services::authenticated::DeletePasswordUserInfo; use crate::grpc_services::protos::unauth::{ find_user_id_request, AddReservedUsernamesRequest, AuthResponse, Empty, ExistingDeviceLoginRequest, FindUserIdRequest, FindUserIdResponse, GenerateNonceResponse, OpaqueLoginFinishRequest, OpaqueLoginStartRequest, OpaqueLoginStartResponse, RegistrationFinishRequest, RegistrationStartRequest, RegistrationStartResponse, RemoveReservedUsernameRequest, ReservedRegistrationStartRequest, ReservedWalletRegistrationRequest, SecondaryDeviceKeysUploadRequest, VerifyUserAccessTokenRequest, VerifyUserAccessTokenResponse, WalletAuthRequest, GetFarcasterUsersRequest, GetFarcasterUsersResponse }; use crate::grpc_services::shared::get_platform_metadata; use crate::grpc_utils::{ DeviceKeyUploadActions, RegistrationActions, SignedNonce }; use crate::nonce::generate_nonce_data; use crate::reserved_users::{ validate_account_ownership_message_and_get_user_id, validate_add_reserved_usernames_message, validate_remove_reserved_username_message, }; use crate::siwe::{ is_valid_ethereum_address, parse_and_verify_siwe_message, SocialProof, }; use crate::token::{AccessTokenData, AuthType}; pub use crate::grpc_services::protos::unauth::identity_client_service_server::{ IdentityClientService, IdentityClientServiceServer, }; use crate::regex::is_valid_username; #[derive(Clone, Serialize, Deserialize)] pub enum WorkflowInProgress { Registration(Box), Login(Box), Update(UpdateState), PasswordUserDeletion(Box), } #[derive(Clone, Serialize, Deserialize)] pub struct UserRegistrationInfo { pub username: String, pub flattened_device_key_upload: FlattenedDeviceKeyUpload, pub user_id: Option, pub farcaster_id: Option, pub initial_device_list: Option, } #[derive(Clone, Serialize, Deserialize)] pub struct UserLoginInfo { pub user_id: String, pub username: String, pub flattened_device_key_upload: FlattenedDeviceKeyUpload, pub opaque_server_login: comm_opaque2::server::Login, pub device_to_remove: Option, } #[derive(Clone, Serialize, Deserialize)] pub struct UpdateState { pub user_id: String, } #[derive(Clone, Serialize, Deserialize)] pub struct FlattenedDeviceKeyUpload { pub device_id_key: String, pub key_payload: String, pub key_payload_signature: String, pub content_prekey: String, pub content_prekey_signature: String, pub content_one_time_keys: Vec, pub notif_prekey: String, pub notif_prekey_signature: String, pub notif_one_time_keys: Vec, pub device_type: DeviceType, } #[derive(derive_more::Constructor)] pub struct ClientService { client: DatabaseClient, } #[tonic::async_trait] impl IdentityClientService for ClientService { #[tracing::instrument(skip_all)] async fn register_password_user_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); debug!("Received registration request for: {}", message.username); if !is_valid_username(&message.username) || is_valid_ethereum_address(&message.username) { return Err(tonic::Status::invalid_argument("invalid username")); } self.check_username_taken(&message.username).await?; let username_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&message.username) .await .map_err(handle_db_error)?; if username_in_reserved_usernames_table { - return Err(tonic::Status::already_exists("username already exists")); + return Err(tonic::Status::already_exists( + tonic_status_messages::USERNAME_ALREADY_EXISTS, + )); } if RESERVED_USERNAME_SET.contains(&message.username) { - return Err(tonic::Status::invalid_argument("username reserved")); + return Err(tonic::Status::invalid_argument( + tonic_status_messages::USERNAME_RESERVED, + )); } if let Some(fid) = &message.farcaster_id { self.check_farcaster_id_taken(fid).await?; } let registration_state = construct_user_registration_info( &message, None, message.username.clone(), message.farcaster_id.clone(), )?; let server_registration = comm_opaque2::server::Registration::new(); let server_message = server_registration .start( &CONFIG.server_setup, &message.opaque_registration_request, message.username.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let session_id = self .client .insert_workflow(WorkflowInProgress::Registration(Box::new( registration_state, ))) .await .map_err(handle_db_error)?; let response = RegistrationStartResponse { session_id, opaque_registration_response: server_message, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn register_reserved_password_user_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); self.check_username_taken(&message.username).await?; if RESERVED_USERNAME_SET.contains(&message.username) { - return Err(tonic::Status::invalid_argument("username reserved")); + return Err(tonic::Status::invalid_argument( + tonic_status_messages::USERNAME_RESERVED, + )); } let username_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&message.username) .await .map_err(handle_db_error)?; if !username_in_reserved_usernames_table { return Err(tonic::Status::permission_denied("username not reserved")); } let user_id = validate_account_ownership_message_and_get_user_id( &message.username, &message.keyserver_message, &message.keyserver_signature, )?; let registration_state = construct_user_registration_info( &message, Some(user_id), message.username.clone(), None, )?; let server_registration = comm_opaque2::server::Registration::new(); let server_message = server_registration .start( &CONFIG.server_setup, &message.opaque_registration_request, message.username.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let session_id = self .client .insert_workflow(WorkflowInProgress::Registration(Box::new( registration_state, ))) .await .map_err(handle_db_error)?; let response = RegistrationStartResponse { session_id, opaque_registration_response: server_message, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn register_password_user_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); if let Some(WorkflowInProgress::Registration(state)) = self .client .get_workflow(message.session_id) .await .map_err(handle_db_error)? { let server_registration = comm_opaque2::server::Registration::new(); let password_file = server_registration .finish(&message.opaque_registration_upload) .map_err(protocol_error_to_grpc_status)?; let login_time = chrono::Utc::now(); let device_id = state.flattened_device_key_upload.device_id_key.clone(); let username = state.username.clone(); let user_id = self .client .add_password_user_to_users_table( *state, password_file, platform_metadata, login_time, ) .await .map_err(handle_db_error)?; // Create access token let token = AccessTokenData::with_created_time( user_id.clone(), device_id, login_time, crate::token::AuthType::Password, &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username, }; Ok(Response::new(response)) } else { Err(tonic::Status::not_found("session not found")) } } #[tracing::instrument(skip_all)] async fn log_in_password_user_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); debug!("Attempting to log in user: {:?}", &message.username); let user_id_and_password_file = self .client .get_user_id_and_password_file_from_username(&message.username) .await .map_err(handle_db_error)?; let (user_id, password_file_bytes) = if let Some(data) = user_id_and_password_file { data } else { // It's possible that the user attempting login is already registered // on Ashoat's keyserver. If they are, we should send back a gRPC status // code instructing them to get a signed message from Ashoat's keyserver // in order to claim their username and register with the Identity // service. let username_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&message.username) .await .map_err(handle_db_error)?; if username_in_reserved_usernames_table { return Err(tonic::Status::permission_denied( "need keyserver message to claim username", )); } - return Err(tonic::Status::not_found("user not found")); + return Err(tonic::Status::not_found( + tonic_status_messages::USER_NOT_FOUND, + )); }; let flattened_device_key_upload = construct_flattened_device_key_upload(&message)?; let maybe_device_to_remove = self .get_keyserver_device_to_remove( &user_id, &flattened_device_key_upload.device_id_key, message.force.unwrap_or(false), &flattened_device_key_upload.device_type, ) .await?; let mut server_login = comm_opaque2::server::Login::new(); let server_response = server_login .start( &CONFIG.server_setup, &password_file_bytes, &message.opaque_login_request, message.username.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let login_state = construct_user_login_info( user_id, message.username, server_login, flattened_device_key_upload, maybe_device_to_remove, )?; let session_id = self .client .insert_workflow(WorkflowInProgress::Login(Box::new(login_state))) .await .map_err(handle_db_error)?; let response = Response::new(OpaqueLoginStartResponse { session_id, opaque_login_response: server_response, }); Ok(response) } #[tracing::instrument(skip_all)] async fn log_in_password_user_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); if let Some(WorkflowInProgress::Login(state)) = self .client .get_workflow(message.session_id) .await .map_err(handle_db_error)? { let mut server_login = state.opaque_server_login.clone(); server_login .finish(&message.opaque_login_upload) .map_err(protocol_error_to_grpc_status)?; if let Some(device_to_remove) = state.device_to_remove { self .client .remove_device(state.user_id.clone(), device_to_remove) .await .map_err(handle_db_error)?; } let login_time = chrono::Utc::now(); self .client .add_user_device( state.user_id.clone(), state.flattened_device_key_upload.clone(), platform_metadata, login_time, ) .await .map_err(handle_db_error)?; // Create access token let token = AccessTokenData::with_created_time( state.user_id.clone(), state.flattened_device_key_upload.device_id_key, login_time, crate::token::AuthType::Password, &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id: state.user_id, access_token, username: state.username, }; Ok(Response::new(response)) } else { Err(tonic::Status::not_found("session not found")) } } #[tracing::instrument(skip_all)] async fn log_in_wallet_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); // WalletAuthRequest is used for both log_in_wallet_user and register_wallet_user if !message.initial_device_list.is_empty() { return Err(tonic::Status::invalid_argument( "unexpected initial device list", )); } let parsed_message = parse_and_verify_siwe_message( &message.siwe_message, &message.siwe_signature, )?; self.verify_and_remove_nonce(&parsed_message.nonce).await?; let wallet_address = eip55(&parsed_message.address); let flattened_device_key_upload = construct_flattened_device_key_upload(&message)?; let login_time = chrono::Utc::now(); let Some(user_id) = self .client .get_user_id_from_user_info(wallet_address.clone(), &AuthType::Wallet) .await .map_err(handle_db_error)? else { // It's possible that the user attempting login is already registered // on Ashoat's keyserver. If they are, we should send back a gRPC status // code instructing them to get a signed message from Ashoat's keyserver // in order to claim their wallet address and register with the Identity // service. let username_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&wallet_address) .await .map_err(handle_db_error)?; if username_in_reserved_usernames_table { return Err(tonic::Status::permission_denied( "need keyserver message to claim username", )); } - return Err(tonic::Status::not_found("user not found")); + return Err(tonic::Status::not_found( + tonic_status_messages::USER_NOT_FOUND, + )); }; self .client .add_user_device( user_id.clone(), flattened_device_key_upload.clone(), platform_metadata, chrono::Utc::now(), ) .await .map_err(handle_db_error)?; // Create access token let token = AccessTokenData::with_created_time( user_id.clone(), flattened_device_key_upload.device_id_key, login_time, crate::token::AuthType::Wallet, &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username: wallet_address, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn register_wallet_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); let parsed_message = parse_and_verify_siwe_message( &message.siwe_message, &message.siwe_signature, )?; match self .client .get_nonce_from_nonces_table(&parsed_message.nonce) .await .map_err(handle_db_error)? { None => return Err(tonic::Status::invalid_argument("invalid nonce")), Some(nonce) if nonce.is_expired() => { // we don't need to remove the nonce from the table here // because the DynamoDB TTL will take care of it return Err(tonic::Status::aborted("nonce expired")); } Some(_) => self .client .remove_nonce_from_nonces_table(&parsed_message.nonce) .await .map_err(handle_db_error)?, }; let wallet_address = eip55(&parsed_message.address); self.check_wallet_address_taken(&wallet_address).await?; let username_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&wallet_address) .await .map_err(handle_db_error)?; if username_in_reserved_usernames_table { return Err(tonic::Status::already_exists( - "wallet address already exists", + tonic_status_messages::WALLET_ADDRESS_TAKEN, )); } if let Some(fid) = &message.farcaster_id { self.check_farcaster_id_taken(fid).await?; } let flattened_device_key_upload = construct_flattened_device_key_upload(&message)?; let login_time = chrono::Utc::now(); let initial_device_list = message.get_and_verify_initial_device_list()?; let social_proof = SocialProof::new(message.siwe_message, message.siwe_signature); let user_id = self .client .add_wallet_user_to_users_table( flattened_device_key_upload.clone(), wallet_address.clone(), social_proof, None, platform_metadata, login_time, message.farcaster_id, initial_device_list, ) .await .map_err(handle_db_error)?; // Create access token let token = AccessTokenData::with_created_time( user_id.clone(), flattened_device_key_upload.device_id_key, login_time, crate::token::AuthType::Wallet, &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username: wallet_address, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn register_reserved_wallet_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); let parsed_message = parse_and_verify_siwe_message( &message.siwe_message, &message.siwe_signature, )?; self.verify_and_remove_nonce(&parsed_message.nonce).await?; let wallet_address = eip55(&parsed_message.address); self.check_wallet_address_taken(&wallet_address).await?; let wallet_address_in_reserved_usernames_table = self .client .username_in_reserved_usernames_table(&wallet_address) .await .map_err(handle_db_error)?; if !wallet_address_in_reserved_usernames_table { return Err(tonic::Status::permission_denied( - "wallet address not reserved", + tonic_status_messages::WALLET_ADDRESS_NOT_RESERVED, )); } let user_id = validate_account_ownership_message_and_get_user_id( &wallet_address, &message.keyserver_message, &message.keyserver_signature, )?; let flattened_device_key_upload = construct_flattened_device_key_upload(&message)?; let initial_device_list = message.get_and_verify_initial_device_list()?; let social_proof = SocialProof::new(message.siwe_message, message.siwe_signature); let login_time = chrono::Utc::now(); self .client .add_wallet_user_to_users_table( flattened_device_key_upload.clone(), wallet_address.clone(), social_proof, Some(user_id.clone()), platform_metadata, login_time, None, initial_device_list, ) .await .map_err(handle_db_error)?; let token = AccessTokenData::with_created_time( user_id.clone(), flattened_device_key_upload.device_id_key, login_time, crate::token::AuthType::Wallet, &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username: wallet_address, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn upload_keys_for_registered_device_and_log_in( &self, request: tonic::Request, ) -> Result, tonic::Status> { let platform_metadata = get_platform_metadata(&request)?; let message = request.into_inner(); let challenge_response = SignedNonce::try_from(&message)?; let flattened_device_key_upload = construct_flattened_device_key_upload(&message)?; let user_id = message.user_id; let device_id = flattened_device_key_upload.device_id_key.clone(); let nonce = challenge_response.verify_and_get_nonce(&device_id)?; self.verify_and_remove_nonce(&nonce).await?; let user_identity = self .client .get_user_identity(&user_id) .await .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + .ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; let Some(device_list) = self .client .get_current_device_list(&user_id) .await .map_err(handle_db_error)? else { warn!("User {} does not have valid device list. Secondary device auth impossible.", user_id); return Err(tonic::Status::aborted("device list error")); }; if !device_list.device_ids.contains(&device_id) { return Err(tonic::Status::permission_denied( "device not in device list", )); } let login_time = chrono::Utc::now(); let identifier = user_identity.identifier; let username = identifier.username().to_string(); let token = AccessTokenData::with_created_time( user_id.clone(), device_id, login_time, identifier.into(), &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; self .client .put_device_data( &user_id, flattened_device_key_upload, platform_metadata, login_time, ) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn log_in_existing_device( &self, request: tonic::Request, ) -> std::result::Result, tonic::Status> { let message = request.into_inner(); let challenge_response = SignedNonce::try_from(&message)?; let ExistingDeviceLoginRequest { user_id, device_id, .. } = message; let nonce = challenge_response.verify_and_get_nonce(&device_id)?; self.verify_and_remove_nonce(&nonce).await?; let (identity_response, device_list_response) = tokio::join!( self.client.get_user_identity(&user_id), self.client.get_current_device_list(&user_id) ); - let user_identity = identity_response - .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + let user_identity = + identity_response.map_err(handle_db_error)?.ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; let device_list = device_list_response .map_err(handle_db_error)? .ok_or_else(|| { warn!("User {} does not have a valid device list.", user_id); tonic::Status::aborted("device list error") })?; if !device_list.device_ids.contains(&device_id) { return Err(tonic::Status::permission_denied( "device not in device list", )); } let login_time = chrono::Utc::now(); let identifier = user_identity.identifier; let username = identifier.username().to_string(); let token = AccessTokenData::with_created_time( user_id.clone(), device_id, login_time, identifier.into(), &mut OsRng, ); let access_token = token.access_token.clone(); self .client .put_access_token_data(token) .await .map_err(handle_db_error)?; let response = AuthResponse { user_id, access_token, username, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn generate_nonce( &self, _request: tonic::Request, ) -> Result, tonic::Status> { let nonce_data = generate_nonce_data(&mut OsRng); match self .client .add_nonce_to_nonces_table(nonce_data.clone()) .await { Ok(_) => Ok(Response::new(GenerateNonceResponse { nonce: nonce_data.nonce, })), Err(e) => Err(handle_db_error(e)), } } #[tracing::instrument(skip_all)] async fn verify_user_access_token( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); debug!("Verifying device: {}", &message.device_id); let token_valid = self .client .verify_access_token( message.user_id, message.device_id.clone(), message.access_token, ) .await .map_err(handle_db_error)?; let response = Response::new(VerifyUserAccessTokenResponse { token_valid }); debug!( "device {} was verified: {}", &message.device_id, token_valid ); Ok(response) } #[tracing::instrument(skip_all)] async fn add_reserved_usernames( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let user_details = validate_add_reserved_usernames_message( &message.message, &message.signature, )?; let filtered_user_details = self .client .filter_out_taken_usernames(user_details) .await .map_err(handle_db_error)?; self .client .add_usernames_to_reserved_usernames_table(filtered_user_details) .await .map_err(handle_db_error)?; let response = Response::new(Empty {}); Ok(response) } #[tracing::instrument(skip_all)] async fn remove_reserved_username( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let username = validate_remove_reserved_username_message( &message.message, &message.signature, )?; self .client .delete_username_from_reserved_usernames_table(username) .await .map_err(handle_db_error)?; let response = Response::new(Empty {}); Ok(response) } #[tracing::instrument(skip_all)] async fn ping( &self, _request: tonic::Request, ) -> Result, tonic::Status> { let response = Response::new(Empty {}); Ok(response) } #[tracing::instrument(skip_all)] async fn find_user_id( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); use find_user_id_request::Identifier; let (user_ident, auth_type) = match message.identifier { None => { return Err(tonic::Status::invalid_argument("no identifier provided")) } Some(Identifier::Username(username)) => (username, AuthType::Password), Some(Identifier::WalletAddress(address)) => (address, AuthType::Wallet), }; let (is_reserved_result, user_id_result) = tokio::join!( self .client .username_in_reserved_usernames_table(&user_ident), self .client .get_user_id_from_user_info(user_ident.clone(), &auth_type), ); let is_reserved = is_reserved_result.map_err(handle_db_error)?; let user_id = user_id_result.map_err(handle_db_error)?; Ok(Response::new(FindUserIdResponse { user_id, is_reserved, })) } #[tracing::instrument(skip_all)] async fn get_farcaster_users( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let farcaster_users = self .client .get_farcaster_users(message.farcaster_ids) .await .map_err(handle_db_error)? .into_iter() .map(|d| d.0) .collect(); Ok(Response::new(GetFarcasterUsersResponse { farcaster_users })) } } impl ClientService { async fn check_username_taken( &self, username: &str, ) -> Result<(), tonic::Status> { let username_taken = self .client .username_taken(username.to_string()) .await .map_err(handle_db_error)?; if username_taken { - return Err(tonic::Status::already_exists("username already exists")); + return Err(tonic::Status::already_exists( + tonic_status_messages::USERNAME_ALREADY_EXISTS, + )); } Ok(()) } async fn check_wallet_address_taken( &self, wallet_address: &str, ) -> Result<(), tonic::Status> { let wallet_address_taken = self .client .wallet_address_taken(wallet_address.to_string()) .await .map_err(handle_db_error)?; if wallet_address_taken { return Err(tonic::Status::already_exists( - "wallet address already exists", + tonic_status_messages::WALLET_ADDRESS_TAKEN, )); } Ok(()) } async fn check_farcaster_id_taken( &self, farcaster_id: &str, ) -> Result<(), tonic::Status> { let fid_already_registered = !self .client .get_farcaster_users(vec![farcaster_id.to_string()]) .await .map_err(handle_db_error)? .is_empty(); if fid_already_registered { return Err(tonic::Status::already_exists( "farcaster ID already associated with different user", )); } Ok(()) } async fn verify_and_remove_nonce( &self, nonce: &str, ) -> Result<(), tonic::Status> { match self .client .get_nonce_from_nonces_table(nonce) .await .map_err(handle_db_error)? { None => return Err(tonic::Status::invalid_argument("invalid nonce")), Some(nonce) if nonce.is_expired() => { // we don't need to remove the nonce from the table here // because the DynamoDB TTL will take care of it return Err(tonic::Status::aborted("nonce expired")); } Some(nonce_data) => self .client .remove_nonce_from_nonces_table(&nonce_data.nonce) .await .map_err(handle_db_error)?, }; Ok(()) } async fn get_keyserver_device_to_remove( &self, user_id: &str, new_keyserver_device_id: &str, force: bool, device_type: &DeviceType, ) -> Result, tonic::Status> { if device_type != &DeviceType::Keyserver { return Ok(None); } let maybe_keyserver_device_id = self .client .get_keyserver_device_id_for_user(user_id) .await .map_err(handle_db_error)?; let Some(existing_keyserver_device_id) = maybe_keyserver_device_id else { return Ok(None); }; if new_keyserver_device_id == existing_keyserver_device_id { return Ok(None); } if force { info!( "keyserver {} will be removed from the device list", existing_keyserver_device_id ); Ok(Some(existing_keyserver_device_id)) } else { Err(tonic::Status::already_exists( "user already has a keyserver", )) } } } #[tracing::instrument(skip_all)] pub fn handle_db_error(db_error: DBError) -> tonic::Status { match db_error { DBError::AwsSdk(DynamoDBError::InternalServerError(_)) | DBError::AwsSdk(DynamoDBError::ProvisionedThroughputExceededException( _, )) | DBError::AwsSdk(DynamoDBError::RequestLimitExceeded(_)) => { tonic::Status::unavailable("please retry") } DBError::DeviceList(DeviceListError::InvalidDeviceListUpdate) => { tonic::Status::invalid_argument("invalid device list update") } DBError::DeviceList(DeviceListError::InvalidSignature) => { tonic::Status::invalid_argument("invalid device list signature") } e => { error!( errorType = error_types::GENERIC_DB_LOG, "Encountered an unexpected error: {}", e ); tonic::Status::failed_precondition("unexpected error") } } } fn construct_user_registration_info( message: &(impl DeviceKeyUploadActions + RegistrationActions), user_id: Option, username: String, farcaster_id: Option, ) -> Result { Ok(UserRegistrationInfo { username, flattened_device_key_upload: construct_flattened_device_key_upload( message, )?, user_id, farcaster_id, initial_device_list: message.get_and_verify_initial_device_list()?, }) } fn construct_user_login_info( user_id: String, username: String, opaque_server_login: comm_opaque2::server::Login, flattened_device_key_upload: FlattenedDeviceKeyUpload, device_to_remove: Option, ) -> Result { Ok(UserLoginInfo { user_id, username, flattened_device_key_upload, opaque_server_login, device_to_remove, }) } fn construct_flattened_device_key_upload( message: &impl DeviceKeyUploadActions, ) -> Result { - let key_info = KeyPayload::from_str(&message.payload()?) - .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?; + let key_info = KeyPayload::from_str(&message.payload()?).map_err(|_| { + tonic::Status::invalid_argument(tonic_status_messages::MALFORMED_PAYLOAD) + })?; let flattened_device_key_upload = FlattenedDeviceKeyUpload { device_id_key: key_info.primary_identity_public_keys.ed25519, key_payload: message.payload()?, key_payload_signature: message.payload_signature()?, content_prekey: message.content_prekey()?, content_prekey_signature: message.content_prekey_signature()?, content_one_time_keys: message.one_time_content_prekeys()?, notif_prekey: message.notif_prekey()?, notif_prekey_signature: message.notif_prekey_signature()?, notif_one_time_keys: message.one_time_notif_prekeys()?, device_type: DeviceType::try_from(DBDeviceTypeInt(message.device_type()?)) .map_err(handle_db_error)?, }; Ok(flattened_device_key_upload) } diff --git a/services/identity/src/constants.rs b/services/identity/src/constants.rs index 03f0fc642..e4dd7f603 100644 --- a/services/identity/src/constants.rs +++ b/services/identity/src/constants.rs @@ -1,300 +1,310 @@ use tokio::time::Duration; // Secrets pub const SECRETS_DIRECTORY: &str = "secrets"; pub const SECRETS_SETUP_FILE: &str = "server_setup.txt"; // DynamoDB // User table information, supporting opaque_ke 2.0 and X3DH information // Users can sign in either through username+password or Eth wallet. // // This structure should be aligned with the messages defined in // shared/protos/identity_unauthenticated.proto // // Structure for a user should be: // { // userID: String, // opaqueRegistrationData: Option, // username: Option, // walletAddress: Option, // devices: HashMap // } // // A device is defined as: // { // deviceType: String, # client or keyserver // keyPayload: String, // keyPayloadSignature: String, // identityPreKey: String, // identityPreKeySignature: String, // identityOneTimeKeys: Vec, // notifPreKey: String, // notifPreKeySignature: String, // notifOneTimeKeys: Vec, // socialProof: Option // } // } // // Additional context: // "devices" uses the signing public identity key of the device as a key for the devices map // "keyPayload" is a JSON encoded string containing identity and notif keys (both signature and verification) // if "deviceType" == "keyserver", then the device will not have any notif key information pub const USERS_TABLE: &str = "identity-users"; pub const USERS_TABLE_PARTITION_KEY: &str = "userID"; pub const USERS_TABLE_REGISTRATION_ATTRIBUTE: &str = "opaqueRegistrationData"; pub const USERS_TABLE_USERNAME_ATTRIBUTE: &str = "username"; pub const USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME: &str = "deviceType"; pub const USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE: &str = "walletAddress"; pub const USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME: &str = "socialProof"; pub const USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME: &str = "deviceListTimestamp"; pub const USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME: &str = "farcasterID"; pub const USERS_TABLE_USERNAME_INDEX: &str = "username-index"; pub const USERS_TABLE_WALLET_ADDRESS_INDEX: &str = "walletAddress-index"; pub const USERS_TABLE_FARCASTER_ID_INDEX: &str = "farcasterID-index"; pub mod token_table { pub const NAME: &str = "identity-tokens"; pub const PARTITION_KEY: &str = "userID"; pub const SORT_KEY: &str = "signingPublicKey"; pub const ATTR_CREATED: &str = "created"; pub const ATTR_AUTH_TYPE: &str = "authType"; pub const ATTR_VALID: &str = "valid"; pub const ATTR_TOKEN: &str = "token"; } pub const NONCE_TABLE: &str = "identity-nonces"; pub const NONCE_TABLE_PARTITION_KEY: &str = "nonce"; pub const NONCE_TABLE_CREATED_ATTRIBUTE: &str = "created"; pub const NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE: &str = "expirationTime"; pub const NONCE_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE: &str = "expirationTimeUnix"; pub const WORKFLOWS_IN_PROGRESS_TABLE: &str = "identity-workflows-in-progress"; pub const WORKFLOWS_IN_PROGRESS_PARTITION_KEY: &str = "id"; pub const WORKFLOWS_IN_PROGRESS_WORKFLOW_ATTRIBUTE: &str = "workflow"; pub const WORKFLOWS_IN_PROGRESS_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE: &str = "expirationTimeUnix"; // Usernames reserved because they exist in Ashoat's keyserver already pub const RESERVED_USERNAMES_TABLE: &str = "identity-reserved-usernames"; pub const RESERVED_USERNAMES_TABLE_PARTITION_KEY: &str = "username"; pub const RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE: &str = "userID"; // Users table social proof attribute pub const SOCIAL_PROOF_MESSAGE_ATTRIBUTE: &str = "siweMessage"; pub const SOCIAL_PROOF_SIGNATURE_ATTRIBUTE: &str = "siweSignature"; pub mod devices_table { /// table name pub const NAME: &str = "identity-devices"; pub const TIMESTAMP_INDEX_NAME: &str = "deviceList-timestamp-index"; pub const DEVICE_ID_INDEX_NAME: &str = "deviceID-index"; /// partition key pub const ATTR_USER_ID: &str = "userID"; /// sort key pub const ATTR_ITEM_ID: &str = "itemID"; // itemID prefixes (one shouldn't be a prefix of the other) pub const DEVICE_ITEM_KEY_PREFIX: &str = "device-"; pub const DEVICE_LIST_KEY_PREFIX: &str = "devicelist-"; // device-specific attrs pub const ATTR_DEVICE_KEY_INFO: &str = "deviceKeyInfo"; pub const ATTR_CONTENT_PREKEY: &str = "contentPreKey"; pub const ATTR_NOTIF_PREKEY: &str = "notifPreKey"; pub const ATTR_PLATFORM_DETAILS: &str = "platformDetails"; pub const ATTR_LOGIN_TIME: &str = "loginTime"; // IdentityKeyInfo constants pub const ATTR_KEY_PAYLOAD: &str = "keyPayload"; pub const ATTR_KEY_PAYLOAD_SIGNATURE: &str = "keyPayloadSignature"; // PreKey constants pub const ATTR_PREKEY: &str = "preKey"; pub const ATTR_PREKEY_SIGNATURE: &str = "preKeySignature"; // PlatformDetails constants pub const ATTR_DEVICE_TYPE: &str = "deviceType"; pub const ATTR_CODE_VERSION: &str = "codeVersion"; pub const ATTR_STATE_VERSION: &str = "stateVersion"; pub const ATTR_MAJOR_DESKTOP_VERSION: &str = "majorDesktopVersion"; // device-list-specific attrs pub const ATTR_TIMESTAMP: &str = "timestamp"; pub const ATTR_DEVICE_IDS: &str = "deviceIDs"; pub const ATTR_CURRENT_SIGNATURE: &str = "curPrimarySignature"; pub const ATTR_LAST_SIGNATURE: &str = "lastPrimarySignature"; // one-time key constants pub const ATTR_CONTENT_OTK_COUNT: &str = "contentOTKCount"; pub const ATTR_NOTIF_OTK_COUNT: &str = "notifOTKCount"; // deprecated attributes pub const OLD_ATTR_DEVICE_TYPE: &str = "deviceType"; pub const OLD_ATTR_CODE_VERSION: &str = "codeVersion"; } // One time keys table, which need to exist in their own table to ensure // atomicity of additions and removals pub mod one_time_keys_table { pub const NAME: &str = "identity-one-time-keys"; pub const PARTITION_KEY: &str = "userID#deviceID#olmAccount"; pub const SORT_KEY: &str = "timestamp#keyNumber"; pub const ATTR_ONE_TIME_KEY: &str = "oneTimeKey"; } // Tokio pub const MPSC_CHANNEL_BUFFER_CAPACITY: usize = 1; pub const IDENTITY_SERVICE_SOCKET_ADDR: &str = "[::]:50054"; pub const IDENTITY_SERVICE_WEBSOCKET_ADDR: &str = "[::]:51004"; pub const SOCKET_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(3); // Token pub const ACCESS_TOKEN_LENGTH: usize = 512; // Temporary config pub const AUTH_TOKEN: &str = "COMM_IDENTITY_SERVICE_AUTH_TOKEN"; pub const KEYSERVER_PUBLIC_KEY: &str = "KEYSERVER_PUBLIC_KEY"; // Nonce pub const NONCE_LENGTH: usize = 17; pub const NONCE_TTL_DURATION: Duration = Duration::from_secs(120); // seconds // Device list pub const DEVICE_LIST_TIMESTAMP_VALID_FOR: Duration = Duration::from_secs(300); // Workflows in progress pub const WORKFLOWS_IN_PROGRESS_TTL_DURATION: Duration = Duration::from_secs(120); // Identity pub const DEFAULT_IDENTITY_ENDPOINT: &str = "http://localhost:50054"; // LocalStack pub const LOCALSTACK_ENDPOINT: &str = "LOCALSTACK_ENDPOINT"; // OPAQUE Server Setup pub const OPAQUE_SERVER_SETUP: &str = "OPAQUE_SERVER_SETUP"; // Identity Search pub const OPENSEARCH_ENDPOINT: &str = "OPENSEARCH_ENDPOINT"; pub const DEFAULT_OPENSEARCH_ENDPOINT: &str = "identity-search-domain.us-east-2.opensearch.localhost.localstack.cloud:4566"; pub const IDENTITY_SEARCH_INDEX: &str = "users"; pub const IDENTITY_SEARCH_RESULT_SIZE: u32 = 20; // Log Error Types pub mod error_types { pub const GENERIC_DB_LOG: &str = "DB Error"; pub const OTK_DB_LOG: &str = "One-time Key DB Error"; pub const DEVICE_LIST_DB_LOG: &str = "Device List DB Error"; pub const TOKEN_DB_LOG: &str = "Token DB Error"; pub const FARCASTER_DB_LOG: &str = "Farcaster DB Error"; pub const SYNC_LOG: &str = "Sync Error"; pub const SEARCH_LOG: &str = "Search Error"; pub const SIWE_LOG: &str = "SIWE Error"; pub const GRPC_SERVICES_LOG: &str = "gRPC Services Error"; pub const TUNNELBROKER_LOG: &str = "Tunnelbroker Error"; pub const HTTP_LOG: &str = "HTTP Error"; } // Tonic Status Messages pub mod tonic_status_messages { pub const UNEXPECTED_MESSAGE_DATA: &str = "unexpected_message_data"; + pub const SIGNATURE_INVALID: &str = "signature_invalid"; + pub const MALFORMED_KEY: &str = "malformed_key"; + pub const VERIFICATION_FAILED: &str = "verification_failed"; + pub const MALFORMED_PAYLOAD: &str = "malformed_payload"; + pub const INVALID_DEVICE_LIST_PAYLOAD: &str = "invalid_device_list_payload"; + pub const USERNAME_ALREADY_EXISTS: &str = "username_already_exists"; + pub const USERNAME_RESERVED: &str = "username_reserved"; + pub const WALLET_ADDRESS_TAKEN: &str = "wallet_address_taken"; + pub const WALLET_ADDRESS_NOT_RESERVED: &str = "wallet_address_not_reserved"; + pub const USER_NOT_FOUND: &str = "user_not_found"; } // Tunnelbroker pub const TUNNELBROKER_GRPC_ENDPOINT: &str = "TUNNELBROKER_GRPC_ENDPOINT"; pub const DEFAULT_TUNNELBROKER_ENDPOINT: &str = "http://localhost:50051"; // X3DH key management // Threshold for requesting more one_time keys pub const ONE_TIME_KEY_MINIMUM_THRESHOLD: usize = 5; // Number of keys to be refreshed when below the threshold pub const ONE_TIME_KEY_REFRESH_NUMBER: u32 = 5; // Minimum supported code versions pub const MIN_SUPPORTED_NATIVE_VERSION: u64 = 270; // Request metadata pub mod request_metadata { pub const CODE_VERSION: &str = "code_version"; pub const STATE_VERSION: &str = "state_version"; pub const MAJOR_DESKTOP_VERSION: &str = "major_desktop_version"; pub const DEVICE_TYPE: &str = "device_type"; pub const USER_ID: &str = "user_id"; pub const DEVICE_ID: &str = "device_id"; pub const ACCESS_TOKEN: &str = "access_token"; } // CORS pub mod cors { use std::time::Duration; pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); pub const DEFAULT_EXPOSED_HEADERS: [&str; 3] = ["grpc-status", "grpc-message", "grpc-status-details-bin"]; pub const DEFAULT_ALLOW_HEADERS: [&str; 11] = [ "x-grpc-web", "content-type", "x-user-agent", "grpc-timeout", super::request_metadata::CODE_VERSION, super::request_metadata::STATE_VERSION, super::request_metadata::MAJOR_DESKTOP_VERSION, super::request_metadata::DEVICE_TYPE, super::request_metadata::USER_ID, super::request_metadata::DEVICE_ID, super::request_metadata::ACCESS_TOKEN, ]; pub const ALLOW_ORIGIN_LIST: &str = "ALLOW_ORIGIN_LIST"; pub const PROD_ORIGIN_HOST_STR: &str = "web.comm.app"; } // Tracing pub const COMM_SERVICES_USE_JSON_LOGS: &str = "COMM_SERVICES_USE_JSON_LOGS"; // Regex pub const VALID_USERNAME_REGEX_STRING: &str = r"^[a-zA-Z0-9][a-zA-Z0-9-_]{0,190}$"; // Retry // TODO: Replace this with `ExponentialBackoffConfig` from `comm-lib` pub mod retry { pub const MAX_ATTEMPTS: usize = 8; pub const CONDITIONAL_CHECK_FAILED: &str = "ConditionalCheckFailed"; pub const TRANSACTION_CONFLICT: &str = "TransactionConflict"; } // One-time keys pub const ONE_TIME_KEY_UPLOAD_LIMIT_PER_ACCOUNT: usize = 24; pub const ONE_TIME_KEY_SIZE: usize = 43; // as defined in olm pub const MAX_ONE_TIME_KEYS: usize = 100; // as defined in olm diff --git a/services/identity/src/device_list.rs b/services/identity/src/device_list.rs index 340b9090b..b41e7a2ef 100644 --- a/services/identity/src/device_list.rs +++ b/services/identity/src/device_list.rs @@ -1,553 +1,559 @@ use chrono::{DateTime, Duration, Utc}; use std::{collections::HashSet, str::FromStr}; use tracing::{debug, error, warn}; use crate::{ - constants::{error_types, DEVICE_LIST_TIMESTAMP_VALID_FOR}, + constants::{ + error_types, tonic_status_messages, DEVICE_LIST_TIMESTAMP_VALID_FOR, + }, database::{DeviceListRow, DeviceListUpdate}, error::DeviceListError, grpc_services::protos::auth::UpdateDeviceListRequest, }; // serde helper for serializing/deserializing // device list JSON payload #[derive(serde::Serialize, serde::Deserialize)] struct RawDeviceList { devices: Vec, timestamp: i64, } /// Signed device list payload that is serializable to JSON. /// For the DDB payload, see [`DeviceListUpdate`] #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SignedDeviceList { /// JSON-stringified [`RawDeviceList`] raw_device_list: String, /// Current primary device signature. /// NOTE: Present only when the payload is received from primary device. /// It's `None` for Identity-generated device-lists #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] cur_primary_signature: Option, /// Previous primary device signature. Present only /// if primary device has changed since last update. #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] last_primary_signature: Option, } impl SignedDeviceList { fn as_raw(&self) -> Result { // The device list payload is sent as an escaped JSON payload. // Escaped double quotes need to be trimmed before attempting to deserialize serde_json::from_str(&self.raw_device_list.replace(r#"\""#, r#"""#)) .map_err(|err| { warn!("Failed to deserialize raw device list: {}", err); - tonic::Status::invalid_argument("invalid device list payload") + tonic::Status::invalid_argument( + tonic_status_messages::INVALID_DEVICE_LIST_PAYLOAD, + ) }) } /// Serializes the signed device list to a JSON string pub fn as_json_string(&self) -> Result { serde_json::to_string(self).map_err(|err| { error!( errorType = error_types::GRPC_SERVICES_LOG, "Failed to serialize device list updates: {}", err ); tonic::Status::failed_precondition("unexpected error") }) } } impl TryFrom for SignedDeviceList { type Error = tonic::Status; fn try_from(row: DeviceListRow) -> Result { let raw_list = RawDeviceList { devices: row.device_ids, timestamp: row.timestamp.timestamp_millis(), }; let stringified_list = serde_json::to_string(&raw_list).map_err(|err| { error!( errorType = error_types::GRPC_SERVICES_LOG, "Failed to serialize raw device list: {}", err ); tonic::Status::failed_precondition("unexpected error") })?; Ok(Self { raw_device_list: stringified_list, cur_primary_signature: row.current_primary_signature, last_primary_signature: row.last_primary_signature, }) } } impl TryFrom for SignedDeviceList { type Error = tonic::Status; fn try_from(request: UpdateDeviceListRequest) -> Result { request.new_device_list.parse().map_err(|err| { warn!("Failed to deserialize device list update: {}", err); - tonic::Status::invalid_argument("invalid device list payload") + tonic::Status::invalid_argument( + tonic_status_messages::INVALID_DEVICE_LIST_PAYLOAD, + ) }) } } impl FromStr for SignedDeviceList { type Err = serde_json::Error; fn from_str(s: &str) -> Result { serde_json::from_str(s) } } impl TryFrom for DeviceListUpdate { type Error = tonic::Status; fn try_from(signed_list: SignedDeviceList) -> Result { let RawDeviceList { devices, timestamp: raw_timestamp, } = signed_list.as_raw()?; let timestamp = DateTime::from_timestamp_millis(raw_timestamp).ok_or_else(|| { error!( errorType = error_types::GRPC_SERVICES_LOG, "Failed to parse RawDeviceList timestamp!" ); tonic::Status::invalid_argument("invalid timestamp") })?; Ok(DeviceListUpdate { devices, timestamp, current_primary_signature: signed_list.cur_primary_signature, last_primary_signature: signed_list.last_primary_signature, raw_payload: signed_list.raw_device_list, }) } } /// Returns `true` if given timestamp is valid. The timestamp is considered /// valid under the following condition: /// - `new_timestamp` is greater than `previous_timestamp` (if provided) /// - `new_timestamp` is not older than [`DEVICE_LIST_TIMESTAMP_VALID_FOR`] /// /// Note: For Identity-managed device lists, the timestamp can be `None`. /// Verification is then skipped fn is_new_timestamp_valid( previous_timestamp: Option<&DateTime>, new_timestamp: Option<&DateTime>, ) -> bool { let Some(new_timestamp) = new_timestamp else { return true; }; if let Some(previous_timestamp) = previous_timestamp { if new_timestamp < previous_timestamp { return false; } } let timestamp_valid_duration = Duration::from_std(DEVICE_LIST_TIMESTAMP_VALID_FOR) .expect("FATAL - Invalid duration constant provided"); Utc::now().signed_duration_since(new_timestamp) < timestamp_valid_duration } /// Returns error if new timestamp is invalid. The timestamp is considered /// valid under the following condition: /// - `new_timestamp` is greater than `previous_timestamp` (if provided) /// - `new_timestamp` is not older than [`DEVICE_LIST_TIMESTAMP_VALID_FOR`] /// /// Note: For Identity-managed device lists, the timestamp can be `None`. /// Verification is then skipped pub fn verify_device_list_timestamp( previous_timestamp: Option<&DateTime>, new_timestamp: Option<&DateTime>, ) -> Result<(), DeviceListError> { if !is_new_timestamp_valid(previous_timestamp, new_timestamp) { return Err(DeviceListError::InvalidDeviceListUpdate); } Ok(()) } pub fn verify_device_list_signatures( previous_primary_device_id: Option<&String>, new_device_list: &DeviceListUpdate, ) -> Result<(), DeviceListError> { let Some(primary_device_id) = new_device_list.devices.first() else { return Ok(()); }; // verify current signature if let Some(signature) = &new_device_list.current_primary_signature { crate::grpc_utils::ed25519_verify( primary_device_id, &new_device_list.raw_payload, signature, ) .map_err(|err| { debug!("curPrimarySignature verification failed: {err}"); DeviceListError::InvalidSignature })?; } // verify last signature if primary device changed if let (Some(previous_primary_id), Some(last_signature)) = ( previous_primary_device_id.filter(|prev| *prev != primary_device_id), &new_device_list.last_primary_signature, ) { crate::grpc_utils::ed25519_verify( previous_primary_id, &new_device_list.raw_payload, last_signature, ) .map_err(|err| { debug!("lastPrimarySignature verification failed: {err}"); DeviceListError::InvalidSignature })?; } Ok(()) } pub fn verify_initial_device_list( device_list: &DeviceListUpdate, expected_primary_device_id: &str, ) -> Result<(), tonic::Status> { use tonic::Status; if device_list.last_primary_signature.is_some() { debug!("Received lastPrimarySignature for initial device list"); return Err(Status::invalid_argument( "invalid device list: unexpected lastPrimarySignature", )); } let Some(signature) = &device_list.current_primary_signature else { debug!("Missing curPrimarySignature for initial device list"); return Err(Status::invalid_argument( "invalid device list: signature missing", )); }; crate::grpc_utils::ed25519_verify( expected_primary_device_id, &device_list.raw_payload, signature, )?; if device_list.devices.len() != 1 { debug!("Invalid device list length"); return Err(Status::invalid_argument( "invalid device list: invalid length", )); } if device_list .devices .first() .filter(|it| **it == expected_primary_device_id) .is_none() { debug!("Invalid primary device ID for initial device list"); return Err(Status::invalid_argument( "invalid device list: invalid primary device", )); } Ok(()) } pub mod validation { use super::*; /// Returns `true` if `new_device_list` contains exactly one more new device /// compared to `previous_device_list` fn is_device_added( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { let previous_set: HashSet<_> = previous_device_list.iter().collect(); let new_set: HashSet<_> = new_device_list.iter().collect(); return new_set.difference(&previous_set).count() == 1; } /// Returns `true` if `new_device_list` contains exactly one fewer device /// compared to `previous_device_list` fn is_device_removed( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { let previous_set: HashSet<_> = previous_device_list.iter().collect(); let new_set: HashSet<_> = new_device_list.iter().collect(); return previous_set.difference(&new_set).count() == 1; } fn primary_device_changed( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { let previous_primary = previous_device_list.first(); let new_primary = new_device_list.first(); new_primary != previous_primary } /// Verifies if exactly one device has been replaced. /// No reorders are permitted. Both lists have to have the same length. fn is_device_replaced( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { if previous_device_list.len() != new_device_list.len() { return false; } // exactly 1 different device ID std::iter::zip(previous_device_list, new_device_list) .filter(|(a, b)| a != b) .count() == 1 } // This is going to be used when doing primary devicd keys rotation #[allow(unused)] pub fn primary_device_rotation_validator( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { primary_device_changed(previous_device_list, new_device_list) && !is_device_replaced(&previous_device_list[1..], &new_device_list[1..]) } /// The `UpdateDeviceList` RPC should be able to either add or remove /// one device, and it cannot currently switch primary devices. /// The RPC is also able to replace a keyserver device pub fn update_device_list_rpc_validator( previous_device_list: &[&str], new_device_list: &[&str], ) -> bool { if primary_device_changed(previous_device_list, new_device_list) { return false; } // allow replacing a keyserver if is_device_replaced(previous_device_list, new_device_list) { return true; } let is_added = is_device_added(previous_device_list, new_device_list); let is_removed = is_device_removed(previous_device_list, new_device_list); is_added != is_removed } #[cfg(test)] mod tests { use super::*; #[test] fn test_device_added_or_removed() { use std::ops::Not; let list1 = vec!["device1"]; let list2 = vec!["device1", "device2"]; assert!(is_device_added(&list1, &list2)); assert!(is_device_removed(&list1, &list2).not()); assert!(is_device_added(&list2, &list1).not()); assert!(is_device_removed(&list2, &list1)); assert!(is_device_added(&list1, &list1).not()); assert!(is_device_removed(&list1, &list1).not()); } #[test] fn test_primary_device_changed() { use std::ops::Not; let list1 = vec!["device1"]; let list2 = vec!["device1", "device2"]; let list3 = vec!["device2"]; assert!(primary_device_changed(&list1, &list2).not()); assert!(primary_device_changed(&list1, &list3)); } #[test] fn test_device_replaced() { use std::ops::Not; let list1 = vec!["device1"]; let list2 = vec!["device2"]; let list3 = vec!["device1", "device2"]; let list4 = vec!["device2", "device1"]; let list5 = vec!["device2", "device3"]; assert!(is_device_replaced(&list1, &list2), "Singleton replacement"); assert!(is_device_replaced(&list4, &list5), "Standard replacement"); assert!(is_device_replaced(&list1, &list3).not(), "Length unequal"); assert!(is_device_replaced(&list3, &list3).not(), "Unchanged"); assert!(is_device_replaced(&list3, &list4).not(), "Reorder"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn deserialize_device_list_signature() { let payload_with_signature = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}","curPrimarySignature":"foo"}"#; let payload_without_signatures = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":222222222}"}"#; let list_with_signature: SignedDeviceList = serde_json::from_str(payload_with_signature).unwrap(); let list_without_signatures: SignedDeviceList = serde_json::from_str(payload_without_signatures).unwrap(); assert_eq!( list_with_signature.cur_primary_signature, Some("foo".to_string()) ); assert!(list_with_signature.last_primary_signature.is_none()); assert!(list_without_signatures.cur_primary_signature.is_none()); assert!(list_without_signatures.last_primary_signature.is_none()); } #[test] fn serialize_device_list_signatures() { let raw_list = r#"{"devices":["device1"],"timestamp":111111111}"#; let expected_payload_without_signatures = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}"}"#; let device_list_without_signature = SignedDeviceList { raw_device_list: raw_list.to_string(), cur_primary_signature: None, last_primary_signature: None, }; assert_eq!( device_list_without_signature.as_json_string().unwrap(), expected_payload_without_signatures ); let expected_payload_with_signature = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}","curPrimarySignature":"foo"}"#; let device_list_with_cur_signature = SignedDeviceList { raw_device_list: raw_list.to_string(), cur_primary_signature: Some("foo".to_string()), last_primary_signature: None, }; assert_eq!( device_list_with_cur_signature.as_json_string().unwrap(), expected_payload_with_signature ); } #[test] fn serialize_device_list_updates() { let raw_updates = vec![ create_device_list_row(RawDeviceList { devices: vec!["device1".into()], timestamp: 111111111, }), create_device_list_row(RawDeviceList { devices: vec!["device1".into(), "device2".into()], timestamp: 222222222, }), ]; let expected_raw_list1 = r#"{"devices":["device1"],"timestamp":111111111}"#; let expected_raw_list2 = r#"{"devices":["device1","device2"],"timestamp":222222222}"#; let signed_updates = raw_updates .into_iter() .map(SignedDeviceList::try_from) .collect::, _>>() .expect("signing device list updates failed"); assert_eq!(signed_updates[0].raw_device_list, expected_raw_list1); assert_eq!(signed_updates[1].raw_device_list, expected_raw_list2); let stringified_updates = signed_updates .iter() .map(serde_json::to_string) .collect::, _>>() .expect("serialize signed device lists failed"); let expected_stringified_list1 = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}"}"#; let expected_stringified_list2 = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":222222222}"}"#; assert_eq!(stringified_updates[0], expected_stringified_list1); assert_eq!(stringified_updates[1], expected_stringified_list2); } #[test] fn deserialize_device_list_update() { let raw_payload = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":123456789}"}"#; let request = UpdateDeviceListRequest { new_device_list: raw_payload.to_string(), }; let signed_list = SignedDeviceList::try_from(request) .expect("Failed to parse SignedDeviceList"); let update = DeviceListUpdate::try_from(signed_list) .expect("Failed to parse DeviceListUpdate from signed list"); let expected_timestamp = DateTime::from_timestamp_millis(123456789).unwrap(); assert_eq!(update.timestamp, expected_timestamp); assert_eq!( update.devices, vec!["device1".to_string(), "device2".to_string()] ); } #[test] fn test_timestamp_validation() { let valid_timestamp = Utc::now() - Duration::milliseconds(100); let previous_timestamp = Utc::now() - Duration::seconds(10); let too_old_timestamp = previous_timestamp - Duration::seconds(1); let expired_timestamp = Utc::now() - Duration::minutes(20); assert!( verify_device_list_timestamp( Some(&previous_timestamp), Some(&valid_timestamp) ) .is_ok(), "Valid timestamp should pass verification" ); assert!( verify_device_list_timestamp( Some(&previous_timestamp), Some(&too_old_timestamp) ) .is_err(), "Timestamp older than previous, should fail verification" ); assert!( verify_device_list_timestamp(None, Some(&expired_timestamp)).is_err(), "Expired timestamp should fail verification" ); assert!( verify_device_list_timestamp(None, None).is_ok(), "No provided timestamp should pass" ); } /// helper for mocking DB rows from raw device list payloads fn create_device_list_row(raw_list: RawDeviceList) -> DeviceListRow { DeviceListRow { user_id: "".to_string(), device_ids: raw_list.devices, timestamp: DateTime::from_timestamp_millis(raw_list.timestamp).unwrap(), current_primary_signature: None, last_primary_signature: None, } } } diff --git a/services/identity/src/grpc_services/authenticated.rs b/services/identity/src/grpc_services/authenticated.rs index 0b5fe50cc..6e40d7145 100644 --- a/services/identity/src/grpc_services/authenticated.rs +++ b/services/identity/src/grpc_services/authenticated.rs @@ -1,834 +1,844 @@ use std::collections::HashMap; use crate::config::CONFIG; use crate::database::{DeviceListUpdate, PlatformDetails}; use crate::device_list::SignedDeviceList; use crate::error::consume_error; use crate::{ client_service::{handle_db_error, UpdateState, WorkflowInProgress}, - constants::{error_types, request_metadata}, + constants::{error_types, request_metadata, tonic_status_messages}, database::DatabaseClient, grpc_services::shared::{get_platform_metadata, get_value}, }; use chrono::DateTime; use comm_opaque2::grpc::protocol_error_to_grpc_status; use tonic::{Request, Response, Status}; use tracing::{debug, error, trace, warn}; use super::protos::auth::{ identity_client_service_server::IdentityClientService, DeletePasswordUserFinishRequest, DeletePasswordUserStartRequest, DeletePasswordUserStartResponse, GetDeviceListRequest, GetDeviceListResponse, InboundKeyInfo, InboundKeysForUserRequest, InboundKeysForUserResponse, KeyserverKeysResponse, LinkFarcasterAccountRequest, OutboundKeyInfo, OutboundKeysForUserRequest, OutboundKeysForUserResponse, PeersDeviceListsRequest, PeersDeviceListsResponse, RefreshUserPrekeysRequest, UpdateDeviceListRequest, UpdateUserPasswordFinishRequest, UpdateUserPasswordStartRequest, UpdateUserPasswordStartResponse, UploadOneTimeKeysRequest, UserDevicesPlatformDetails, UserIdentitiesRequest, UserIdentitiesResponse, }; use super::protos::unauth::Empty; #[derive(derive_more::Constructor)] pub struct AuthenticatedService { db_client: DatabaseClient, } fn get_auth_info(req: &Request<()>) -> Option<(String, String, String)> { trace!("Retrieving auth info for request: {:?}", req); let user_id = get_value(req, request_metadata::USER_ID)?; let device_id = get_value(req, request_metadata::DEVICE_ID)?; let access_token = get_value(req, request_metadata::ACCESS_TOKEN)?; Some((user_id, device_id, access_token)) } pub fn auth_interceptor( req: Request<()>, db_client: &DatabaseClient, ) -> Result, Status> { trace!("Intercepting request to check auth info: {:?}", req); let (user_id, device_id, access_token) = get_auth_info(&req) .ok_or_else(|| Status::unauthenticated("Missing credentials"))?; let handle = tokio::runtime::Handle::current(); let new_db_client = db_client.clone(); // This function cannot be `async`, yet must call the async db call // Force tokio to resolve future in current thread without an explicit .await let valid_token = tokio::task::block_in_place(move || { handle .block_on(new_db_client.verify_access_token( user_id, device_id, access_token, )) .map_err(handle_db_error) })?; if !valid_token { return Err(Status::aborted("Bad Credentials")); } Ok(req) } pub fn get_user_and_device_id( request: &Request, ) -> Result<(String, String), Status> { let user_id = get_value(request, request_metadata::USER_ID) .ok_or_else(|| Status::unauthenticated("Missing user_id field"))?; let device_id = get_value(request, request_metadata::DEVICE_ID) .ok_or_else(|| Status::unauthenticated("Missing device_id field"))?; Ok((user_id, device_id)) } #[tonic::async_trait] impl IdentityClientService for AuthenticatedService { #[tracing::instrument(skip_all)] async fn refresh_user_prekeys( &self, request: Request, ) -> Result, Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Refreshing prekeys for user: {}", user_id); let content_keys = message .new_content_prekeys .ok_or_else(|| Status::invalid_argument("Missing content keys"))?; let notif_keys = message .new_notif_prekeys .ok_or_else(|| Status::invalid_argument("Missing notification keys"))?; self .db_client .update_device_prekeys( user_id, device_id, content_keys.into(), notif_keys.into(), ) .await .map_err(handle_db_error)?; let response = Response::new(Empty {}); Ok(response) } #[tracing::instrument(skip_all)] async fn get_outbound_keys_for_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let user_id = &message.user_id; let devices_map = self .db_client .get_keys_for_user(user_id, true) .await .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + .ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; let transformed_devices = devices_map .into_iter() .map(|(key, device_info)| (key, OutboundKeyInfo::from(device_info))) .collect::>(); Ok(tonic::Response::new(OutboundKeysForUserResponse { devices: transformed_devices, })) } #[tracing::instrument(skip_all)] async fn get_inbound_keys_for_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let user_id = &message.user_id; let devices_map = self .db_client .get_keys_for_user(user_id, false) .await .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + .ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; let transformed_devices = devices_map .into_iter() .map(|(key, device_info)| (key, InboundKeyInfo::from(device_info))) .collect::>(); let identifier = self .db_client .get_user_identity(user_id) .await .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + .ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; Ok(tonic::Response::new(InboundKeysForUserResponse { devices: transformed_devices, identity: Some(identifier.into()), })) } #[tracing::instrument(skip_all)] async fn get_keyserver_keys( &self, request: Request, ) -> Result, Status> { let message = request.into_inner(); let identifier = self .db_client .get_user_identity(&message.user_id) .await .map_err(handle_db_error)? - .ok_or_else(|| tonic::Status::not_found("user not found"))?; + .ok_or_else(|| { + tonic::Status::not_found(tonic_status_messages::USER_NOT_FOUND) + })?; let Some(keyserver_info) = self .db_client .get_keyserver_keys_for_user(&message.user_id) .await .map_err(handle_db_error)? else { return Err(Status::not_found("keyserver not found")); }; let primary_device_data = self .db_client .get_primary_device_data(&message.user_id) .await .map_err(handle_db_error)?; let primary_device_keys = primary_device_data.device_key_info; let response = Response::new(KeyserverKeysResponse { keyserver_info: Some(keyserver_info.into()), identity: Some(identifier.into()), primary_device_identity_info: Some(primary_device_keys.into()), }); return Ok(response); } #[tracing::instrument(skip_all)] async fn upload_one_time_keys( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to update one time keys for user: {}", user_id); self .db_client .append_one_time_prekeys( &user_id, &device_id, &message.content_one_time_prekeys, &message.notif_one_time_prekeys, ) .await .map_err(handle_db_error)?; Ok(tonic::Response::new(Empty {})) } #[tracing::instrument(skip_all)] async fn update_user_password_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); let server_registration = comm_opaque2::server::Registration::new(); let server_message = server_registration .start( &CONFIG.server_setup, &message.opaque_registration_request, user_id.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let update_state = UpdateState { user_id }; let session_id = self .db_client .insert_workflow(WorkflowInProgress::Update(update_state)) .await .map_err(handle_db_error)?; let response = UpdateUserPasswordStartResponse { session_id, opaque_registration_response: server_message, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn update_user_password_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let Some(WorkflowInProgress::Update(state)) = self .db_client .get_workflow(message.session_id) .await .map_err(handle_db_error)? else { return Err(tonic::Status::not_found("session not found")); }; let server_registration = comm_opaque2::server::Registration::new(); let password_file = server_registration .finish(&message.opaque_registration_upload) .map_err(protocol_error_to_grpc_status)?; self .db_client .update_user_password(state.user_id, password_file) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn log_out_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; self .db_client .remove_device(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_otks_table_rows_for_user_device(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_access_token_data(&user_id, device_id) .await .map_err(handle_db_error)?; let device_list = self .db_client .get_current_device_list(&user_id) .await .map_err(|err| { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "Failed fetching device list: {err}" ); handle_db_error(err) })?; let Some(device_list) = device_list else { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "User has no device list!" ); return Err(Status::failed_precondition("no device list")); }; tokio::spawn(async move { debug!( "Sending device list updates to {:?}", device_list.device_ids ); let device_ids: Vec<&str> = device_list.device_ids.iter().map(AsRef::as_ref).collect(); let result = crate::tunnelbroker::send_device_list_update(&device_ids).await; consume_error(result); }); let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn log_out_secondary_device( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; debug!( "Secondary device logout request for user_id={}, device_id={}", user_id, device_id ); self .verify_device_on_device_list( &user_id, &device_id, DeviceListItemKind::Secondary, ) .await?; self .db_client .delete_access_token_data(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_otks_table_rows_for_user_device(&user_id, &device_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn delete_wallet_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; debug!("Attempting to delete wallet user: {}", user_id); let maybe_username_and_password_file = self .db_client .get_username_and_password_file(&user_id) .await .map_err(handle_db_error)?; if maybe_username_and_password_file.is_some() { return Err(tonic::Status::permission_denied("password user")); } self .db_client .delete_user(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn delete_password_user_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to start deleting password user: {}", user_id); let maybe_username_and_password_file = self .db_client .get_username_and_password_file(&user_id) .await .map_err(handle_db_error)?; let Some((username, password_file_bytes)) = maybe_username_and_password_file else { - return Err(tonic::Status::not_found("user not found")); + return Err(tonic::Status::not_found( + tonic_status_messages::USER_NOT_FOUND, + )); }; let mut server_login = comm_opaque2::server::Login::new(); let server_response = server_login .start( &CONFIG.server_setup, &password_file_bytes, &message.opaque_login_request, username.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let delete_state = construct_delete_password_user_info(server_login); let session_id = self .db_client .insert_workflow(WorkflowInProgress::PasswordUserDeletion(Box::new( delete_state, ))) .await .map_err(handle_db_error)?; let response = Response::new(DeletePasswordUserStartResponse { session_id, opaque_login_response: server_response, }); Ok(response) } #[tracing::instrument(skip_all)] async fn delete_password_user_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to finish deleting password user: {}", user_id); let Some(WorkflowInProgress::PasswordUserDeletion(state)) = self .db_client .get_workflow(message.session_id) .await .map_err(handle_db_error)? else { return Err(tonic::Status::not_found("session not found")); }; let mut server_login = state.opaque_server_login; server_login .finish(&message.opaque_login_upload) .map_err(protocol_error_to_grpc_status)?; self .db_client .delete_user(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn get_device_list_for_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let GetDeviceListRequest { user_id, since_timestamp, } = request.into_inner(); let since = since_timestamp .map(|timestamp| { DateTime::from_timestamp_millis(timestamp) .ok_or_else(|| tonic::Status::invalid_argument("Invalid timestamp")) }) .transpose()?; let mut db_result = self .db_client .get_device_list_history(user_id, since) .await .map_err(handle_db_error)?; // these should be sorted already, but just in case db_result.sort_by_key(|list| list.timestamp); let device_list_updates: Vec = db_result .into_iter() .map(SignedDeviceList::try_from) .collect::, _>>()?; let stringified_updates = device_list_updates .iter() .map(SignedDeviceList::as_json_string) .collect::, _>>()?; Ok(Response::new(GetDeviceListResponse { device_list_updates: stringified_updates, })) } #[tracing::instrument(skip_all)] async fn get_device_lists_for_users( &self, request: tonic::Request, ) -> Result, tonic::Status> { let PeersDeviceListsRequest { user_ids } = request.into_inner(); use crate::database::{DeviceListRow, DeviceRow}; async fn get_current_device_list_and_data( db_client: DatabaseClient, user_id: &str, ) -> Result<(Option, Vec), crate::error::Error> { let (list_result, data_result) = tokio::join!( db_client.get_current_device_list(user_id), db_client.get_current_devices(user_id) ); Ok((list_result?, data_result?)) } // do all fetches concurrently let mut fetch_tasks = tokio::task::JoinSet::new(); let mut device_lists = HashMap::with_capacity(user_ids.len()); let mut users_devices_platform_details = HashMap::with_capacity(user_ids.len()); for user_id in user_ids { let db_client = self.db_client.clone(); fetch_tasks.spawn(async move { let result = get_current_device_list_and_data(db_client, &user_id).await; (user_id, result) }); } while let Some(task_result) = fetch_tasks.join_next().await { match task_result { Ok((user_id, Ok((device_list, devices_data)))) => { let Some(device_list_row) = device_list else { warn!(user_id, "User has no device list, skipping!"); continue; }; let signed_list = SignedDeviceList::try_from(device_list_row)?; let serialized_list = signed_list.as_json_string()?; device_lists.insert(user_id.clone(), serialized_list); let devices_platform_details = devices_data .into_iter() .map(|data| (data.device_id, data.platform_details.into())) .collect(); users_devices_platform_details.insert( user_id, UserDevicesPlatformDetails { devices_platform_details, }, ); } Ok((user_id, Err(err))) => { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "Failed fetching device list: {err}" ); // abort fetching other users fetch_tasks.abort_all(); return Err(handle_db_error(err)); } Err(join_error) => { error!( errorType = error_types::GRPC_SERVICES_LOG, "Failed to join device list task: {join_error}" ); fetch_tasks.abort_all(); return Err(Status::aborted("unexpected error")); } } } let response = PeersDeviceListsResponse { users_device_lists: device_lists, users_devices_platform_details, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn update_device_list( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _device_id) = get_user_and_device_id(&request)?; // TODO: when we stop doing "primary device rotation" (migration procedure) // we should verify if this RPC is called by primary device only let new_list = SignedDeviceList::try_from(request.into_inner())?; let update = DeviceListUpdate::try_from(new_list)?; self .db_client .apply_devicelist_update( &user_id, update, crate::device_list::validation::update_device_list_rpc_validator, ) .await .map_err(handle_db_error)?; Ok(Response::new(Empty {})) } #[tracing::instrument(skip_all)] async fn link_farcaster_account( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); let mut get_farcaster_users_response = self .db_client .get_farcaster_users(vec![message.farcaster_id.clone()]) .await .map_err(handle_db_error)?; if get_farcaster_users_response.len() > 1 { error!( errorType = error_types::GRPC_SERVICES_LOG, "multiple users associated with the same Farcaster ID" ); return Err(Status::failed_precondition("cannot link Farcaster ID")); } if let Some(u) = get_farcaster_users_response.pop() { if u.0.user_id == user_id { return Ok(Response::new(Empty {})); } else { return Err(Status::already_exists( "farcaster ID already associated with different user", )); } } self .db_client .add_farcaster_id(user_id, message.farcaster_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn unlink_farcaster_account( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; self .db_client .remove_farcaster_id(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn find_user_identities( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let results = self .db_client .find_db_user_identities(message.user_ids) .await .map_err(handle_db_error)?; let mapped_results = results .into_iter() .map(|(user_id, identifier)| (user_id, identifier.into())) .collect(); let response = UserIdentitiesResponse { identities: mapped_results, }; return Ok(Response::new(response)); } #[tracing::instrument(skip_all)] async fn sync_platform_details( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; let platform_metadata = get_platform_metadata(&request)?; let platform_details = PlatformDetails::new(platform_metadata, None) .map_err(|_| Status::invalid_argument("Invalid platform metadata"))?; self .db_client .update_device_platform_details(user_id, device_id, platform_details) .await .map_err(handle_db_error)?; Ok(Response::new(Empty {})) } } #[allow(dead_code)] enum DeviceListItemKind { Any, Primary, Secondary, } impl AuthenticatedService { async fn verify_device_on_device_list( &self, user_id: &String, device_id: &String, device_kind: DeviceListItemKind, ) -> Result<(), tonic::Status> { let device_list = self .db_client .get_current_device_list(user_id) .await .map_err(|err| { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "Failed fetching device list: {err}" ); handle_db_error(err) })?; let Some(device_list) = device_list else { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "User has no device list!" ); return Err(Status::failed_precondition("no device list")); }; use DeviceListItemKind as DeviceKind; let device_on_list = match device_kind { DeviceKind::Any => device_list.has_device(device_id), DeviceKind::Primary => device_list.is_primary_device(device_id), DeviceKind::Secondary => device_list.has_secondary_device(device_id), }; if !device_on_list { debug!( "Device {} not on device list for user {}", device_id, user_id ); return Err(Status::permission_denied("device not on device list")); } Ok(()) } } #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct DeletePasswordUserInfo { pub opaque_server_login: comm_opaque2::server::Login, } fn construct_delete_password_user_info( opaque_server_login: comm_opaque2::server::Login, ) -> DeletePasswordUserInfo { DeletePasswordUserInfo { opaque_server_login, } } diff --git a/services/identity/src/grpc_utils.rs b/services/identity/src/grpc_utils.rs index d6f41949d..940c05d51 100644 --- a/services/identity/src/grpc_utils.rs +++ b/services/identity/src/grpc_utils.rs @@ -1,380 +1,390 @@ use base64::{engine::general_purpose, Engine as _}; use ed25519_dalek::{PublicKey, Signature, Verifier}; use serde::Deserialize; use tonic::Status; use tracing::warn; use crate::{ constants::tonic_status_messages, database::{DeviceListUpdate, DeviceRow, KeyPayload}, ddb_utils::{DBIdentity, Identifier as DBIdentifier}, device_list::SignedDeviceList, grpc_services::protos::{ auth::{EthereumIdentity, Identity, InboundKeyInfo, OutboundKeyInfo}, unauth::{ DeviceKeyUpload, ExistingDeviceLoginRequest, OpaqueLoginStartRequest, RegistrationStartRequest, ReservedRegistrationStartRequest, ReservedWalletRegistrationRequest, SecondaryDeviceKeysUploadRequest, WalletAuthRequest, }, }, }; #[derive(Deserialize)] pub struct SignedNonce { nonce: String, signature: String, } impl TryFrom<&SecondaryDeviceKeysUploadRequest> for SignedNonce { type Error = Status; fn try_from( value: &SecondaryDeviceKeysUploadRequest, ) -> Result { Ok(Self { nonce: value.nonce.to_string(), signature: value.nonce_signature.to_string(), }) } } impl TryFrom<&ExistingDeviceLoginRequest> for SignedNonce { type Error = Status; fn try_from(value: &ExistingDeviceLoginRequest) -> Result { Ok(Self { nonce: value.nonce.to_string(), signature: value.nonce_signature.to_string(), }) } } impl SignedNonce { pub fn verify_and_get_nonce( self, signing_public_key: &str, ) -> Result { ed25519_verify(signing_public_key, &self.nonce, &self.signature)?; Ok(self.nonce) } } /// Verifies ed25519-signed message. Returns Ok if the signature is valid. /// Public key and signature should be base64-encoded strings. pub fn ed25519_verify( signing_public_key: &str, message: &str, signature: &str, ) -> Result<(), Status> { let signature_bytes = general_purpose::STANDARD_NO_PAD .decode(signature) - .map_err(|_| Status::invalid_argument("signature invalid"))?; + .map_err(|_| { + Status::invalid_argument(tonic_status_messages::SIGNATURE_INVALID) + })?; - let signature = Signature::from_bytes(&signature_bytes) - .map_err(|_| Status::invalid_argument("signature invalid"))?; + let signature = Signature::from_bytes(&signature_bytes).map_err(|_| { + Status::invalid_argument(tonic_status_messages::SIGNATURE_INVALID) + })?; let public_key_bytes = general_purpose::STANDARD_NO_PAD .decode(signing_public_key) - .map_err(|_| Status::failed_precondition("malformed key"))?; + .map_err(|_| { + Status::failed_precondition(tonic_status_messages::MALFORMED_KEY) + })?; let public_key: PublicKey = PublicKey::from_bytes(&public_key_bytes) - .map_err(|_| Status::failed_precondition("malformed key"))?; + .map_err(|_| { + Status::failed_precondition(tonic_status_messages::MALFORMED_KEY) + })?; public_key .verify(message.as_bytes(), &signature) - .map_err(|_| Status::permission_denied("verification failed"))?; + .map_err(|_| { + Status::permission_denied(tonic_status_messages::VERIFICATION_FAILED) + })?; Ok(()) } pub struct DeviceKeysInfo { pub device_info: DeviceRow, pub content_one_time_key: Option, pub notif_one_time_key: Option, } impl From for DeviceKeysInfo { fn from(device_info: DeviceRow) -> Self { Self { device_info, content_one_time_key: None, notif_one_time_key: None, } } } impl From for InboundKeyInfo { fn from(info: DeviceKeysInfo) -> Self { let DeviceKeysInfo { device_info, .. } = info; InboundKeyInfo { identity_info: Some(device_info.device_key_info.into()), content_prekey: Some(device_info.content_prekey.into()), notif_prekey: Some(device_info.notif_prekey.into()), } } } impl From for OutboundKeyInfo { fn from(info: DeviceKeysInfo) -> Self { let DeviceKeysInfo { device_info, content_one_time_key, notif_one_time_key, } = info; OutboundKeyInfo { identity_info: Some(device_info.device_key_info.into()), content_prekey: Some(device_info.content_prekey.into()), notif_prekey: Some(device_info.notif_prekey.into()), one_time_content_prekey: content_one_time_key, one_time_notif_prekey: notif_one_time_key, } } } pub trait DeviceKeyUploadData { fn device_key_upload(&self) -> Option<&DeviceKeyUpload>; } impl DeviceKeyUploadData for RegistrationStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for ReservedRegistrationStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for OpaqueLoginStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for WalletAuthRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for ReservedWalletRegistrationRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for SecondaryDeviceKeysUploadRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } pub trait DeviceKeyUploadActions { fn payload(&self) -> Result; fn payload_signature(&self) -> Result; fn content_prekey(&self) -> Result; fn content_prekey_signature(&self) -> Result; fn notif_prekey(&self) -> Result; fn notif_prekey_signature(&self) -> Result; fn one_time_content_prekeys(&self) -> Result, Status>; fn one_time_notif_prekeys(&self) -> Result, Status>; fn device_type(&self) -> Result; } impl DeviceKeyUploadActions for T { fn payload(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.device_key_info.as_ref()) .map(|info| info.payload.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn payload_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.device_key_info.as_ref()) .map(|info| info.payload_signature.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn content_prekey(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.content_upload.as_ref()) .map(|prekey| prekey.prekey.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn content_prekey_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.content_upload.as_ref()) .map(|prekey| prekey.prekey_signature.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn notif_prekey(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.notif_upload.as_ref()) .map(|prekey| prekey.prekey.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn notif_prekey_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.notif_upload.as_ref()) .map(|prekey| prekey.prekey_signature.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn one_time_content_prekeys(&self) -> Result, Status> { self .device_key_upload() .map(|upload| upload.one_time_content_prekeys.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn one_time_notif_prekeys(&self) -> Result, Status> { self .device_key_upload() .map(|upload| upload.one_time_notif_prekeys.clone()) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } fn device_type(&self) -> Result { self .device_key_upload() .map(|upload| upload.device_type) .ok_or_else(|| { Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) }) } } /// Common functionality for registration request messages trait RegistrationData { fn initial_device_list(&self) -> &str; } impl RegistrationData for RegistrationStartRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for ReservedRegistrationStartRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for WalletAuthRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for ReservedWalletRegistrationRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } /// Similar to `[DeviceKeyUploadActions]` but only for registration requests pub trait RegistrationActions { fn get_and_verify_initial_device_list( &self, ) -> Result, tonic::Status>; } impl RegistrationActions for T { fn get_and_verify_initial_device_list( &self, ) -> Result, tonic::Status> { let payload = self.initial_device_list(); if payload.is_empty() { return Ok(None); } let signed_list: SignedDeviceList = payload.parse().map_err(|err| { warn!("Failed to deserialize initial device list: {}", err); - tonic::Status::invalid_argument("invalid device list payload") + tonic::Status::invalid_argument( + tonic_status_messages::INVALID_DEVICE_LIST_PAYLOAD, + ) })?; - let key_info = self - .payload()? - .parse::() - .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?; + let key_info = self.payload()?.parse::().map_err(|_| { + tonic::Status::invalid_argument(tonic_status_messages::MALFORMED_PAYLOAD) + })?; let primary_device_id = key_info.primary_identity_public_keys.ed25519; let update_payload = DeviceListUpdate::try_from(signed_list.clone())?; crate::device_list::verify_initial_device_list( &update_payload, &primary_device_id, )?; Ok(Some(signed_list)) } } impl From for Identity { fn from(value: DBIdentity) -> Self { match value.identifier { DBIdentifier::Username(username) => Identity { username, eth_identity: None, farcaster_id: value.farcaster_id, }, DBIdentifier::WalletAddress(eth_identity) => Identity { username: eth_identity.wallet_address.clone(), eth_identity: Some(EthereumIdentity { wallet_address: eth_identity.wallet_address, siwe_message: eth_identity.social_proof.message, siwe_signature: eth_identity.social_proof.signature, }), farcaster_id: value.farcaster_id, }, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_challenge_response_verification() { let expected_nonce = "hello"; let signing_key = "jnBariweGMSdfmJYvuObTu4IGT1fpaJTo/ovbkU0SAY"; let request = SecondaryDeviceKeysUploadRequest { nonce: expected_nonce.to_string(), nonce_signature: "LWlgCDND3bmgIS8liW/0eKJvuNs4Vcb4iMf43zD038/MnC0cSAYl2l3bO9dFc0fa2w6/2ABsUlPDMVr+isE0Aw".to_string(), user_id: "foo".to_string(), device_key_upload: None, }; let challenge_response = SignedNonce::try_from(&request) .expect("failed to parse challenge response"); let retrieved_nonce = challenge_response .verify_and_get_nonce(signing_key) .expect("verification failed"); assert_eq!(retrieved_nonce, expected_nonce); } } diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js index 82c2e0f51..808acc69f 100644 --- a/web/account/siwe-login-form.react.js +++ b/web/account/siwe-login-form.react.js @@ -1,328 +1,328 @@ // @flow import '@rainbow-me/rainbowkit/styles.css'; import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useAccount, useWalletClient } from 'wagmi'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { getSIWENonce, getSIWENonceActionTypes, legacySiweAuth, legacySiweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import { identityGenerateNonceActionTypes, useIdentityGenerateNonce, } from 'lib/actions/user-actions.js'; import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import stores from 'lib/facts/stores.js'; import { useWalletLogIn } from 'lib/hooks/login-hooks.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { legacyLogInExtraInfoSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LegacyLogInStartingPayload, LegacyLogInExtraInfo, } from 'lib/types/account-types.js'; import { SIWEMessageTypes } from 'lib/types/siwe-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { createSIWEMessage, getSIWEStatementForPublicKey, siweMessageSigningExplanationStatements, } from 'lib/utils/siwe-utils.js'; import HeaderSeparator from './header-separator.react.js'; import css from './siwe.css'; import Button from '../components/button.react.js'; import OrBreak from '../components/or-break.react.js'; import { olmAPI } from '../crypto/olm-api.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { getVersionUnsupportedError } from '../utils/version-utils.js'; type SIWELogInError = 'account_does_not_exist' | 'client_version_unsupported'; type SIWELoginFormProps = { +cancelSIWEAuthFlow: () => void, }; const legacyGetSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const identityGenerateNonceLoadingStatusSelector = createLoadingStatusSelector( identityGenerateNonceActionTypes, ); const legacySiweAuthLoadingStatusSelector = createLoadingStatusSelector( legacySiweAuthActionTypes, ); function SIWELoginForm(props: SIWELoginFormProps): React.Node { const { address } = useAccount(); const { data: signer } = useWalletClient(); const dispatchActionPromise = useDispatchActionPromise(); const legacyGetSIWENonceCall = useLegacyAshoatKeyserverCall(getSIWENonce); const legacyGetSIWENonceCallLoadingStatus = useSelector( legacyGetSIWENonceLoadingStatusSelector, ); const identityGenerateNonce = useIdentityGenerateNonce(); const identityGenerateNonceLoadingStatus = useSelector( identityGenerateNonceLoadingStatusSelector, ); const siweAuthLoadingStatus = useSelector( legacySiweAuthLoadingStatusSelector, ); const legacySiweAuthCall = useLegacyAshoatKeyserverCall(legacySiweAuth); const legacyLogInExtraInfo = useSelector(legacyLogInExtraInfoSelector); const walletLogIn = useWalletLogIn(); const [siweNonce, setSIWENonce] = React.useState(null); const siweNonceShouldBeFetched = !siweNonce && legacyGetSIWENonceCallLoadingStatus !== 'loading' && identityGenerateNonceLoadingStatus !== 'loading'; React.useEffect(() => { if (!siweNonceShouldBeFetched) { return; } if (usingCommServicesAccessToken) { void dispatchActionPromise( identityGenerateNonceActionTypes, (async () => { const response = await identityGenerateNonce(); setSIWENonce(response); })(), ); } else { void dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await legacyGetSIWENonceCall(); setSIWENonce(response); })(), ); } }, [ dispatchActionPromise, identityGenerateNonce, legacyGetSIWENonceCall, siweNonceShouldBeFetched, ]); const callLegacySIWEAuthEndpoint = React.useCallback( async ( message: string, signature: string, extraInfo: LegacyLogInExtraInfo, ) => { await olmAPI.initializeCryptoAccount(); const userPublicKey = await olmAPI.getUserPublicKey(); try { return await legacySiweAuthCall({ message, signature, signedIdentityKeysBlob: { payload: userPublicKey.blobPayload, signature: userPublicKey.signature, }, doNotRegister: true, ...extraInfo, }); } catch (e) { const messageForException = getMessageForException(e); if (messageForException === 'account_does_not_exist') { setError('account_does_not_exist'); } else if (messageForException === 'client_version_unsupported') { setError('client_version_unsupported'); } throw e; } }, [legacySiweAuthCall], ); const attemptLegacySIWEAuth = React.useCallback( (message: string, signature: string) => { return dispatchActionPromise( legacySiweAuthActionTypes, callLegacySIWEAuthEndpoint(message, signature, legacyLogInExtraInfo), undefined, ({ calendarQuery: legacyLogInExtraInfo.calendarQuery, }: LegacyLogInStartingPayload), ); }, [callLegacySIWEAuthEndpoint, dispatchActionPromise, legacyLogInExtraInfo], ); const attemptWalletLogIn = React.useCallback( async ( walletAddress: string, siweMessage: string, siweSignature: string, ) => { try { return await walletLogIn(walletAddress, siweMessage, siweSignature); } catch (e) { const messageForException = getMessageForException(e); - if (messageForException === 'user not found') { + if (messageForException === 'user_not_found') { setError('account_does_not_exist'); } else if ( messageForException === 'client_version_unsupported' || messageForException === 'Unsupported version' ) { setError('client_version_unsupported'); } throw e; } }, [walletLogIn], ); const dispatch = useDispatch(); const onSignInButtonClick = React.useCallback(async () => { invariant(signer, 'signer must be present during SIWE attempt'); invariant(siweNonce, 'nonce must be present during SIWE attempt'); await olmAPI.initializeCryptoAccount(); const { primaryIdentityPublicKeys: { ed25519 }, } = await olmAPI.getUserPublicKey(); const statement = getSIWEStatementForPublicKey( ed25519, SIWEMessageTypes.MSG_AUTH, ); const message = createSIWEMessage(address, statement, siweNonce); const signature = await signer.signMessage({ message }); if (usingCommServicesAccessToken) { await attemptWalletLogIn(address, message, signature); } else { await attemptLegacySIWEAuth(message, signature); dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); } }, [ address, attemptLegacySIWEAuth, attemptWalletLogIn, signer, siweNonce, dispatch, ]); const { cancelSIWEAuthFlow } = props; const backButtonColor = React.useMemo( () => ({ backgroundColor: '#211E2D' }), [], ); const signInButtonColor = React.useMemo( () => ({ backgroundColor: '#6A20E3' }), [], ); const [error, setError] = React.useState(); const mainMiddleAreaClassName = classNames({ [css.mainMiddleArea]: true, [css.hidden]: !!error, }); const errorOverlayClassNames = classNames({ [css.errorOverlay]: true, [css.hidden]: !error, }); if (siweAuthLoadingStatus === 'loading' || !siweNonce) { return (
); } let errorText; if (error === 'account_does_not_exist') { errorText = ( <>

No Comm account found for that Ethereum wallet!

We require that users register on their mobile devices. Comm relies on a primary device capable of scanning QR codes in order to authorize secondary devices.

You can install our iOS app  here , or our Android app  here .

); } else if (error === 'client_version_unsupported') { errorText =

{getVersionUnsupportedError()}

; } return (

Sign in with Ethereum

Wallet Connected

{siweMessageSigningExplanationStatements}

By signing up, you agree to our{' '} Terms of Use &{' '} Privacy Policy.

{errorText}
); } export default SIWELoginForm; diff --git a/web/account/traditional-login-form.react.js b/web/account/traditional-login-form.react.js index 6484e4fa3..2bb738454 100644 --- a/web/account/traditional-login-form.react.js +++ b/web/account/traditional-login-form.react.js @@ -1,267 +1,267 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useLegacyLogIn, legacyLogInActionTypes, } from 'lib/actions/user-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { usePasswordLogIn } from 'lib/hooks/login-hooks.js'; import { legacyLogInExtraInfoSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils.js'; import type { LegacyLogInExtraInfo, LegacyLogInStartingPayload, } from 'lib/types/account-types.js'; import { logInActionSources } from 'lib/types/account-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import HeaderSeparator from './header-separator.react.js'; import css from './log-in-form.css'; import PasswordInput from './password-input.react.js'; import Button from '../components/button.react.js'; import { olmAPI } from '../crypto/olm-api.js'; import LoadingIndicator from '../loading-indicator.react.js'; import Input from '../modals/input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { getShortVersionUnsupportedError } from '../utils/version-utils.js'; const loadingStatusSelector = createLoadingStatusSelector( legacyLogInActionTypes, ); function TraditionalLoginForm(): React.Node { const legacyAuthInProgress = useSelector(loadingStatusSelector) === 'loading'; const [identityAuthInProgress, setIdentityAuthInProgress] = React.useState(false); const inputDisabled = legacyAuthInProgress || identityAuthInProgress; const legacyLoginExtraInfo = useSelector(legacyLogInExtraInfoSelector); const callLegacyLogIn = useLegacyLogIn(); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const usernameInputRef = React.useRef(); React.useEffect(() => { usernameInputRef.current?.focus(); }, []); const [username, setUsername] = React.useState(''); const onUsernameChange = React.useCallback( (e: SyntheticEvent) => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setUsername(e.target.value); }, [], ); const onUsernameBlur = React.useCallback(() => { setUsername(untrimmedUsername => untrimmedUsername.trim()); }, []); const [password, setPassword] = React.useState(''); const onPasswordChange = React.useCallback( (e: SyntheticEvent) => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setPassword(e.target.value); }, [], ); const [errorMessage, setErrorMessage] = React.useState(''); const legacyLogInAction = React.useCallback( async (extraInfo: LegacyLogInExtraInfo) => { await olmAPI.initializeCryptoAccount(); const userPublicKey = await olmAPI.getUserPublicKey(); try { const result = await callLegacyLogIn({ ...extraInfo, username, password, authActionSource: logInActionSources.logInFromWebForm, signedIdentityKeysBlob: { payload: userPublicKey.blobPayload, signature: userPublicKey.signature, }, }); modalContext.popModal(); return result; } catch (e) { setUsername(''); setPassword(''); const messageForException = getMessageForException(e); if (messageForException === 'invalid_credentials') { setErrorMessage('incorrect username or password'); } else if (messageForException === 'client_version_unsupported') { setErrorMessage(getShortVersionUnsupportedError()); } else { setErrorMessage('unknown error'); } usernameInputRef.current?.focus(); throw e; } }, [callLegacyLogIn, modalContext, password, username], ); const callIdentityPasswordLogIn = usePasswordLogIn(); const identityPasswordLogInAction = React.useCallback(async () => { if (identityAuthInProgress) { return; } setIdentityAuthInProgress(true); try { await callIdentityPasswordLogIn(username, password); modalContext.popModal(); } catch (e) { setUsername(''); setPassword(''); const messageForException = getMessageForException(e); if ( - messageForException === 'user not found' || + messageForException === 'user_not_found' || messageForException === 'login failed' ) { setErrorMessage('incorrect username or password'); } else if ( messageForException === 'client_version_unsupported' || messageForException === 'Unsupported version' ) { setErrorMessage(getShortVersionUnsupportedError()); } else { setErrorMessage('unknown error'); } usernameInputRef.current?.focus(); throw e; } finally { setIdentityAuthInProgress(false); } }, [ identityAuthInProgress, callIdentityPasswordLogIn, modalContext, password, username, ]); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (username.search(validEmailRegex) > -1) { setUsername(''); setErrorMessage('usernames only, not emails'); usernameInputRef.current?.focus(); return; } else if (username.search(oldValidUsernameRegex) === -1) { setUsername(''); setErrorMessage('alphanumeric usernames only'); usernameInputRef.current?.focus(); return; } else if (password === '') { setErrorMessage('password is empty'); usernameInputRef.current?.focus(); return; } if (usingCommServicesAccessToken) { void identityPasswordLogInAction(); } else { void dispatchActionPromise( legacyLogInActionTypes, legacyLogInAction(legacyLoginExtraInfo), undefined, ({ calendarQuery: legacyLoginExtraInfo.calendarQuery, }: LegacyLogInStartingPayload), ); } }, [ dispatchActionPromise, identityPasswordLogInAction, legacyLogInAction, legacyLoginExtraInfo, username, password, ], ); const loadingIndicatorClassName = inputDisabled ? css.loadingIndicator : css.hiddenLoadingIndicator; const buttonTextClassName = inputDisabled ? css.invisibleButtonText : undefined; const loginButtonContent = React.useMemo( () => ( <>
Sign in
), [loadingIndicatorClassName, buttonTextClassName], ); const signInButtonColor = React.useMemo( () => ({ backgroundColor: '#6A20E3' }), [], ); return (

Sign in to Comm

Username
Password
{errorMessage}
); } export default TraditionalLoginForm;