diff --git a/keyserver/src/responders/landing-handler.js b/keyserver/src/responders/landing-handler.js index 71b806d9c..1b7052524 100644 --- a/keyserver/src/responders/landing-handler.js +++ b/keyserver/src/responders/landing-handler.js @@ -1,240 +1,242 @@ // @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 { type SIWEMessageType } from 'lib/types/siwe-types.js'; 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'); + const siweMessageTypeRawString = req.header('siwe-message-type'); if ( - siweMessageType !== null && - siweMessageType !== undefined && - !isValidSIWEMessageType(siweMessageType) + siweMessageTypeRawString !== null && + siweMessageTypeRawString !== undefined && + !isValidSIWEMessageType(siweMessageTypeRawString) ) { res.status(400).send({ message: 'Invalid siwe message type.', }); return; } + const siweMessageType = ((siweMessageTypeRawString: any): SIWEMessageType); 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 dd98c27e8..59f3b0925 100644 --- a/landing/landing-ssr.react.js +++ b/landing/landing-ssr.react.js @@ -1,43 +1,45 @@ // @flow import * as React from 'react'; import { StaticRouter } from 'react-router'; +import { type SIWEMessageType } from 'lib/types/siwe-types.js'; + 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, + +siweMessageType: ?SIWEMessageType, }; function LandingSSR(props: LandingSSRProps): React.Node { const { url, basename, siweNonce, siwePrimaryIdentityPublicKey, siweMessageType, } = props; const siweContextValue = React.useMemo( () => ({ siweNonce, siwePrimaryIdentityPublicKey, siweMessageType, }), [siweNonce, siwePrimaryIdentityPublicKey, siweMessageType], ); const routerContext = React.useMemo(() => ({}), []); return ( ); } export default LandingSSR; diff --git a/landing/root.js b/landing/root.js index a316cd080..5d84232d3 100644 --- a/landing/root.js +++ b/landing/root.js @@ -1,32 +1,34 @@ // @flow import * as React from 'react'; import { BrowserRouter } from 'react-router-dom'; +import { type SIWEMessageType } from 'lib/types/siwe-types.js'; + 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; +declare var siweMessageType: ?SIWEMessageType; 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 a287355a9..96d25a561 100644 --- a/landing/siwe-context.js +++ b/landing/siwe-context.js @@ -1,17 +1,19 @@ // @flow import * as React from 'react'; +import { type SIWEMessageType } from 'lib/types/siwe-types.js'; + export type SIWEContextType = { +siweNonce: ?string, +siwePrimaryIdentityPublicKey: ?string, - +siweMessageType: ?string, + +siweMessageType: ?SIWEMessageType, }; const SIWEContext: React.Context = React.createContext({ siweNonce: null, siwePrimaryIdentityPublicKey: null, siweMessageType: null, }); export { SIWEContext }; diff --git a/landing/siwe.react.js b/landing/siwe.react.js index e200c34a9..3acdca9b6 100644 --- a/landing/siwe.react.js +++ b/landing/siwe.react.js @@ -1,201 +1,209 @@ // @flow import { useConnectModal, RainbowKitProvider, darkTheme, useModalState, } from '@rainbow-me/rainbowkit'; import '@rainbow-me/rainbowkit/styles.css'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import invariant from 'invariant'; import _merge from 'lodash/fp/merge.js'; import * as React from 'react'; import { useAccount, useWalletClient, WagmiProvider } from 'wagmi'; import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js'; import { type SIWEWebViewMessage } from 'lib/types/siwe-types.js'; import { getSIWEStatementForPublicKey, userTextsForSIWEMessageTypes, createSIWEMessage, } from 'lib/utils/siwe-utils.js'; import { AlchemyENSCacheProvider, getWagmiConfig, } from 'lib/utils/wagmi-utils.js'; import { SIWEContext } from './siwe-context.js'; import css from './siwe.css'; import { useMonitorForWalletConnectModal, type WalletConnectModalUpdate, } from './walletconnect-hooks.js'; function postMessageToNativeWebView(message: SIWEWebViewMessage) { window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); } const wagmiConfig = getWagmiConfig(['rainbow', 'metamask', 'walletconnect']); type Signer = { +signMessage: ({ +message: string, ... }) => Promise, ... }; async function signInWithEthereum( address: string, signer: Signer, nonce: string, statement: string, ) { invariant(nonce, 'nonce must be present in signInWithEthereum'); const message = createSIWEMessage(address, statement, nonce); const signature = await signer.signMessage({ message }); postMessageToNativeWebView({ type: 'siwe_success', address, message, signature, }); } const queryClient = new QueryClient(); function SIWE(): React.Node { const { address } = useAccount(); const { data: signer } = useWalletClient(); const { siweNonce, siwePrimaryIdentityPublicKey, siweMessageType } = React.useContext(SIWEContext); const onClick = React.useCallback(() => { invariant(siweNonce, 'nonce must be present during SIWE attempt'); + invariant(siweMessageType, 'message type must be set during SIWE attempt'); invariant( siwePrimaryIdentityPublicKey, 'primaryIdentityPublicKey must be present during SIWE attempt', ); const statement = getSIWEStatementForPublicKey( siwePrimaryIdentityPublicKey, + siweMessageType, ); void signInWithEthereum(address, signer, siweNonce, statement); - }, [address, signer, siweNonce, siwePrimaryIdentityPublicKey]); + }, [ + address, + signer, + siweNonce, + siwePrimaryIdentityPublicKey, + siweMessageType, + ]); const { openConnectModal } = useConnectModal(); const hasNonce = siweNonce !== null && siweNonce !== undefined; React.useEffect(() => { if (hasNonce && openConnectModal) { openConnectModal(); } }, [hasNonce, openConnectModal]); const [wcModalOpen, setWCModalOpen] = React.useState(false); const prevModalOpen = React.useRef(false); const modalState = useModalState(); const closeTimeoutRef = React.useRef(); const { connectModalOpen } = modalState; const modalOpen = connectModalOpen || wcModalOpen; React.useEffect(() => { if (!modalOpen && prevModalOpen.current && !signer) { closeTimeoutRef.current = setTimeout( () => postMessageToNativeWebView({ type: 'siwe_closed' }), 500, ); } else if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = undefined; } prevModalOpen.current = modalOpen; }, [modalOpen, signer]); const onWalletConnectModalUpdate = React.useCallback( (update: WalletConnectModalUpdate) => { if (update.state === 'closed') { setWCModalOpen(false); postMessageToNativeWebView({ type: 'walletconnect_modal_update', ...update, }); } else { setWCModalOpen(true); postMessageToNativeWebView({ type: 'walletconnect_modal_update', ...update, }); } }, [], ); useMonitorForWalletConnectModal(onWalletConnectModalUpdate); if (!siweMessageType) { return (

