diff --git a/lib/types/siwe-types.js b/lib/types/siwe-types.js index 7dae8f300..c8b05c7dd 100644 --- a/lib/types/siwe-types.js +++ b/lib/types/siwe-types.js @@ -1,164 +1,164 @@ // @flow import type { LegacyLogInExtraInfo } from './account-types.js'; import type { SignedIdentityKeysBlob } from './crypto-types.js'; import { type DeviceTokenUpdateRequest, type PlatformDetails, } from './device-types.js'; import { type CalendarQuery } from './entry-types.js'; export type SIWENonceResponse = { +nonce: string, }; export type SIWEAuthRequest = { +message: string, +signature: string, +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, +initialNotificationsEncryptedMessage?: string, +doNotRegister?: boolean, }; export type LegacySIWEAuthServerCall = { +message: string, +signature: string, +doNotRegister?: boolean, ...LegacyLogInExtraInfo, }; export type SIWESocialProof = { +siweMessage: string, +siweMessageSignature: string, }; // This is a message that the rendered webpage (landing/siwe.react.js) uses to // communicate back to the React Native WebView that is rendering it // (native/account/siwe-panel.react.js) export type SIWEWebViewMessage = | { +type: 'siwe_success', +address: string, +message: string, +signature: string, } | { +type: 'siwe_closed', } | { +type: 'walletconnect_modal_update', +state: 'open', +height: number, } | { +type: 'walletconnect_modal_update', +state: 'closed', }; export type SIWEMessage = { // RFC 4501 dns authority that is requesting the signing. +domain: string, // Ethereum address performing the signing conformant to capitalization // encoded checksum specified in EIP-55 where applicable. +address: string, // Human-readable ASCII assertion that the user will sign, and it must not // contain `\n`. +statement?: string, // RFC 3986 URI referring to the resource that is the subject of the signing // (as in the __subject__ of a claim). +uri: string, // Current version of the message. +version: string, // EIP-155 Chain ID to which the session is bound, and the network where // Contract Accounts must be resolved. +chainId: number, // Randomized token used to prevent replay attacks, at least 8 alphanumeric // characters. +nonce: string, // ISO 8601 datetime string of the current time. +issuedAt: string, // ISO 8601 datetime string that, if present, indicates when the signed // authentication message is no longer valid. +expirationTime?: string, // ISO 8601 datetime string that, if present, indicates when the signed // authentication message will become valid. +notBefore?: string, // System-specific identifier that may be used to uniquely refer to the // sign-in request. +requestId?: string, // List of information or references to information the user wishes to have // resolved as part of authentication by the relying party. They are // expressed as RFC 3986 URIs separated by `\n- `. +resources?: $ReadOnlyArray, // @deprecated // Signature of the message signed by the wallet. // // This field will be removed in future releases, an additional parameter // was added to the validate function were the signature goes to validate // the message. +signature?: string, // @deprecated // Type of sign message to be generated. // // This field will be removed in future releases and will rely on the // message version. +type?: 'Personal signature', +verify: ({ +signature: string, ... }) => Promise, +toMessage: () => string, }; export type SIWEResult = { +address: string, +message: string, +signature: string, - +nonceTimestamp: number, + +nonceTimestamp: ?number, }; export type IdentityWalletRegisterInput = { +address: string, +message: string, +signature: string, +fid?: ?string, }; export const SIWEMessageTypes = Object.freeze({ MSG_AUTH: 'msg_auth', MSG_BACKUP: 'msg_backup', MSG_BACKUP_RESTORE: 'msg_backup_restore', }); export type SIWEMessageType = $Values; export type SIWESignatureRequestData = | { +messageType: SIWEMessageType } | { +messageType: 'msg_backup_restore', +siweNonce: string, +siweStatement: string, +siweIssuedAt: string, }; export type SIWEBackupSecrets = { +message: string, +signature: string, }; export const legacyKeyserverSIWENonceLifetime = 30 * 60 * 1000; // 30 minutes export const identitySIWENonceLifetime = 2 * 60 * 1000; // 2 minutes diff --git a/native/account/fullscreen-siwe-panel.react.js b/native/account/fullscreen-siwe-panel.react.js index ddb583445..7147d7491 100644 --- a/native/account/fullscreen-siwe-panel.react.js +++ b/native/account/fullscreen-siwe-panel.react.js @@ -1,226 +1,228 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, View } from 'react-native'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { useWalletLogIn } from 'lib/hooks/login-hooks.js'; import { type SIWEResult, SIWEMessageTypes } from 'lib/types/siwe-types.js'; import { ServerError, getMessageForException } from 'lib/utils/errors.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { useGetEthereumAccountFromSIWEResult } from './registration/ethereum-utils.js'; import { RegistrationContext } from './registration/registration-context.js'; import { enableNewRegistrationMode } from './registration/registration-types.js'; import { useLegacySIWEServerCall } from './siwe-hooks.js'; import SIWEPanel from './siwe-panel.react.js'; import { commRustModule } from '../native-modules.js'; import { AccountDoesNotExistRouteName, RegistrationRouteName, } from '../navigation/route-names.js'; import { unknownErrorAlertDetails, appOutOfDateAlertDetails, } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; +const siweSignatureRequestData = { messageType: SIWEMessageTypes.MSG_AUTH }; + type Props = { +goBackToPrompt: () => mixed, +closing: boolean, }; function FullscreenSIWEPanel(props: Props): React.Node { const [loading, setLoading] = React.useState(true); const activity = loading ? : null; const activityContainer = React.useMemo( () => ({ flex: 1, }), [], ); const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { setSkipEthereumLoginOnce, register: registrationServerCall } = registrationContext; const getEthereumAccountFromSIWEResult = useGetEthereumAccountFromSIWEResult(); const { navigate } = useNavigation(); const { goBackToPrompt } = props; const onAccountDoesNotExist = React.useCallback( async (result: SIWEResult) => { await getEthereumAccountFromSIWEResult(result); setSkipEthereumLoginOnce(true); goBackToPrompt(); navigate<'Registration'>(RegistrationRouteName, { screen: AccountDoesNotExistRouteName, }); }, [ getEthereumAccountFromSIWEResult, navigate, goBackToPrompt, setSkipEthereumLoginOnce, ], ); const onNonceExpired = React.useCallback( (registrationOrLogin: 'registration' | 'login') => { Alert.alert( registrationOrLogin === 'registration' ? 'Registration attempt timed out' : 'Login attempt timed out', 'Please try again', [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); }, [goBackToPrompt], ); const legacySiweServerCall = useLegacySIWEServerCall(); const walletLogIn = useWalletLogIn(); const successRef = React.useRef(false); const dispatch = useDispatch(); const onSuccess = React.useCallback( async (result: SIWEResult) => { successRef.current = true; if (usingCommServicesAccessToken) { try { const findUserIDResponseString = await commRustModule.findUserIDForWalletAddress(result.address); const findUserIDResponse = JSON.parse(findUserIDResponseString); if (findUserIDResponse.userID || findUserIDResponse.isReserved) { try { await walletLogIn( result.address, result.message, result.signature, ); } catch (e) { const messageForException = getMessageForException(e); if (messageForException === 'nonce expired') { onNonceExpired('login'); } else if ( messageForException === 'Unsupported version' || messageForException === 'client_version_unsupported' ) { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); } else { throw e; } } } else if (enableNewRegistrationMode) { await onAccountDoesNotExist(result); } else { try { await registrationServerCall({ farcasterID: null, accountSelection: { accountType: 'ethereum', ...result, avatarURI: null, }, avatarData: null, clearCachedSelections: () => {}, onNonceExpired: () => onNonceExpired('registration'), onAlertAcknowledged: goBackToPrompt, }); } catch { // We swallow exceptions here because registrationServerCall // already handles showing Alerts, and we don't want to show two } } } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); } } else { try { await legacySiweServerCall({ ...result, doNotRegister: enableNewRegistrationMode, }); } catch (e) { if ( e instanceof ServerError && e.message === 'account_does_not_exist' ) { await onAccountDoesNotExist(result); } else if ( e instanceof ServerError && e.message === 'client_version_unsupported' ) { Alert.alert( appOutOfDateAlertDetails.title, appOutOfDateAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); } else { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); } return; } dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); } }, [ walletLogIn, registrationServerCall, goBackToPrompt, dispatch, legacySiweServerCall, onAccountDoesNotExist, onNonceExpired, ], ); const ifBeforeSuccessGoBackToPrompt = React.useCallback(() => { if (!successRef.current) { goBackToPrompt(); } }, [goBackToPrompt]); const { closing } = props; return ( <> {activity} ); } export default FullscreenSIWEPanel; diff --git a/native/account/registration/connect-ethereum.react.js b/native/account/registration/connect-ethereum.react.js index 0e0a645b2..d3e433a22 100644 --- a/native/account/registration/connect-ethereum.react.js +++ b/native/account/registration/connect-ethereum.react.js @@ -1,335 +1,345 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { exactSearchUser, exactSearchUserActionTypes, } from 'lib/actions/user-actions.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { type SIWEResult, SIWEMessageTypes } from 'lib/types/siwe-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { useGetEthereumAccountFromSIWEResult, siweNonceExpired, } from './ethereum-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { commRustModule } from '../../native-modules.js'; import { type NavigationRoute, ExistingEthereumAccountRouteName, UsernameSelectionRouteName, AvatarSelectionRouteName, } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import { defaultURLPrefix } from '../../utils/url-utils.js'; import EthereumLogoDark from '../../vectors/ethereum-logo-dark.react.js'; import { useSIWEPanelState } from '../siwe-hooks.js'; import SIWEPanel from '../siwe-panel.react.js'; const exactSearchUserLoadingStatusSelector = createLoadingStatusSelector( exactSearchUserActionTypes, ); +const siweSignatureRequestData = { + messageType: SIWEMessageTypes.MSG_AUTH, +}; + export type ConnectEthereumParams = { +userSelections: { +coolOrNerdMode?: ?CoolOrNerdMode, +keyserverURL?: ?string, +farcasterID: ?string, }, }; type Props = { +navigation: RegistrationNavigationProp<'ConnectEthereum'>, +route: NavigationRoute<'ConnectEthereum'>, }; function ConnectEthereum(props: Props): React.Node { const { params } = props.route; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const userSelections = params?.userSelections; const isNerdMode = userSelections?.coolOrNerdMode === 'nerd'; const styles = useStyles(unboundStyles); let body; if (!isNerdMode) { body = ( Connecting your Ethereum wallet allows you to use your ENS name and avatar in the app. You’ll also be able to log in with your wallet instead of a password. ); } else { body = ( <> Connecting your Ethereum wallet has three benefits: {'1. '} Your peers will be able to cryptographically verify that your Comm account is associated with your Ethereum wallet. {'2. '} You’ll be able to use your ENS name and avatar in the app. {'3. '} You can choose to skip setting a password, and to log in with your Ethereum wallet instead. ); } const { navigate } = props.navigation; const onSkip = React.useCallback(() => { navigate<'UsernameSelection'>({ name: UsernameSelectionRouteName, params: { userSelections, }, }); }, [navigate, userSelections]); const keyserverURL = userSelections?.keyserverURL ?? defaultURLPrefix; const serverCallParamOverride = React.useMemo( () => ({ urlPrefix: keyserverURL, }), [keyserverURL], ); const exactSearchUserCall = useLegacyAshoatKeyserverCall( exactSearchUser, serverCallParamOverride, ); const dispatchActionPromise = useDispatchActionPromise(); const getEthereumAccountFromSIWEResult = useGetEthereumAccountFromSIWEResult(); const onSuccessfulWalletSignature = React.useCallback( async (result: SIWEResult) => { let userAlreadyExists; if (usingCommServicesAccessToken) { const findUserIDResponseString = await commRustModule.findUserIDForWalletAddress(result.address); const findUserIDResponse = JSON.parse(findUserIDResponseString); userAlreadyExists = !!findUserIDResponse.userID || findUserIDResponse.isReserved; } else { const searchPromise = exactSearchUserCall(result.address); void dispatchActionPromise(exactSearchUserActionTypes, searchPromise); const { userInfo } = await searchPromise; userAlreadyExists = !!userInfo; } if (userAlreadyExists) { navigate<'ExistingEthereumAccount'>({ name: ExistingEthereumAccountRouteName, params: result, }); return; } const ethereumAccount = await getEthereumAccountFromSIWEResult(result); const newUserSelections = { ...userSelections, accountSelection: ethereumAccount, }; navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections, }, }); }, [ userSelections, exactSearchUserCall, dispatchActionPromise, navigate, getEthereumAccountFromSIWEResult, ], ); const { panelState, onPanelClosed, onPanelClosing, siwePanelSetLoading, openPanel, } = useSIWEPanelState(); let siwePanel; if (panelState !== 'closed') { siwePanel = ( ); } const { ethereumAccount } = cachedSelections; + invariant( + !ethereumAccount || ethereumAccount.nonceTimestamp, + 'nonceTimestamp must be set after connecting to Ethereum account', + ); const nonceExpired = - ethereumAccount && siweNonceExpired(ethereumAccount.nonceTimestamp); + ethereumAccount && + ethereumAccount.nonceTimestamp && + siweNonceExpired(ethereumAccount.nonceTimestamp); const alreadyHasConnected = !!ethereumAccount && !nonceExpired; React.useEffect(() => { if (nonceExpired) { setCachedSelections(oldUserSelections => ({ ...oldUserSelections, ethereumAccount: undefined, })); } }, [nonceExpired, setCachedSelections]); const exactSearchUserCallLoading = useSelector( state => exactSearchUserLoadingStatusSelector(state) === 'loading', ); const defaultConnectButtonVariant = alreadyHasConnected ? 'outline' : 'enabled'; const connectButtonVariant = exactSearchUserCallLoading || panelState === 'opening' ? 'loading' : defaultConnectButtonVariant; const connectButtonText = alreadyHasConnected ? 'Connect new Ethereum wallet' : 'Connect Ethereum wallet'; const onUseAlreadyConnectedWallet = React.useCallback(() => { invariant( ethereumAccount, 'ethereumAccount should be set in onUseAlreadyConnectedWallet', ); const newUserSelections = { ...userSelections, accountSelection: ethereumAccount, }; navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections, }, }); }, [ethereumAccount, userSelections, navigate]); let alreadyConnectedButton; if (alreadyHasConnected) { alreadyConnectedButton = ( ); } return ( <> Do you want to connect an Ethereum wallet? {body} {alreadyConnectedButton} {siwePanel} ); } const unboundStyles = { scrollViewContentContainer: { flexGrow: 1, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, ethereumLogoContainer: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', }, list: { paddingBottom: 16, }, listItem: { flexDirection: 'row', }, listItemNumber: { fontFamily: 'Arial', fontWeight: 'bold', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, listItemContent: { fontFamily: 'Arial', flexShrink: 1, fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, }; export default ConnectEthereum; diff --git a/native/account/registration/connect-farcaster.react.js b/native/account/registration/connect-farcaster.react.js index ac4ecda52..34c4341d9 100644 --- a/native/account/registration/connect-farcaster.react.js +++ b/native/account/registration/connect-farcaster.react.js @@ -1,254 +1,259 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import { siweNonceExpired } from './ethereum-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import FarcasterPrompt from '../../components/farcaster-prompt.react.js'; import FarcasterWebView from '../../components/farcaster-web-view.react.js'; import type { FarcasterWebViewState } from '../../components/farcaster-web-view.react.js'; import { type NavigationRoute, ConnectEthereumRouteName, AvatarSelectionRouteName, } from '../../navigation/route-names.js'; import { getFarcasterAccountAlreadyLinkedAlertDetails, type AlertDetails, } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; import { useStaffCanSee } from '../../utils/staff-utils.js'; export type ConnectFarcasterParams = ?{ +userSelections?: { +coolOrNerdMode?: CoolOrNerdMode, +keyserverURL?: string, }, }; type Props = { +navigation: RegistrationNavigationProp<'ConnectFarcaster'>, +route: NavigationRoute<'ConnectFarcaster'>, }; function ConnectFarcaster(prop: Props): React.Node { const { navigation, route } = prop; const { navigate } = navigation; const userSelections = route.params?.userSelections; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections, skipEthereumLoginOnce, setSkipEthereumLoginOnce, } = registrationContext; const [webViewState, setWebViewState] = React.useState('closed'); const { ethereumAccount } = cachedSelections; const goToNextStep = React.useCallback( (fid?: ?string) => { setWebViewState('closed'); - + invariant( + !ethereumAccount || ethereumAccount.nonceTimestamp, + 'nonceTimestamp must be set after connecting to Ethereum account', + ); const nonceExpired = - ethereumAccount && siweNonceExpired(ethereumAccount.nonceTimestamp); + ethereumAccount && + ethereumAccount.nonceTimestamp && + siweNonceExpired(ethereumAccount.nonceTimestamp); if (nonceExpired) { setCachedSelections(oldUserSelections => ({ ...oldUserSelections, ethereumAccount: undefined, })); } if (!skipEthereumLoginOnce || !ethereumAccount || nonceExpired) { navigate<'ConnectEthereum'>({ name: ConnectEthereumRouteName, params: { userSelections: { ...userSelections, farcasterID: fid, }, }, }); return; } const newUserSelections = { ...userSelections, farcasterID: fid, accountSelection: ethereumAccount, }; setSkipEthereumLoginOnce(false); navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections }, }); }, [ navigate, skipEthereumLoginOnce, setSkipEthereumLoginOnce, ethereumAccount, userSelections, setCachedSelections, ], ); const onSkip = React.useCallback(() => goToNextStep(), [goToNextStep]); const identityServiceClient = React.useContext(IdentityClientContext); const getFarcasterUsers = identityServiceClient?.identityClient.getFarcasterUsers; invariant(getFarcasterUsers, 'Could not get getFarcasterUsers'); const [queuedAlert, setQueuedAlert] = React.useState(); const onSuccess = React.useCallback( async (fid: string) => { try { const commFCUsers = await getFarcasterUsers([fid]); if (commFCUsers.length > 0 && commFCUsers[0].farcasterID === fid) { const commUsername = commFCUsers[0].username; const alert = getFarcasterAccountAlreadyLinkedAlertDetails(commUsername); setQueuedAlert(alert); setWebViewState('closed'); } else { goToNextStep(fid); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, farcasterID: fid, })); } } catch (e) { setQueuedAlert({ title: 'Failed to query Comm', message: 'We failed to query Comm to see if that Farcaster account is ' + 'already linked', }); setWebViewState('closed'); } }, [goToNextStep, setCachedSelections, getFarcasterUsers], ); const isAppForegrounded = useIsAppForegrounded(); React.useEffect(() => { if (!queuedAlert || !isAppForegrounded) { return; } Alert.alert(queuedAlert.title, queuedAlert.message); setQueuedAlert(null); }, [queuedAlert, isAppForegrounded]); const { farcasterID } = cachedSelections; const alreadyHasConnected = !!farcasterID; const onPressConnectFarcaster = React.useCallback(() => { setWebViewState('opening'); }, []); const defaultConnectButtonVariant = alreadyHasConnected ? 'outline' : 'enabled'; const connectButtonVariant = webViewState === 'opening' ? 'loading' : defaultConnectButtonVariant; const connectButtonText = alreadyHasConnected ? 'Connect new Farcaster account' : 'Connect Farcaster account'; const onUseAlreadyConnectedAccount = React.useCallback(() => { invariant( farcasterID, 'farcasterID should be set in onUseAlreadyConnectedAccount', ); goToNextStep(farcasterID); }, [farcasterID, goToNextStep]); const alreadyConnectedButton = React.useMemo(() => { if (!alreadyHasConnected) { return null; } return ( ); }, [alreadyHasConnected, onUseAlreadyConnectedAccount]); const staffCanSee = useStaffCanSee(); const skipButton = React.useMemo(() => { if (!staffCanSee) { return undefined; } return ( ); }, [staffCanSee, onSkip]); const farcasterPromptTextType = staffCanSee ? 'optional' : 'required'; const connectFarcaster = React.useMemo( () => ( {alreadyConnectedButton} {skipButton} ), [ alreadyConnectedButton, connectButtonText, connectButtonVariant, onPressConnectFarcaster, onSuccess, webViewState, farcasterPromptTextType, skipButton, ], ); return connectFarcaster; } const styles = { scrollViewContentContainer: { flexGrow: 1, }, }; export default ConnectFarcaster; diff --git a/native/account/registration/siwe-backup-message-creation.react.js b/native/account/registration/siwe-backup-message-creation.react.js index 8c563e19d..ed0f50d1a 100644 --- a/native/account/registration/siwe-backup-message-creation.react.js +++ b/native/account/registration/siwe-backup-message-creation.react.js @@ -1,240 +1,244 @@ // @flow import Icon from '@expo/vector-icons/MaterialIcons.js'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import { type SIWEResult, SIWEMessageTypes } from 'lib/types/siwe-types.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import { type RegistrationNavigationProp } from './registration-navigator.react.js'; import type { CoolOrNerdMode, AccountSelection, AvatarData, } from './registration-types.js'; import { type NavigationRoute, RegistrationTermsRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; import { useSIWEPanelState } from '../siwe-hooks.js'; import SIWEPanel from '../siwe-panel.react.js'; +const siweBackupSignatureRequestData = { + messageType: SIWEMessageTypes.MSG_BACKUP, +}; + type CreateSIWEBackupMessageBaseProps = { +onSuccessfulWalletSignature: (result: SIWEResult) => void, +onExistingWalletSignature?: () => void, +onSkip?: () => void, }; const CreateSIWEBackupMessageBase: React.ComponentType = React.memo( function CreateSIWEBackupMessageBase( props: CreateSIWEBackupMessageBaseProps, ): React.Node { const { onSuccessfulWalletSignature, onExistingWalletSignature, onSkip } = props; const styles = useStyles(unboundStyles); const { panelState, onPanelClosed, onPanelClosing, openPanel, siwePanelSetLoading, } = useSIWEPanelState(); let siwePanel; if (panelState !== 'closed') { siwePanel = ( ); } const newSignatureButtonText = onExistingWalletSignature ? 'Encrypt with new signature' : 'Encrypt with Ethereum signature'; const newSignatureButtonVariant = onExistingWalletSignature ? 'outline' : 'enabled'; let useExistingSignatureButton; if (onExistingWalletSignature) { useExistingSignatureButton = ( ); } let onSkipButton; if (onSkip) { onSkipButton = ( ); } return ( <> Encrypting your Comm backup To make sure we can’t see your data, Comm encrypts your backup using a signature from your wallet. This signature is private and never leaves your device, unlike the prior signature, which is public. This signature ensures that you can always recover your data as long as you still control your wallet. {useExistingSignatureButton} {onSkipButton} {siwePanel} ); }, ); export type CreateSIWEBackupMessageParams = { +userSelections: { +coolOrNerdMode?: ?CoolOrNerdMode, +keyserverURL?: ?string, +farcasterID: ?string, +accountSelection: AccountSelection, +avatarData: ?AvatarData, }, }; type Props = { +navigation: RegistrationNavigationProp<'CreateSIWEBackupMessage'>, +route: NavigationRoute<'CreateSIWEBackupMessage'>, }; function CreateSIWEBackupMessage(props: Props): React.Node { const { navigate } = props.navigation; const { params } = props.route; const { userSelections } = params; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const onSuccessfulWalletSignature = React.useCallback( (result: SIWEResult) => { const selectedEthereumAddress = userSelections.accountSelection.address; const { message, signature, address } = result; if (address !== selectedEthereumAddress) { Alert.alert( 'Mismatched Ethereum address', 'You picked a different wallet than the one you use to sign in.', ); return; } const newUserSelections = { ...userSelections, siweBackupSecrets: { message, signature }, }; setCachedSelections(oldUserSelections => ({ ...oldUserSelections, siweBackupSecrets: { message, signature }, })); navigate<'RegistrationTerms'>({ name: RegistrationTermsRouteName, params: { userSelections: newUserSelections }, }); }, [navigate, setCachedSelections, userSelections], ); const { siweBackupSecrets } = cachedSelections; const onExistingWalletSignature = React.useCallback(() => { const registrationTermsParams = { userSelections: { ...userSelections, siweBackupSecrets, }, }; navigate<'RegistrationTerms'>({ name: RegistrationTermsRouteName, params: registrationTermsParams, }); }, [navigate, siweBackupSecrets, userSelections]); if (siweBackupSecrets) { return ( ); } return ( ); } const unboundStyles = { scrollViewContentContainer: { flexGrow: 1, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, siweBackupIcon: { color: 'panelForegroundIcon', }, siweBackupIconContainer: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', }, }; export { CreateSIWEBackupMessageBase, CreateSIWEBackupMessage }; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index e7146338b..ca6d503a2 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,298 +1,318 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; -import invariant from 'invariant'; import * as React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, legacySiweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import { identityGenerateNonceActionTypes, useIdentityGenerateNonce, } from 'lib/actions/user-actions.js'; import type { ServerCallSelectorParams } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { SIWEWebViewMessage, SIWEResult, - SIWEMessageType, + SIWESignatureRequestData, } from 'lib/types/siwe-types.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; +import { getPublicKeyFromSIWEStatement } from 'lib/utils/siwe-utils.js'; import { useKeyboardHeight } from '../keyboard/keyboard-hooks.js'; import { useSelector } from '../redux/redux-utils.js'; import type { BottomSheetRef } from '../types/bottom-sheet.js'; import type { WebViewMessageEvent } from '../types/web-view-types.js'; import { unknownErrorAlertDetails } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; import { defaultLandingURLPrefix } from '../utils/url-utils.js'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const identityGenerateNonceLoadingStatusSelector = createLoadingStatusSelector( identityGenerateNonceActionTypes, ); const legacySiweAuthLoadingStatusSelector = createLoadingStatusSelector( legacySiweAuthActionTypes, ); -type NonceInfo = { - +nonce: string, - +nonceTimestamp: number, -}; +type NonceInfo = + | { +nonceType: 'local', +nonce: string, +issuedAt: string } + | { +nonceType: 'remote', +nonce: string, +nonceTimestamp: number }; type Props = { +onClosed: () => mixed, +onClosing: () => mixed, +onSuccessfulWalletSignature: SIWEResult => mixed, - +siweMessageType: SIWEMessageType, + +siweSignatureRequestData: SIWESignatureRequestData, +closing: boolean, +setLoading: boolean => mixed, +keyserverCallParamOverride?: Partial, }; function SIWEPanel(props: Props): React.Node { const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useLegacyAshoatKeyserverCall( getSIWENonce, props.keyserverCallParamOverride, ); const identityGenerateNonce = useIdentityGenerateNonce(); const legacyGetSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const identityGenerateNonceFailed = useSelector( state => identityGenerateNonceLoadingStatusSelector(state) === 'error', ); const { onClosing } = props; - const { siweMessageType } = props; + const { messageType, siweNonce, siweStatement, siweIssuedAt } = + props.siweSignatureRequestData; const legacySiweAuthCallLoading = useSelector( state => legacySiweAuthLoadingStatusSelector(state) === 'loading', ); const [nonceInfo, setNonceInfo] = React.useState(null); const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = React.useState(null); // This is set if we either succeed or fail, at which point we expect // to be unmounted/remounted by our parent component prior to a retry const nonceNotNeededRef = React.useRef(false); React.useEffect(() => { + if (siweNonce && siweStatement && siweIssuedAt) { + setNonceInfo({ + nonce: siweNonce, + issuedAt: siweIssuedAt, + nonceType: 'local', + }); + const siwePrimaryIdentityPublicKey = + getPublicKeyFromSIWEStatement(siweStatement); + setPrimaryIdentityPublicKey(siwePrimaryIdentityPublicKey); + nonceNotNeededRef.current = true; + return; + } if (nonceNotNeededRef.current) { return; } const generateNonce = async (nonceFunction: () => Promise) => { try { const response = await nonceFunction(); - setNonceInfo({ nonce: response, nonceTimestamp: Date.now() }); + setNonceInfo({ + nonce: response, + nonceTimestamp: Date.now(), + nonceType: 'remote', + }); } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [ { text: 'OK', onPress: () => { nonceNotNeededRef.current = true; onClosing(); }, }, ], { cancelable: false }, ); throw e; } }; void (async () => { if (usingCommServicesAccessToken) { void dispatchActionPromise( identityGenerateNonceActionTypes, generateNonce(identityGenerateNonce), ); } else { void dispatchActionPromise( getSIWENonceActionTypes, generateNonce(getSIWENonceCall), ); } const ed25519 = await getContentSigningKey(); setPrimaryIdentityPublicKey(ed25519); })(); }, [ dispatchActionPromise, getSIWENonceCall, identityGenerateNonce, onClosing, + siweNonce, + siweStatement, + siweIssuedAt, ]); const [isLoading, setLoading] = React.useState(true); const [walletConnectModalHeight, setWalletConnectModalHeight] = React.useState(0); const insets = useSafeAreaInsets(); const keyboardHeight = useKeyboardHeight(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (walletConnectModalHeight) { const baseHeight = bottomInset + walletConnectModalHeight + keyboardHeight; if (baseHeight < 400) { return [baseHeight - 10]; } else { return [baseHeight + 5]; } } else { const baseHeight = bottomInset + keyboardHeight; return [baseHeight + 435, baseHeight + 600]; } }, [isLoading, walletConnectModalHeight, bottomInset, keyboardHeight]); const bottomSheetRef = React.useRef(); const snapToIndex = bottomSheetRef.current?.snapToIndex; React.useEffect(() => { // When the snapPoints change, always reset to the first one // Without this, when we close the WalletConnect modal we don't resize snapToIndex?.(0); }, [snapToIndex, snapPoints]); const closeBottomSheet = bottomSheetRef.current?.close; const { closing, onSuccessfulWalletSignature } = props; const nonceTimestamp = nonceInfo?.nonceTimestamp; const handleMessage = React.useCallback( async (event: WebViewMessageEvent) => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, message, signature } = data; if (address && signature) { nonceNotNeededRef.current = true; closeBottomSheet?.(); - invariant(nonceTimestamp, 'nonceTimestamp should be set'); await onSuccessfulWalletSignature({ address, message, signature, nonceTimestamp, }); } } else if (data.type === 'siwe_closed') { nonceNotNeededRef.current = true; onClosing(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { const height = data.state === 'open' ? data.height : 0; if (!walletConnectModalHeight || height > 0) { setWalletConnectModalHeight(height); } } }, [ onSuccessfulWalletSignature, onClosing, closeBottomSheet, walletConnectModalHeight, nonceTimestamp, ], ); const prevClosingRef = React.useRef(); React.useEffect(() => { if (closing && !prevClosingRef.current) { nonceNotNeededRef.current = true; closeBottomSheet?.(); } prevClosingRef.current = closing; }, [closing, closeBottomSheet]); const nonce = nonceInfo?.nonce; + const issuedAt = nonceInfo?.issuedAt; const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, - 'siwe-message-type': siweMessageType, + 'siwe-message-type': messageType, + 'siwe-message-issued-at': issuedAt, }, }), - [nonce, primaryIdentityPublicKey, siweMessageType], + [nonce, primaryIdentityPublicKey, messageType, issuedAt], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const walletConnectModalOpen = walletConnectModalHeight !== 0; const backgroundStyle = React.useMemo( () => ({ backgroundColor: walletConnectModalOpen ? '#3396ff' : '#242529', }), [walletConnectModalOpen], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const { onClosed } = props; const onBottomSheetChange = React.useCallback( (index: number) => { if (index === -1) { onClosed(); } }, [onClosed], ); let bottomSheet; if (nonce && primaryIdentityPublicKey) { bottomSheet = ( ); } const setLoadingProp = props.setLoading; const loading = !legacyGetSIWENonceCallFailed && !identityGenerateNonceFailed && (isLoading || legacySiweAuthCallLoading); React.useEffect(() => { setLoadingProp(loading); }, [setLoadingProp, loading]); return bottomSheet; } export default SIWEPanel;