diff --git a/keyserver/src/responders/landing-handler.js b/keyserver/src/responders/landing-handler.js index 01819a43c..71b806d9c 100644 --- a/keyserver/src/responders/landing-handler.js +++ b/keyserver/src/responders/landing-handler.js @@ -1,223 +1,240 @@ // @flow import html from 'common-tags/lib/html/index.js'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import { isValidPrimaryIdentityPublicKey, isValidSIWENonce, + isValidSIWEMessageType, } from 'lib/utils/siwe-utils.js'; import { getMessageForException } from './utils.js'; import { type LandingSSRProps } from '../landing/landing-ssr.react.js'; import { waitForStream } from '../utils/json-stream.js'; import { getAndAssertLandingURLFacts } from '../utils/urls.js'; async function landingHandler(req: $Request, res: $Response) { try { await landingResponder(req, res); } catch (e) { console.warn(e); if (!res.headersSent) { res.status(500).send(getMessageForException(e)); } } } const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&family=IBM+Plex+Sans:wght@400;500&display=swap'; const iaDuoFontsURL = 'fonts/duo.css'; const localFontsURL = 'fonts/local-fonts.css'; async function getDevFontURLs(): Promise<$ReadOnlyArray> { try { await access(localFontsURL); return [localFontsURL, iaDuoFontsURL]; } catch { return [googleFontsURL, iaDuoFontsURL]; } } type AssetInfo = { +jsURL: string, +fontURLs: $ReadOnlyArray, +cssInclude: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontURLs = await getDevFontURLs(); assetInfo = ({ jsURL: 'http://localhost:8082/dev.build.js', fontURLs, cssInclude: '', }: AssetInfo); return assetInfo; } try { const manifestString = await readFile( '../landing/dist/manifest.json', 'utf8', ); const manifest = JSON.parse(manifestString); assetInfo = ({ jsURL: `compiled/${manifest['browser.js']}`, fontURLs: [googleFontsURL, iaDuoFontsURL], cssInclude: html` `, }: AssetInfo); return assetInfo; } catch { throw new Error( 'Could not load manifest.json for landing build. ' + 'Did you forget to run `yarn dev` in the landing folder?', ); } } type LandingApp = React.ComponentType; let webpackCompiledRootComponent: ?LandingApp = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe landing/dist doesn't always exist const webpackBuild = await import('landing/dist/landing.build.cjs'); webpackCompiledRootComponent = webpackBuild.landing.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load landing.build.cjs. ' + 'Did you forget to run `yarn dev` in the landing folder?', ); } } // eslint-disable-next-line react/no-deprecated const { renderToNodeStream } = ReactDOMServer; async function landingResponder(req: $Request, res: $Response) { const siweNonce = req.header('siwe-nonce'); if ( siweNonce !== null && siweNonce !== undefined && !isValidSIWENonce(siweNonce) ) { res.status(400).send({ message: 'Invalid nonce in siwe-nonce header.', }); return; } const siwePrimaryIdentityPublicKey = req.header( 'siwe-primary-identity-public-key', ); if ( siwePrimaryIdentityPublicKey !== null && siwePrimaryIdentityPublicKey !== undefined && !isValidPrimaryIdentityPublicKey(siwePrimaryIdentityPublicKey) ) { res.status(400).send({ message: 'Invalid primary identity public key in siwe-primary-identity-public-key header.', }); return; } + const siweMessageType = req.header('siwe-message-type'); + if ( + siweMessageType !== null && + siweMessageType !== undefined && + !isValidSIWEMessageType(siweMessageType) + ) { + res.status(400).send({ + message: 'Invalid siwe message type.', + }); + return; + } const [{ jsURL, fontURLs, cssInclude }, LandingSSR] = await Promise.all([ getAssetInfo(), getWebpackCompiledRootComponentForSSR(), ]); const fontsInclude = fontURLs .map(url => ``) .join(''); const urlFacts = getAndAssertLandingURLFacts(); const { basePath } = urlFacts; // prettier-ignore res.write(html` Comm ${fontsInclude} ${cssInclude}
`); // We remove trailing slash for `react-router` const routerBasename = basePath.replace(/\/$/, ''); const clientPath = routerBasename + req.url; const reactStream = renderToNodeStream( , ); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); const siweNonceString = siweNonce ? `"${siweNonce}"` : 'null'; const siwePrimaryIdentityPublicKeyString = siwePrimaryIdentityPublicKey ? `"${siwePrimaryIdentityPublicKey}"` : 'null'; + const siweMessageTypeString = siweMessageType + ? `"${siweMessageType}"` + : 'null'; // prettier-ignore res.end(html`
+ `); } export default landingHandler; diff --git a/landing/landing-ssr.react.js b/landing/landing-ssr.react.js index 8d10aa229..dd98c27e8 100644 --- a/landing/landing-ssr.react.js +++ b/landing/landing-ssr.react.js @@ -1,35 +1,43 @@ // @flow import * as React from 'react'; import { StaticRouter } from 'react-router'; import Landing from './landing.react.js'; import { SIWEContext } from './siwe-context.js'; export type LandingSSRProps = { +url: string, +basename: string, +siweNonce: ?string, +siwePrimaryIdentityPublicKey: ?string, + +siweMessageType: ?string, }; function LandingSSR(props: LandingSSRProps): React.Node { - const { url, basename, siweNonce, siwePrimaryIdentityPublicKey } = props; + const { + url, + basename, + siweNonce, + siwePrimaryIdentityPublicKey, + siweMessageType, + } = props; const siweContextValue = React.useMemo( () => ({ siweNonce, siwePrimaryIdentityPublicKey, + siweMessageType, }), - [siweNonce, siwePrimaryIdentityPublicKey], + [siweNonce, siwePrimaryIdentityPublicKey, siweMessageType], ); const routerContext = React.useMemo(() => ({}), []); return ( ); } export default LandingSSR; diff --git a/landing/root.js b/landing/root.js index e2d237f49..a316cd080 100644 --- a/landing/root.js +++ b/landing/root.js @@ -1,30 +1,32 @@ // @flow import * as React from 'react'; import { BrowserRouter } from 'react-router-dom'; import Landing from './landing.react.js'; import { SIWEContext } from './siwe-context.js'; declare var routerBasename: string; declare var siweNonce: ?string; declare var siwePrimaryIdentityPublicKey: ?string; +declare var siweMessageType: ?string; function RootComponent(): React.Node { const siweContextValue = React.useMemo( () => ({ siweNonce, siwePrimaryIdentityPublicKey, + siweMessageType, }), [], ); return ( ); } export default RootComponent; diff --git a/landing/siwe-context.js b/landing/siwe-context.js index e93e48e55..a287355a9 100644 --- a/landing/siwe-context.js +++ b/landing/siwe-context.js @@ -1,15 +1,17 @@ // @flow import * as React from 'react'; export type SIWEContextType = { +siweNonce: ?string, +siwePrimaryIdentityPublicKey: ?string, + +siweMessageType: ?string, }; const SIWEContext: React.Context = React.createContext({ siweNonce: null, siwePrimaryIdentityPublicKey: null, + siweMessageType: null, }); export { SIWEContext }; diff --git a/lib/types/siwe-types.js b/lib/types/siwe-types.js index df8dee71e..0da3fe9c6 100644 --- a/lib/types/siwe-types.js +++ b/lib/types/siwe-types.js @@ -1,138 +1,150 @@ // @flow import type { LogInExtraInfo } 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 SIWEAuthServerCall = { +message: string, +signature: string, +doNotRegister?: boolean, ...LogInExtraInfo, }; 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, }; export type IdentityWalletRegisterInput = { +address: string, +message: string, +signature: string, +fid?: ?string, }; + +export const SIWEMessageTypes = Object.freeze({ + MSG_AUTH: 'msg_auth', + MSG_BACKUP: 'msg_backup', +}); + +export type SIWEMessageType = $Values; + +export type SIWEBackupSecrets = { + +message: string, + +signature: string, +}; diff --git a/lib/utils/siwe-utils.js b/lib/utils/siwe-utils.js index ec538c34b..69fd9b0de 100644 --- a/lib/utils/siwe-utils.js +++ b/lib/utils/siwe-utils.js @@ -1,122 +1,130 @@ // @flow import invariant from 'invariant'; import { SiweMessage } from 'siwe'; +import t, { type TEnums } from 'tcomb'; import { isDev } from './dev-utils.js'; -import type { SIWEMessage } from '../types/siwe-types.js'; +import { type SIWEMessage, SIWEMessageTypes } from '../types/siwe-types.js'; +import { values } from '../utils/objects.js'; const siweNonceRegex: RegExp = /^[a-zA-Z0-9]{17}$/; function isValidSIWENonce(candidate: string): boolean { return siweNonceRegex.test(candidate); } const ethereumAddressRegex: RegExp = /^0x[a-fA-F0-9]{40}$/; function isValidEthereumAddress(candidate: string): boolean { return ethereumAddressRegex.test(candidate); } const primaryIdentityPublicKeyRegex: RegExp = /^[a-zA-Z0-9+/]{43}$/; function isValidPrimaryIdentityPublicKey(candidate: string): boolean { return primaryIdentityPublicKeyRegex.test(candidate); } +const siweMessageTypeValidator: TEnums = t.enums.of(values(SIWEMessageTypes)); +function isValidSIWEMessageType(candidate: string): boolean { + return siweMessageTypeValidator.is(candidate); +} + const siweStatementLegalAgreement: string = 'By continuing, I accept the Comm Terms of Service: https://comm.app/terms'; function createSIWEMessage( address: string, statement: string, nonce: string, ): string { invariant(nonce, 'nonce must be present in createSiweMessage'); const domain = window.location.host; const origin = window.location.origin; const message = new SiweMessage({ domain, address, statement, uri: origin, version: '1', chainId: '1', nonce, }); return message.prepareMessage(); } function isValidSIWEDomain(candidate: string): boolean { return isDev ? candidate === 'localhost:3000' : candidate === 'comm.app' || candidate === 'web.comm.app'; } function isValidSIWEURI(candidate: string): boolean { return isDev ? candidate === 'http://localhost:3000' : candidate === 'https://comm.app' || candidate === 'https://web.comm.app'; } // Verify that the SIWEMessage is a well formed Comm SIWE Auth message. function isValidSIWEMessage(candidate: SIWEMessage): boolean { return ( candidate.statement !== null && candidate.statement !== undefined && isValidSIWEStatementWithPublicKey(candidate.statement) && candidate.version === '1' && candidate.chainId === 1 && isValidSIWEDomain(candidate.domain) && isValidSIWEURI(candidate.uri) && isValidSIWENonce(candidate.nonce) && isValidEthereumAddress(candidate.address) ); } function getSIWEStatementForPublicKey(publicKey: string): string { invariant( isValidPrimaryIdentityPublicKey(publicKey), 'publicKey must be well formed in getSIWEStatementForPublicKey', ); return `Device IdPubKey: ${publicKey} ${siweStatementLegalAgreement}`; } const siweStatementWithPublicKeyRegex = /^Device IdPubKey: [a-zA-Z0-9+/]{43} By continuing, I accept the Comm Terms of Service: https:\/\/comm.app\/terms$/; function isValidSIWEStatementWithPublicKey(candidate: string): boolean { return siweStatementWithPublicKeyRegex.test(candidate); } const publicKeyFromSIWEStatementRegex: RegExp = /[a-zA-Z0-9+/]{43}/; function getPublicKeyFromSIWEStatement(statement: string): string { invariant( isValidSIWEStatementWithPublicKey(statement), 'candidate must be well formed SIWE statement with public key', ); const publicKeyMatchArray = statement.match(publicKeyFromSIWEStatementRegex); invariant( publicKeyMatchArray !== null && publicKeyMatchArray !== undefined && publicKeyMatchArray.length === 1, 'publicKeyMatchArray should have one and only one element', ); return publicKeyMatchArray[0]; } // These are shown in the `SIWE` components on both `landing` and `web`. const siweMessageSigningExplanationStatements: string = `To complete the login process, you’ll now be ` + `asked to sign a message using your wallet. ` + `This signature will attest that your Ethereum ` + `identity is represented by your new Comm identity.`; export { isValidSIWENonce, isValidEthereumAddress, + isValidSIWEMessageType, primaryIdentityPublicKeyRegex, isValidPrimaryIdentityPublicKey, createSIWEMessage, isValidSIWEMessage, getSIWEStatementForPublicKey, isValidSIWEStatementWithPublicKey, getPublicKeyFromSIWEStatement, siweMessageSigningExplanationStatements, }; diff --git a/native/account/fullscreen-siwe-panel.react.js b/native/account/fullscreen-siwe-panel.react.js index a52807c51..b971f689c 100644 --- a/native/account/fullscreen-siwe-panel.react.js +++ b/native/account/fullscreen-siwe-panel.react.js @@ -1,161 +1,162 @@ // @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 type { SIWEResult } from 'lib/types/siwe-types.js'; +import { type SIWEResult, SIWEMessageTypes } from 'lib/types/siwe-types.js'; import { ServerError } 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, useIdentityWalletLogInCall, useIdentityWalletRegisterCall, } 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 } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; 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 } = 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 legacySiweServerCall = useLegacySIWEServerCall(); const identityWalletLogInCall = useIdentityWalletLogInCall(); const identityWalletRegisterCall = useIdentityWalletRegisterCall(); const successRef = React.useRef(false); const dispatch = useDispatch(); const onSuccess = React.useCallback( async (result: SIWEResult) => { successRef.current = true; if (usingCommServicesAccessToken) { try { const findUserIDResponse = await commRustModule.findUserIDForWalletAddress(result.address); if (JSON.parse(findUserIDResponse).userID) { await identityWalletLogInCall(result); } else if (enableNewRegistrationMode) { await onAccountDoesNotExist(result); } else { await identityWalletRegisterCall(result); } } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); throw e; } } else { try { await legacySiweServerCall({ ...result, doNotRegister: enableNewRegistrationMode, }); } catch (e) { if ( e instanceof ServerError && e.message === 'account_does_not_exist' ) { await onAccountDoesNotExist(result); return; } Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: goBackToPrompt }], { cancelable: false }, ); throw e; } dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); } }, [ identityWalletLogInCall, identityWalletRegisterCall, goBackToPrompt, dispatch, legacySiweServerCall, onAccountDoesNotExist, ], ); 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 5c09372ae..cf420c23e 100644 --- a/native/account/registration/connect-ethereum.react.js +++ b/native/account/registration/connect-ethereum.react.js @@ -1,326 +1,327 @@ // @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 } from 'lib/types/siwe-types.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 } 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, ConnectFarcasterRouteName, } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import EthereumLogoDark from '../../vectors/ethereum-logo-dark.react.js'; import SIWEPanel from '../siwe-panel.react.js'; const exactSearchUserLoadingStatusSelector = createLoadingStatusSelector( exactSearchUserActionTypes, ); export type ConnectEthereumParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverURL: string, }, }; type PanelState = 'closed' | 'opening' | 'open' | 'closing'; type Props = { +navigation: RegistrationNavigationProp<'ConnectEthereum'>, +route: NavigationRoute<'ConnectEthereum'>, }; function ConnectEthereum(props: Props): React.Node { const { params } = props.route; const { userSelections } = params; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections } = registrationContext; 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 [panelState, setPanelState] = React.useState('closed'); const openPanel = React.useCallback(() => { setPanelState('opening'); }, []); const onPanelClosed = React.useCallback(() => { setPanelState('closed'); }, []); const onPanelClosing = React.useCallback(() => { setPanelState('closing'); }, []); const siwePanelSetLoading = React.useCallback( (loading: boolean) => { if (panelState === 'closing' || panelState === 'closed') { return; } setPanelState(loading ? 'opening' : 'open'); }, [panelState], ); const { navigate } = props.navigation; const onSkip = React.useCallback(() => { navigate<'ConnectFarcaster'>({ name: ConnectFarcasterRouteName, params, }); }, [navigate, params]); const { keyserverURL } = userSelections; 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, ethereumAccount, }; navigate<'ConnectFarcaster'>({ name: ConnectFarcasterRouteName, params: { userSelections: newUserSelections }, }); }, [ userSelections, exactSearchUserCall, dispatchActionPromise, navigate, getEthereumAccountFromSIWEResult, ], ); let siwePanel; if (panelState !== 'closed') { siwePanel = ( ); } const { ethereumAccount } = cachedSelections; const alreadyHasConnected = !!ethereumAccount; 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, ethereumAccount, }; navigate<'ConnectFarcaster'>({ name: ConnectFarcasterRouteName, 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/siwe-panel.react.js b/native/account/siwe-panel.react.js index f1ace6a55..1dc555cbd 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,257 +1,264 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, siweAuthActionTypes, } 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 } from 'lib/types/siwe-types.js'; +import type { + SIWEWebViewMessage, + SIWEResult, + SIWEMessageType, +} 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 { 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(siweAuthActionTypes); type Props = { +onClosed: () => mixed, +onClosing: () => mixed, +onSuccessfulWalletSignature: SIWEResult => mixed, + +siweMessageType: SIWEMessageType, +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 legacySiweAuthCallLoading = useSelector( state => legacySiweAuthLoadingStatusSelector(state) === 'loading', ); const [nonce, setNonce] = React.useState(null); const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = React.useState(null); React.useEffect(() => { const generateNonce = async (nonceFunction: () => Promise) => { try { const response = await nonceFunction(); setNonce(response); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: 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, ]); 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 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) { closeBottomSheet?.(); await onSuccessfulWalletSignature({ address, message, signature }); } } else if (data.type === 'siwe_closed') { 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, ], ); const prevClosingRef = React.useRef(); React.useEffect(() => { if (closing && !prevClosingRef.current) { closeBottomSheet?.(); } prevClosingRef.current = closing; }, [closing, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, + 'siwe-message-type': siweMessageType, }, }), - [nonce, primaryIdentityPublicKey], + [nonce, primaryIdentityPublicKey, siweMessageType], ); 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;