Unable to proceed: message type not found

); } if (!hasNonce) { return (

Unable to proceed: nonce not found.

); } else if (!signer) { return null; } else { const { explanationStatement, buttonStatement, showTermsAgreement } = userTextsForSIWEMessageTypes[siweMessageType]; let termsOfUseAndPolicyInfo = null; if (showTermsAgreement) { termsOfUseAndPolicyInfo = (

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

); } return (

Wallet Connected

{explanationStatement}

{termsOfUseAndPolicyInfo}
{buttonStatement}
); } } function SIWEWrapper(): React.Node { const theme = React.useMemo(() => { return _merge(darkTheme())({ radii: { modal: 0, modalMobile: 0, }, colors: { modalBackdrop: '#242529', }, }); }, []); return ( ); } export default SIWEWrapper; diff --git a/lib/utils/siwe-utils.js b/lib/utils/siwe-utils.js index d41dc542d..d1a3797f7 100644 --- a/lib/utils/siwe-utils.js +++ b/lib/utils/siwe-utils.js @@ -1,161 +1,174 @@ // @flow import invariant from 'invariant'; import { SiweMessage } from 'siwe'; import t, { type TEnums } from 'tcomb'; import { isDev } from './dev-utils.js'; -import { type SIWEMessage, SIWEMessageTypes } from '../types/siwe-types.js'; +import { + type SIWEMessage, + SIWEMessageTypes, + type SIWEMessageType, +} 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 { +function getSIWEStatementForPublicKey( + publicKey: string, + messageType: SIWEMessageType, +): string { invariant( isValidPrimaryIdentityPublicKey(publicKey), 'publicKey must be well formed in getSIWEStatementForPublicKey', ); - return `Device IdPubKey: ${publicKey} ${siweStatementLegalAgreement}`; + if (messageType === SIWEMessageTypes.MSG_AUTH) { + return `Primary device IdPubKey: ${publicKey} ${siweStatementLegalAgreement}`; + } + return ( + `Backup message for primary 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$/; + /^(Primary device|Backup message for primary 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.`; const siweMessageSigningButtonStatement = 'Sign in using this wallet'; const siweBackupMessageSigningExplanationStatements: string = `Your signature on this message will be used to derive ` + `a secret key that will encrypt your Comm backup.`; const siweBackupMessageSigningButtonStatement = 'Create a backup key'; const userTextsForSIWEMessageTypes: { +[signatureRequestType: string]: { +explanationStatement: string, +showTermsAgreement: boolean, +buttonStatement: string, }, } = { [SIWEMessageTypes.MSG_AUTH]: { explanationStatement: siweMessageSigningExplanationStatements, showTermsAgreement: true, buttonStatement: siweMessageSigningButtonStatement, }, [SIWEMessageTypes.MSG_BACKUP]: { explanationStatement: siweBackupMessageSigningExplanationStatements, showTermsAgreement: false, buttonStatement: siweBackupMessageSigningButtonStatement, }, }; export { isValidSIWENonce, isValidEthereumAddress, isValidSIWEMessageType, primaryIdentityPublicKeyRegex, isValidPrimaryIdentityPublicKey, createSIWEMessage, isValidSIWEMessage, getSIWEStatementForPublicKey, isValidSIWEStatementWithPublicKey, getPublicKeyFromSIWEStatement, siweMessageSigningExplanationStatements, siweBackupMessageSigningExplanationStatements, siweBackupMessageSigningButtonStatement, siweMessageSigningButtonStatement, userTextsForSIWEMessageTypes, }; diff --git a/lib/utils/siwe-utils.test.js b/lib/utils/siwe-utils.test.js index 57bdebce4..fc62fa0b6 100644 --- a/lib/utils/siwe-utils.test.js +++ b/lib/utils/siwe-utils.test.js @@ -1,215 +1,258 @@ // @flow import { getPublicKeyFromSIWEStatement, getSIWEStatementForPublicKey, isValidEthereumAddress, isValidPrimaryIdentityPublicKey, isValidSIWENonce, isValidSIWEStatementWithPublicKey, } from './siwe-utils.js'; +import { SIWEMessageTypes } from '../types/siwe-types.js'; describe('SIWE Nonce utils', () => { it('isValidSIWENonce should match valid nonces', () => { // Following valid nonces generated using `siwe/generateNonce()` function const validNonces = [ '1VxXbANmmHLFP4eks', '2MmJuAAseMtLv5mCn', '2USPurs7uBwua8S8x', '3Qk22CmMo65LpaG9c', '4QXu7RFVlXreNzQrK', '5BLyDYtuk7coJCvzC', '7K2JD4wCmGrKsTkOF', 'Akx19qnKDuvB48SZC', 'aTirbrzPTKVPOCl4D', 'bkjdjOg4tA7Xpy452', 'BNhdFo59dEribfobg', 'buf0Wxv4bGLjcWT3c', 'CoXCHoJTCdVy5sTtf', 'd7oetTb1wJuvhCA4l', 'DMaRtZ6yiLRnuyafK', 'e7Sdl1z6EQiXCN8l9', 'EF4Hdiej0gxDmBjvy', 'EFauh8CSAIRlDwOLg', 'eqbMxIOjiJdjskqlN', 'fOYFxCD5ir430agxl', 'GCN1lI61eRvHUws1M', 'gGDMKiPcbykhCwzMO', 'gJCSvNZ1pHksy5TpJ', 'gzgnURrK65KTlfYBp', 'H4wZ6w5qiisbulWzI', 'hNJFuzAdnSEU4bx8X', 'HWaO4nN9aDGH8AnaA', 'IU5DJWa9TUXz5H1tV', 'kDE8OPvsheXIihCj4', 'LaQ8i3ZJY3DpdwCPI', 'LBWHU6XM4MFjLqXrd', 'lf3hoCBuqTdsl58EA', 'm1nx1X4EQJRL3Sg9b', 'Mk6t3PKZnL8jwcd0n', 'mnkumoUJFtI9Zhxdu', 'OPL9f2NvQL3d8rHce', 'orJqvnFu0dsIDuPPv', 'ox9zchmtBRDUxhiKr', 'q35RUCfsoGHszDi2W', 'qc13k7CZp9noVr9Xm', 'QnyXVsOu4ul4E8UTT', 'RBDJVgUWxrxVoOYpg', 'u3vxhrCwHihNlwZOf', 'wBF0fzrjy4oWrager', 'xEmvoMx72izf48yKN', 'Xha2QfepadZtolkTi', 'XZLvZW0VqcK3og31l', 'yGMxs0bt7r15KxAFF', 'YmHPiTCKGzTMyWe3x', 'YzxFDqTd84pTDbcJP', ]; validNonces.forEach(nonce => { expect(isValidSIWENonce(nonce)).toBe(true); }); }); it('isValidSIWENonce should fail if nonce is wrong length', () => { const shortNonce = '1VxXbANmmHLFP4ek'; expect(isValidSIWENonce(shortNonce)).toBe(false); const longNonce = '1VxXbANmmHLFP4eks1'; expect(isValidSIWENonce(longNonce)).toBe(false); }); it('isValidSIWENonce should fail if nonce has invalid characters', () => { const invalidNonce = '1VxXbANmmHLFP4ek!'; expect(isValidSIWENonce(invalidNonce)).toBe(false); }); it('isValidEthereumAddress should match valid ethereum addresses', () => { // Following valid ethereum addresses from https://etherscan.io/accounts const validEthereumAddresses = [ '0x00000000219ab540356cbb839cbe05303d7705fa', '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', '0xbe0eb53f46cd790cd13851d5eff43d12404d33e8', '0xda9dfa130df4de4673b89022ee50ff26f6ea73cf', '0x0716a17fbaee714f1e6ab0f9d59edbc5f09815c0', '0xf977814e90da44bfa03b6295a0616a897441acec', '0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a', '0x47ac0fb4f2d84898e4d9e7b4dab3c24507a6d503', '0x61edcdf5bb737adffe5043706e7c5bb1f1a56eea', '0xe92d1a43df510f82c66382592a047d288f85226f', '0x742d35cc6634c0532925a3b844bc454e4438f44e', '0xdf9eb223bafbe5c5271415c75aecd68c21fe3d7f', '0x1b3cb81e51011b549d78bf720b0d924ac763a7c2', '0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae', '0xca8fa8f0b631ecdb18cda619c4fc9d197c8affca', '0x756d64dc5edb56740fc617628dc832ddbcfd373c', '0x4ddc2d193948926d02f9b1fe9e1daa0718270ed5', '0x3bfc20f0b9afcace800d73d2191166ff16540258', '0xcffad3200574698b78f32232aa9d63eabd290703', '0x8484ef722627bf18ca5ae6bcf031c23e6e922b30', '0x8103683202aa8da10536036edef04cdd865c225e', '0x78605df79524164911c144801f41e9811b7db73d', '0x189b9cbd4aff470af2c0102f365fc1823d857965', '0xdc24316b9ae028f1497c275eb9192a3ea0f67022', '0x0a4c79ce84202b03e95b7a692e5d728d83c44c76', '0x220866b1a2219f40e72f5c628b65d54268ca3a9d', '0x2b6ed29a95753c3ad948348e3e7b1a251080ffb9', '0x195b91ccebd51aa61d851fe531f5612dea4efbfd', '0x28c6c06298d514db089934071355e5743bf21d60', '0x9845e1909dca337944a0272f1f9f7249833d2d19', '0x99c9fc46f92e8a1c0dec1b1747d010903e884be1', '0x176f3dab24a159341c0509bb36b833e7fdd0a132', '0x07ee55aa48bb72dcc6e9d78256648910de513eca', '0xa3ae36c55a076e849b9d3de677d1e0b6e9c98e84', '0x59448fe20378357f206880c58068f095ae63d5a5', '0x73af3bcf944a6559933396c1577b257e2054d935', '0x558553d54183a8542f7832742e7b4ba9c33aa1e6', '0x98ec059dc3adfbdd63429454aeb0c990fba4a128', '0x539c92186f7c6cc4cbf443f26ef84c595babbca1', '0xbfbbfaccd1126a11b8f84c60b09859f80f3bd10f', '0x868dab0b8e21ec0a48b726a1ccf25826c78c6d7f', '0x0c23fc0ef06716d2f8ba19bc4bed56d045581f2d', '0x6262998ced04146fa42253a5c0af90ca02dfd2a3', '0xcdbf58a9a9b54a2c43800c50c7192946de858321', '0xbddf00563c9abd25b576017f08c46982012f12be', '0xe523fc253bcdea8373e030ee66e00c6864776d70', '0x2f2d854c1d6d5bb8936bb85bc07c28ebb42c9b10', '0xbf3aeb96e164ae67e763d9e050ff124e7c3fdd28', '0x434587332cc35d33db75b93f4f27cc496c67a4db', '0x36a85757645e8e8aec062a1dee289c7d615901ca', ]; validEthereumAddresses.forEach(address => { expect(isValidEthereumAddress(address)).toBe(true); }); }); it('isValidEthereumAddress should fail if address is wrong length', () => { const shortEthereumAddress = '0x36a85757645e8e8aec062a1dee289c7d615901'; expect(isValidEthereumAddress(shortEthereumAddress)).toBe(false); const longEthereumString = '0x36a85757645e8e8aec062a1dee289c7d615901cAAAAA'; expect(isValidEthereumAddress(longEthereumString)).toBe(false); }); it('isValidEthereumAddress should fail if address has invalid characters', () => { const invalidAddress = '0x36a85757645e8e8aec062a1dee289c7d615901ca!'; expect(isValidEthereumAddress(invalidAddress)).toBe(false); }); it(`isValidEthereumAddress should fail if address doesn't begin with 0x`, () => { const invalidAddress = 'e523fc253bcdea8373e030ee66e00c6864776d70'; expect(isValidEthereumAddress(invalidAddress)).toBe(false); }); it(`isValidPrimaryIdentityPublicKey should succeed for valid ed25519 keys`, () => { const validPublicKey = 'rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo'; expect(isValidPrimaryIdentityPublicKey(validPublicKey)).toBe(true); const anotherValidPublicKey = '98+/eB2MUVvYCpkESOS1zuWnWttsYWDKeDXl8T3o8LY'; expect(isValidPrimaryIdentityPublicKey(anotherValidPublicKey)).toBe(true); }); it(`isValidPrimaryIdentityPublicKey should fail for keys < 43 chars`, () => { const shortPublicKey = 'rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4JYdo'; expect(isValidPrimaryIdentityPublicKey(shortPublicKey)).toBe(false); }); it(`isValidPrimaryIdentityPublicKey should fail for keys > 43 chars`, () => { const longPublicKey = '98+/eB2MUVvYCpkESOSWttsYWDKeDXl8TAAAAAAAAAA3o8LY'; expect(isValidPrimaryIdentityPublicKey(longPublicKey)).toBe(false); }); - it(`getSIWEStatementForPublicKey should generate expected statement`, () => { + it(`getSIWEStatementForPublicKey should generate expected statements`, () => { const validPublicKey = 'rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo'; - const expectedString = - `Device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + const expectedStringForSocialProof = + `Primary device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; expect( - getSIWEStatementForPublicKey(validPublicKey) === expectedString, + getSIWEStatementForPublicKey( + validPublicKey, + SIWEMessageTypes.MSG_AUTH, + ) === expectedStringForSocialProof, + ).toBe(true); + + const expectedStringForBackupMessage = + `Backup message for primary device IdPubKey: ` + + `rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; + expect( + getSIWEStatementForPublicKey( + validPublicKey, + SIWEMessageTypes.MSG_BACKUP, + ) === expectedStringForBackupMessage, ).toBe(true); }); it(`isValidSIWEStatementWithPublicKey should be true for well formed SIWE statement`, () => { const validPublicKey = 'rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo'; - const expectedString = - `Device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + const expectedStringForSocialProof = + `Primary device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; + + expect( + isValidSIWEStatementWithPublicKey(expectedStringForSocialProof), + ).toBe(true); + expect( + isValidSIWEStatementWithPublicKey( + getSIWEStatementForPublicKey(validPublicKey, SIWEMessageTypes.MSG_AUTH), + ), + ).toBe(true); + + const expectedStringForBackupMessage = + `Backup message for primary device IdPubKey: ` + + `rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; - expect(isValidSIWEStatementWithPublicKey(expectedString)).toBe(true); + expect( + isValidSIWEStatementWithPublicKey(expectedStringForBackupMessage), + ).toBe(true); expect( isValidSIWEStatementWithPublicKey( - getSIWEStatementForPublicKey(validPublicKey), + getSIWEStatementForPublicKey( + validPublicKey, + SIWEMessageTypes.MSG_BACKUP, + ), ), ).toBe(true); }); - it(`getPublicKeyFromSIWEStatement should pull public key out of valid SIWE statement`, () => { - const validSIWEMessageStatement = - `Device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + it(`getPublicKeyFromSIWEStatement should pull public key out of valid SIWE statements`, () => { + const validSIWEMessageStatementForSocialProof = + `Primary device IdPubKey: rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; - expect(getPublicKeyFromSIWEStatement(validSIWEMessageStatement)).toBe( - 'rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo', - ); + const validSIWEStatementForBackupMessage = + `Backup message for primary device IdPubKey: ` + + `rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo ` + + `By continuing, I accept the Comm Terms of Service: https://comm.app/terms`; + + expect( + getPublicKeyFromSIWEStatement(validSIWEMessageStatementForSocialProof), + ).toBe('rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo'); + + expect( + getPublicKeyFromSIWEStatement(validSIWEStatementForBackupMessage), + ).toBe('rPFzRtV7E6v1b60zjTvghqb2xgnggmn6j4UaYccJYdo'); }); }); diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js index c0d03e169..0560cc0a9 100644 --- a/web/account/siwe-login-form.react.js +++ b/web/account/siwe-login-form.react.js @@ -1,308 +1,312 @@ // @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, siweAuth, siweAuthActionTypes, } 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 { logInExtraInfoSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LogInStartingPayload, LogInExtraInfo, } from 'lib/types/account-types.js'; +import { SIWEMessageTypes } from 'lib/types/siwe-types.js'; import { getMessageForException, ServerError } 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'; type SIWELogInError = 'account_does_not_exist'; type SIWELoginFormProps = { +cancelSIWEAuthFlow: () => void, }; const legacyGetSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const identityGenerateNonceLoadingStatusSelector = createLoadingStatusSelector( identityGenerateNonceActionTypes, ); const legacySiweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); 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(siweAuth); const logInExtraInfo = useSelector(logInExtraInfoSelector); 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: LogInExtraInfo) => { 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) { if ( e instanceof ServerError && e.message === 'account_does_not_exist' ) { setError('account_does_not_exist'); } throw e; } }, [legacySiweAuthCall], ); const attemptLegacySIWEAuth = React.useCallback( (message: string, signature: string) => { return dispatchActionPromise( siweAuthActionTypes, callLegacySIWEAuthEndpoint(message, signature, logInExtraInfo), undefined, ({ calendarQuery: logInExtraInfo.calendarQuery }: LogInStartingPayload), ); }, [callLegacySIWEAuthEndpoint, dispatchActionPromise, logInExtraInfo], ); const attemptWalletLogIn = React.useCallback( async ( walletAddress: string, siweMessage: string, siweSignature: string, ) => { try { return await walletLogIn(walletAddress, siweMessage, siweSignature); } catch (e) { if (getMessageForException(e) === 'user not found') { setError('account_does_not_exist'); } 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); + 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 .

); } return (

Sign in with Ethereum

Wallet Connected

{siweMessageSigningExplanationStatements}

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

{errorText}
); } export default SIWELoginForm;