diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 10301a490..5b26f6957 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,379 +1,390 @@ // @flow import invariant from 'invariant'; +import { SiweMessage } from 'siwe'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { policies } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from 'lib/types/account-types'; import { userSettingsTypes, notificationTypeValues, logInActionSources, } from 'lib/types/account-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; +import type { SIWEAuthRequest, SIWEMessage } from 'lib/types/siwe-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { PasswordUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; +import { isValidSIWEMessage } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, } from 'lib/utils/validation-utils'; import createAccount from '../creators/account-creator'; import { dbQuery, SQL } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, } from '../fetchers/user-fetchers'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, } from '../updaters/account-updaters'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { validateInput } from '../utils/validation-utils'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: tPassword, }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(logInActionSources))), }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const username = request.username ?? request.usernameOrEmail; if (!username) { throw new ServerError('invalid_parameters'); } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { if (hasMinCodeVersion(viewer.platformDetails, 99999)) { throw new ServerError('invalid_parameters'); } else { throw new ServerError('invalid_credentials'); } } const id = userRow.id.toString(); const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(t.String), }); async function siweAuthResponder(viewer: Viewer, input: any): Promise { await validateInput(viewer, siweAuthRequestInputValidator, input); + const request: SIWEAuthRequest = input; + const { message } = request; + + // 1. Ensure that `message` is a well formed Comm SIWE Auth message. + const siweMessage: SIWEMessage = new SiweMessage(message); + if (!isValidSIWEMessage(siweMessage)) { + throw new ServerError('invalid_parameters'); + } return false; } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); return await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: any, ): Promise { const request: PolicyAcknowledgmentRequest = input; await validateInput( viewer, policyAcknowledgmentRequestInputValidator, request, ); await viewerAcknowledgmentUpdater(viewer, request.policy); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, }; diff --git a/landing/siwe.react.js b/landing/siwe.react.js index 7bcc0b2d8..826e62d1f 100644 --- a/landing/siwe.react.js +++ b/landing/siwe.react.js @@ -1,187 +1,184 @@ // @flow import { useConnectModal, getDefaultWallets, RainbowKitProvider, darkTheme, useModalState, } from '@rainbow-me/rainbowkit'; import invariant from 'invariant'; import _merge from 'lodash/fp/merge'; import * as React from 'react'; import { SiweMessage } from 'siwe'; import '@rainbow-me/rainbowkit/dist/index.css'; import { useAccount, useSigner, chain, configureChains, createClient, WagmiConfig, } from 'wagmi'; import { publicProvider } from 'wagmi/providers/public'; import type { SIWEWebViewMessage } from 'lib/types/siwe-types'; +import { siweStatement } from 'lib/utils/siwe-utils.js'; import { SIWENonceContext } from './siwe-nonce-context.js'; import css from './siwe.css'; // details can be found https://wagmi.sh/docs/providers/configuring-chains const { chains, provider } = configureChains( [chain.mainnet], [publicProvider()], ); const { connectors } = getDefaultWallets({ appName: 'comm', chains, }); const wagmiClient = createClient({ autoConnect: true, connectors, provider, }); function createSiweMessage(address: string, statement: string, nonce: 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 postMessageToNativeWebView(message: SIWEWebViewMessage) { window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); } async function signInWithEthereum(address: string, signer, nonce: string) { invariant(nonce, 'nonce must be present in signInWithEthereum'); - const message = createSiweMessage( - address, - 'By continuing, I accept the Comm Terms of Service: https://comm.app/terms', - nonce, - ); + const message = createSiweMessage(address, siweStatement, nonce); const signature = await signer.signMessage(message); postMessageToNativeWebView({ type: 'siwe_success', address, message, signature, }); } function SIWE(): React.Node { const { address } = useAccount(); const { data: signer } = useSigner(); const { siweNonce } = React.useContext(SIWENonceContext); const onClick = React.useCallback(() => { invariant(siweNonce, 'nonce must be present during SIWE attempt'); signInWithEthereum(address, signer, siweNonce); }, [address, signer, siweNonce]); const { openConnectModal } = useConnectModal(); const hasNonce = siweNonce !== null && siweNonce !== undefined; React.useEffect(() => { if (hasNonce && openConnectModal) { openConnectModal(); } }, [hasNonce, openConnectModal]); const prevConnectModalOpen = React.useRef(false); const modalState = useModalState(); const { connectModalOpen } = modalState; React.useEffect(() => { if (!connectModalOpen && prevConnectModalOpen.current && !signer) { postMessageToNativeWebView({ type: 'siwe_closed' }); } prevConnectModalOpen.current = connectModalOpen; }, [connectModalOpen, signer]); const newModalAppeared = React.useCallback(mutationList => { for (const mutation of mutationList) { for (const addedNode of mutation.addedNodes) { if ( addedNode instanceof HTMLElement && addedNode.id === 'walletconnect-wrapper' ) { postMessageToNativeWebView({ type: 'walletconnect_modal_update', state: 'open', }); } } for (const addedNode of mutation.removedNodes) { if ( addedNode instanceof HTMLElement && addedNode.id === 'walletconnect-wrapper' ) { postMessageToNativeWebView({ type: 'walletconnect_modal_update', state: 'closed', }); } } } }, []); React.useEffect(() => { const observer = new MutationObserver(newModalAppeared); invariant(document.body, 'document.body should be set'); observer.observe(document.body, { childList: true }); return () => { observer.disconnect(); }; }, [newModalAppeared]); if (!hasNonce) { return (

Unable to proceed: nonce not found.

); } else if (!signer) { return null; } else { return (
sign in
); } } function SIWEWrapper(): React.Node { const theme = React.useMemo(() => { return _merge(darkTheme())({ radii: { modal: 0, modalMobile: 0, }, colors: { modalBackdrop: '#242529', }, }); }, []); return ( ); } const ethIconStyle = { height: 25, paddingRight: 10 }; export default SIWEWrapper; diff --git a/lib/utils/siwe-utils.js b/lib/utils/siwe-utils.js index bfbeac1cd..4608197bb 100644 --- a/lib/utils/siwe-utils.js +++ b/lib/utils/siwe-utils.js @@ -1,13 +1,40 @@ // @flow +import type { SIWEMessage } from '../types/siwe-types.js'; +import { isDev } from './dev-utils.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); } -export { isValidSIWENonce, isValidEthereumAddress }; +const siweStatement: string = + 'By continuing, I accept the Comm Terms of Service: https://comm.app/terms'; + +const expectedDomain = isDev ? 'localhost:3000' : 'comm.app'; +const expectedURI = isDev ? 'http://localhost:3000' : 'https://comm.app'; + +// Verify that the SIWEMessage is a well formed Comm SIWE Auth message. +function isValidSIWEMessage(candidate: SIWEMessage): boolean { + return ( + candidate.statement === siweStatement && + candidate.version === '1' && + candidate.chainId === 1 && + candidate.domain === expectedDomain && + candidate.uri === expectedURI && + isValidSIWENonce(candidate.nonce) && + isValidEthereumAddress(candidate.address) + ); +} + +export { + siweStatement, + isValidSIWENonce, + isValidEthereumAddress, + isValidSIWEMessage, +};