diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index d9d2043cb..c2b2c5721 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,401 +1,419 @@ // @flow import invariant from 'invariant'; -import { SiweMessage } from 'siwe'; +import { ErrorTypes, 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 { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; 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; + const { message, signature } = 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'); } // 2. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } + // 3. Validate SIWEMessage signature and handle possible errors. + try { + await siweMessage.validate(signature); + } catch (error) { + if (error === ErrorTypes.EXPIRED_MESSAGE) { + // Thrown when the `expirationTime` is present and in the past. + throw new ServerError('expired_message', { status: 400 }); + } else if (error === ErrorTypes.INVALID_SIGNATURE) { + // Thrown when the `validate()` function can't verify the message. + throw new ServerError('invalid_signature', { status: 400 }); + } else if (error === ErrorTypes.MALFORMED_SESSION) { + // Thrown when some required field is missing. + throw new ServerError('malformed_session', { status: 400 }); + } else { + throw new ServerError('unknown_error', { status: 500 }); + } + } + 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/lib/types/siwe-types.js b/lib/types/siwe-types.js index 29b72b2d4..50ac46b9d 100644 --- a/lib/types/siwe-types.js +++ b/lib/types/siwe-types.js @@ -1,108 +1,109 @@ // @flow import type { LogInExtraInfo } from './account-types.js'; import { type DeviceTokenUpdateRequest, type PlatformDetails, } from './device-types'; import { type CalendarQuery } from './entry-types'; export type SIWENonceResponse = { +nonce: string, }; export type SIWEAuthRequest = { +message: string, +signature: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, }; export type SIWEAuthServerCall = { +message: string, +signature: string, ...LogInExtraInfo, }; // 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' | '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', + +validate: (signature: string, provider?: any) => Promise, };