diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md index 39cc57ad53..b994fc98e4 100644 --- a/web/docs/webauthn-passkeys.md +++ b/web/docs/webauthn-passkeys.md @@ -293,8 +293,7 @@ const { // ... twoFactorSessionID, passkeySessionID, -} = await loginViaSRP(srpAttributes, kek); -setIsFirstLogin(true); +} = await verifySRP(srpAttributes, kek); if (passkeySessionID) { // ... } diff --git a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx index 02439ab2ac..73d0b529ba 100644 --- a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx @@ -1,5 +1,8 @@ import { Input, TextField } from "@mui/material"; -import type { SRPAttributes } from "ente-accounts/services/srp"; +import { + srpVerificationUnauthorizedErrorMessage, + type SRPAttributes, +} from "ente-accounts/services/srp"; import type { KeyAttributes, User } from "ente-accounts/services/user"; import { LoadingButton } from "ente-base/components/mui/LoadingButton"; import { ShowHidePasswordInputAdornment } from "ente-base/components/mui/PasswordInputAdornment"; @@ -31,9 +34,10 @@ export interface VerifyMasterPasswordFormProps { * the form that some other form of second factor is enabled and the user * has been redirected to a two factor verification page. * - * This function can throw an `CustomError.INCORRECT_PASSWORD_OR_NO_ACCOUNT` - * to signal that either that the password is incorrect, or no account with - * the provided email exists. + * @throws A Error with message + * {@link srpVerificationUnauthorizedErrorMessage} to signal that either + * that the password is incorrect, or no account with the provided email + * exists. */ getKeyAttributes?: (kek: string) => Promise; /** @@ -155,7 +159,7 @@ export const VerifyMasterPasswordForm: React.FC< // the two-factor verification page. return; - case CustomError.INCORRECT_PASSWORD_OR_NO_ACCOUNT: + case srpVerificationUnauthorizedErrorMessage: log.error("Incorrect password or no account", e); setFieldError( t("incorrect_password_or_no_account"), @@ -178,8 +182,7 @@ export const VerifyMasterPasswordForm: React.FC< }, kek, ); - } catch (e) { - log.warn("Incorrect password", e); + } catch { setFieldError(t("incorrect_password")); return; } diff --git a/web/packages/accounts/components/utils/second-factor-choice.ts b/web/packages/accounts/components/utils/second-factor-choice.ts index e263bb6af3..1557255ae3 100644 --- a/web/packages/accounts/components/utils/second-factor-choice.ts +++ b/web/packages/accounts/components/utils/second-factor-choice.ts @@ -3,9 +3,9 @@ * needs to be in a separate file to allow fast refresh. */ +import type { EmailOrSRPVerificationResponse } from "ente-accounts/services/user"; import { useModalVisibility } from "ente-base/components/utils/modal"; import { useCallback, useMemo, useRef } from "react"; -import type { UserVerificationResponse } from "../../services/user"; import type { SecondFactorType } from "../SecondFactorChoice"; /** @@ -39,7 +39,7 @@ export const useSecondFactorChoiceIfNeeded = () => { ); const userVerificationResultAfterResolvingSecondFactorChoice = useCallback( - async (response: UserVerificationResponse) => { + async (response: EmailOrSRPVerificationResponse) => { const { twoFactorSessionID: _twoFactorSessionIDV1, twoFactorSessionIDV2: _twoFactorSessionIDV2, diff --git a/web/packages/accounts/pages/change-email.tsx b/web/packages/accounts/pages/change-email.tsx index a1e6127210..4e9c367278 100644 --- a/web/packages/accounts/pages/change-email.tsx +++ b/web/packages/accounts/pages/change-email.tsx @@ -11,7 +11,7 @@ import { LinkButton } from "ente-base/components/LinkButton"; import { LoadingButton } from "ente-base/components/mui/LoadingButton"; import { isHTTPErrorWithStatus } from "ente-base/http"; import log from "ente-base/log"; -import { getData, setLSUser } from "ente-shared/storage/localStorage"; +import { getData } from "ente-shared/storage/localStorage"; import { Formik, type FormikHelpers } from "formik"; import { t } from "i18next"; import { useRouter } from "next/router"; @@ -86,7 +86,6 @@ const ChangeEmailForm: React.FC = () => { try { setLoading(true); await changeEmail(email, ott!); - await setLSUser({ ...getData("user"), email }); setLoading(false); void goToApp(); } catch (e) { diff --git a/web/packages/accounts/pages/change-password.tsx b/web/packages/accounts/pages/change-password.tsx index 80638af96e..fe6ba8345b 100644 --- a/web/packages/accounts/pages/change-password.tsx +++ b/web/packages/accounts/pages/change-password.tsx @@ -8,31 +8,18 @@ import SetPasswordForm, { } from "ente-accounts/components/SetPasswordForm"; import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect"; import { - generateSRPSetupAttributes, - getSRPAttributes, - srpSetupOrReconfigure, - updateSRPAndKeys, - type UpdatedKeyAttr, -} from "ente-accounts/services/srp"; -import { - ensureSavedKeyAttributes, - generateAndSaveInteractiveKeyAttributes, + changePassword, localUser, type LocalUser, } from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { LoadingIndicator } from "ente-base/components/loaders"; -import { sharedCryptoWorker } from "ente-base/crypto"; import { deriveKeyInsufficientMemoryErrorMessage } from "ente-base/crypto/types"; import log from "ente-base/log"; -import { - ensureMasterKeyFromSession, - saveMasterKeyInSessionAndSafeStore, -} from "ente-base/session"; import { getData, setData } from "ente-shared/storage/localStorage"; import { t } from "i18next"; import { useRouter } from "next/router"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; /** * A page that allows a user to reset or change their password. @@ -62,75 +49,29 @@ interface PageContentsProps { } const PageContents: React.FC = ({ user }) => { - const token = user.token; - const router = useRouter(); - const onSubmit: SetPasswordFormProps["callback"] = async ( - passphrase, - setFieldError, - ) => { - try { - await onSubmit2(passphrase); - } catch (e) { - log.error("Could not change password", e); - setFieldError( - "confirm", - e instanceof Error && - e.message == deriveKeyInsufficientMemoryErrorMessage - ? t("password_generation_failed") - : t("generic_error"), - ); - } - }; - - const onSubmit2 = async (passphrase: string) => { - const cryptoWorker = await sharedCryptoWorker(); - const masterKey = await ensureMasterKeyFromSession(); - const keyAttributes = ensureSavedKeyAttributes(); - const { - key: kek, - salt: kekSalt, - opsLimit, - memLimit, - } = await cryptoWorker.deriveSensitiveKey(passphrase); - const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } = - await cryptoWorker.encryptBox(masterKey, kek); - const updatedKeyAttr: UpdatedKeyAttr = { - encryptedKey, - keyDecryptionNonce, - kekSalt, - opsLimit, - memLimit, - }; - - await srpSetupOrReconfigure( - await generateSRPSetupAttributes(kek), - ({ setupID, srpM1 }) => - updateSRPAndKeys(token, { setupID, srpM1, updatedKeyAttr }), - ); - - // Update the SRP attributes that are stored locally. - const srpAttributes = await getSRPAttributes(user.email); - if (srpAttributes) { - setData("srpAttributes", srpAttributes); - } - - await generateAndSaveInteractiveKeyAttributes( - passphrase, - { ...keyAttributes, ...updatedKeyAttr }, - masterKey, - ); - - await saveMasterKeyInSessionAndSafeStore(masterKey); - - redirectToAppHome(); - }; - - const redirectToAppHome = () => { + const redirectToAppHome = useCallback(() => { setData("showBackButton", { value: true }); void router.push(appHomeRoute); - }; + }, [router]); + + const onSubmit: SetPasswordFormProps["callback"] = async ( + password, + setFieldError, + ) => + changePassword(password) + .then(redirectToAppHome) + .catch((e: unknown) => { + log.error("Could not change password", e); + setFieldError( + "confirm", + e instanceof Error && + e.message == deriveKeyInsufficientMemoryErrorMessage + ? t("password_generation_failed") + : t("generic_error"), + ); + }); return ( diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 34cfadc3d5..c03b269ae0 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -23,10 +23,10 @@ import { import { checkSessionValidity } from "ente-accounts/services/session"; import type { SRPAttributes } from "ente-accounts/services/srp"; import { - configureSRP, generateSRPSetupAttributes, getSRPAttributes, - loginViaSRP, + setupSRP, + verifySRP, } from "ente-accounts/services/srp"; import { generateAndSaveInteractiveKeyAttributes, @@ -185,7 +185,7 @@ const Page: React.FC = () => { accountsUrl, } = await userVerificationResultAfterResolvingSecondFactorChoice( - await loginViaSRP(srpAttributes!, kek), + await verifySRP(srpAttributes!, kek), ); setIsFirstLogin(true); @@ -271,7 +271,7 @@ const Page: React.FC = () => { } log.debug(() => `userSRPSetupPending ${!srpAttributes}`); if (!srpAttributes) { - await configureSRP(await generateSRPSetupAttributes(kek)); + await setupSRP(await generateSRPSetupAttributes(kek)); } } catch (e) { log.error("migrate to srp failed", e); diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index 6ea72c51dd..ecff1d2ba6 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -9,8 +9,8 @@ import SetPasswordForm, { } from "ente-accounts/components/SetPasswordForm"; import { appHomeRoute } from "ente-accounts/services/redirect"; import { - configureSRP, generateSRPSetupAttributes, + setupSRP, } from "ente-accounts/services/srp"; import type { KeyAttributes, User } from "ente-accounts/services/user"; import { @@ -74,7 +74,7 @@ const Page: React.FC = () => { await generateKeysAndAttributes(passphrase); await putUserKeyAttributes(keyAttributes); - await configureSRP(await generateSRPSetupAttributes(kek)); + await setupSRP(await generateSRPSetupAttributes(kek)); await generateAndSaveInteractiveKeyAttributes( passphrase, keyAttributes, diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 72dc9eb325..3841418b11 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -17,8 +17,8 @@ import { unstashRedirect, } from "ente-accounts/services/redirect"; import { - configureSRP, getSRPAttributes, + setupSRP, unstashAndUseSRPSetupAttributes, type SRPAttributes, } from "ente-accounts/services/srp"; @@ -146,7 +146,7 @@ const Page: React.FC = () => { if (originalKeyAttributes) { await putUserKeyAttributes(originalKeyAttributes); } - await unstashAndUseSRPSetupAttributes(configureSRP); + await unstashAndUseSRPSetupAttributes(setupSRP); } // TODO(RE): Temporary safety valve before removing the // unnecessary clear (tag: Migration) diff --git a/web/packages/accounts/services/srp.ts b/web/packages/accounts/services/srp.ts index ebdf3331a1..8618957f89 100644 --- a/web/packages/accounts/services/srp.ts +++ b/web/packages/accounts/services/srp.ts @@ -1,4 +1,3 @@ -import { HttpStatusCode } from "axios"; import { deriveSubKeyBytes, generateDeriveKeySalt, @@ -9,14 +8,14 @@ import { ensureOk, publicRequestHeaders, } from "ente-base/http"; -import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; -import { ApiError, CustomError } from "ente-shared/error"; -import HTTPService from "ente-shared/network/HTTPService"; import { SRP, SrpClient } from "fast-srp-hap"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod/v4"; -import type { UserVerificationResponse } from "./user"; +import { + RemoteSRPVerificationResponse, + type EmailOrSRPVerificationResponse, +} from "./user"; /** * The SRP attributes for a user. @@ -363,7 +362,7 @@ export const unstashAndUseSRPSetupAttributes = async ( * * @param srpSetupAttributes SRP setup attributes. */ -export const configureSRP = async (srpSetupAttributes: SRPSetupAttributes) => +export const setupSRP = async (srpSetupAttributes: SRPSetupAttributes) => srpSetupOrReconfigure(srpSetupAttributes, completeSRPSetup); /** @@ -391,7 +390,7 @@ type SRPSetupOrReconfigureExchangeCallback = ({ * * @param srpSetupAttributes SRP setup attributes. */ -export const srpSetupOrReconfigure = async ( +const srpSetupOrReconfigure = async ( { srpSalt, srpUserID, srpVerifier, loginSubKey }: SRPSetupAttributes, exchangeCB: SRPSetupOrReconfigureExchangeCallback, ) => { @@ -495,15 +494,10 @@ const completeSRPSetup = async ( return CompleteSRPSetupResponse.parse(await res.json()); }; -interface CreateSRPSessionResponse { - sessionID: string; - srpB: string; -} - -export interface SRPVerificationResponse extends UserVerificationResponse { - srpM2: string; -} - +/** + * The subset of {@link KeyAttributes} that get updated when the user changes + * their password. + */ export interface UpdatedKeyAttr { kekSalt: string; encryptedKey: string; @@ -512,9 +506,29 @@ export interface UpdatedKeyAttr { memLimit: number; } +/** + * Update the user's affected key and SRP attributes when they change their + * password. + * + * The flow on changing password is similar to the flow on initial SRP setup, + * with some differences at the tail end of the flow. See: [Note: SRP setup]. + * + * @param srpSetupAttributes Attributes for the user's updated SRP setup. + * + * @param updatedKeyAttr The subset of the user's key attributes which need to + * be updated to reflect their changed password. + */ +export const updateSRPAndKeyAttributes = ( + srpSetupAttributes: SRPSetupAttributes, + updatedKeyAttr: UpdatedKeyAttr, +) => + srpSetupOrReconfigure(srpSetupAttributes, ({ setupID, srpM1 }) => + updateSRPAndKeys({ setupID, srpM1, updatedKeyAttr }), + ); + export interface UpdateSRPAndKeysRequest { - srpM1: string; setupID: string; + srpM1: string; updatedKeyAttr: UpdatedKeyAttr; /** * If true (default), then all existing sessions for the user will be @@ -523,102 +537,126 @@ export interface UpdateSRPAndKeysRequest { logOutOtherDevices?: boolean; } -export interface UpdateSRPAndKeysResponse { - srpM2: string; - setupID: string; +const UpdateSRPAndKeysResponse = z.object({ + srpM2: z.string(), + setupID: z.string(), +}); + +type UpdateSRPAndKeysResponse = z.infer; + +/** + * Update the SRP attributes and a subset of the key attributes on remote. + * + * This is invoked during the flow when the user changes their password, and SRP + * needs to be reconfigured. See: [Note: SRP setup]. + */ +const updateSRPAndKeys = async ( + updateSRPAndKeysRequest: UpdateSRPAndKeysRequest, +): Promise => { + const res = await fetch(await apiURL("/users/srp/update"), { + method: "POST", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify(updateSRPAndKeysRequest), + }); + ensureOk(res); + return UpdateSRPAndKeysResponse.parse(await res.json()); +}; + +/** + * The message of the {@link Error} that is thrown by {@link verifySRP} if + * remote fails SRP verification with a HTTP 401. + * + * The API contract allows for a SRP verification 401 both because of incorrect + * credentials or a non existent account. + */ +export const srpVerificationUnauthorizedErrorMessage = + "SRP verification failed (HTTP 401 Unauthorized)"; + +/** + * Log the user in to a new device by performing SRP verification. + * + * This function implements the flow described in [Note: SRP verification]. + * + * @param srpAttributes The user's SRP attributes. + * + * @param kek The user's key encryption key as a base64 string. + * + * @returns If SRP verification is successful, it returns a + * {@link EmailOrSRPVerificationResponse}. + * + * @throws An Error with {@link srpVerificationUnauthorizedErrorMessage} in case + * there is no such account, or if the credentials (kek) are incorrect. + */ +export const verifySRP = async ( + { srpUserID, srpSalt }: SRPAttributes, + kek: string, +): Promise => { + const loginSubKey = await deriveSRPLoginSubKey(kek); + const srpClient = await generateSRPClient(srpSalt, srpUserID, loginSubKey); + + // Send A, obtain B. + const { srpB, sessionID } = await createSRPSession({ + srpUserID, + srpA: bufferToB64(srpClient.computeA()), + }); + + srpClient.setB(b64ToBuffer(srpB)); + + // Send M1, obtain M2. + const { srpM2, ...rest } = await verifySRPSession({ + sessionID, + srpUserID, + srpM1: bufferToB64(srpClient.computeM1()), + }); + + srpClient.checkM2(b64ToBuffer(srpM2)); + + return rest; +}; + +interface CreateSRPSessionRequest { + srpUserID: string; + srpA: string; } -export const updateSRPAndKeys = async ( - token: string, - updateSRPAndKeyRequest: UpdateSRPAndKeysRequest, -): Promise => { - try { - const resp = await HTTPService.post( - await apiURL("/users/srp/update"), - updateSRPAndKeyRequest, - undefined, - { "X-Auth-Token": token }, - ); - return resp.data as UpdateSRPAndKeysResponse; - } catch (e) { - log.error("updateSRPAndKeys failed", e); - throw e; - } -}; +const CreateSRPSessionResponse = z.object({ + sessionID: z.string(), + srpB: z.string(), +}); -export const loginViaSRP = async ( - srpAttributes: SRPAttributes, - kek: string, -): Promise => { - try { - const loginSubKey = await deriveSRPLoginSubKey(kek); - const srpClient = await generateSRPClient( - srpAttributes.srpSalt, - srpAttributes.srpUserID, - loginSubKey, - ); - const srpA = srpClient.computeA(); - const { srpB, sessionID } = await createSRPSession( - srpAttributes.srpUserID, - bufferToB64(srpA), - ); - srpClient.setB(b64ToBuffer(srpB)); +type CreateSRPSessionResponse = z.infer; - const m1 = srpClient.computeM1(); - log.debug(() => `srp m1: ${bufferToB64(m1)}`); - const { srpM2, ...rest } = await verifySRPSession( - sessionID, - srpAttributes.srpUserID, - bufferToB64(m1), - ); - log.debug(() => `srp verify session successful,srpM2: ${srpM2}`); - - srpClient.checkM2(b64ToBuffer(srpM2)); - - log.debug(() => `srp server verify successful`); - return rest; - } catch (e) { - log.error("srp verify failed", e); - throw e; - } -}; - -const createSRPSession = async (srpUserID: string, srpA: string) => { +const createSRPSession = async ( + createSRPSessionRequest: CreateSRPSessionRequest, +): Promise => { const res = await fetch(await apiURL("/users/srp/create-session"), { method: "POST", headers: publicRequestHeaders(), - body: JSON.stringify({ srpUserID, srpA }), + body: JSON.stringify(createSRPSessionRequest), }); ensureOk(res); - const data = await res.json(); - // TODO: Use zod - return data as CreateSRPSessionResponse; + return CreateSRPSessionResponse.parse(await res.json()); }; +interface VerifySRPSessionRequest { + sessionID: string; + srpUserID: string; + srpM1: string; +} + +type SRPVerificationResponse = z.infer; + const verifySRPSession = async ( - sessionID: string, - srpUserID: string, - srpM1: string, -) => { - try { - const resp = await HTTPService.post( - await apiURL("/users/srp/verify-session"), - { sessionID, srpUserID, srpM1 }, - undefined, - ); - return resp.data as SRPVerificationResponse; - } catch (e) { - log.error("verifySRPSession failed", e); - if ( - e instanceof ApiError && - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - e.httpStatusCode === HttpStatusCode.Unauthorized - ) { - // The API contract allows for a SRP verification 401 both because - // of incorrect credentials or a non existent account. - throw Error(CustomError.INCORRECT_PASSWORD_OR_NO_ACCOUNT); - } else { - throw e; - } + verifySRPSessionRequest: VerifySRPSessionRequest, +): Promise => { + const res = await fetch(await apiURL("/users/srp/verify-session"), { + method: "POST", + headers: publicRequestHeaders(), + body: JSON.stringify(verifySRPSessionRequest), + }); + if (res.status == 401) { + throw new Error(srpVerificationUnauthorizedErrorMessage); } + ensureOk(res); + return RemoteSRPVerificationResponse.parse(await res.json()); }; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index ad75316b0d..66b03e2ac4 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,3 +1,10 @@ +import { + generateSRPSetupAttributes, + getSRPAttributes, + saveSRPAttributes, + updateSRPAndKeyAttributes, + type UpdatedKeyAttr, +} from "ente-accounts/services/srp"; import { decryptBox, deriveInteractiveKey, @@ -6,14 +13,20 @@ import { generateKey, generateKeyPair, } from "ente-base/crypto"; +import { isDevBuild } from "ente-base/env"; import { authenticatedRequestHeaders, ensureOk, publicRequestHeaders, } from "ente-base/http"; import { apiURL } from "ente-base/origins"; +import { + ensureMasterKeyFromSession, + saveMasterKeyInSessionAndSafeStore, +} from "ente-base/session"; import { getAuthToken } from "ente-base/token"; import { getData, setLSUser } from "ente-shared/storage/localStorage"; +import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { getUserRecoveryKey, recoveryKeyFromMnemonic } from "./recovery-key"; @@ -157,6 +170,15 @@ export const ensureExpectedLoggedInValue = (t: T | undefined): T => { * * The various "key" attributes are base64 encoded representations of the * underlying binary data. + * + * [Note: Key attribute mutability] + * + * The key attributes contain two subsets: + * + * - Attributes that changes when the user changes their password. These are the + * {@link UpdatedKeyAttr}. + * + * - All other attributes never change after initial setup. */ export interface KeyAttributes { /** @@ -455,30 +477,6 @@ export const putUserRecoveryKeyAttributes = async ( }), ); -export interface UserVerificationResponse { - id: number; - keyAttributes?: KeyAttributes | undefined; - encryptedToken?: string | undefined; - token?: string; - twoFactorSessionID?: string | undefined; - passkeySessionID?: string | undefined; - /** - * Base URL for the accounts app where we should redirect to for passkey - * verification. - * - * This will only be set if the user has setup a passkey (i.e., whenever - * {@link passkeySessionID} is defined). - */ - accountsUrl: string | undefined; - /** - * If both passkeys and TOTP based two factors are enabled, then {@link - * twoFactorSessionIDV2} will be set to the TOTP session ID instead of - * {@link twoFactorSessionID}. - */ - twoFactorSessionIDV2?: string | undefined; - srpM2?: string | undefined; -} - /** * Ask remote to send a OTP / OTT to the given email to verify that the user has * access to it. Subsequent the app will pass this OTT back via the @@ -505,6 +503,134 @@ export const sendOTT = async ( }), ); +/** + * The response from remote on a successful user verification, either via + * {@link verifyEmail} or {@link verifySRP}. + * + * The {@link id} is always present. The rest of the values are are optional + * since only a subset of them will be returned depending on the case: + * + * 1. If the user has both passkeys and TOTP based second factor enabled, then + * the following will be set: + * - {@link passkeySessionID}, {@link accountsUrl} + * - {@link twoFactorSessionIDV2} + * + * 2. If the user has only passkeys enabled, then the following will be set: + * - {@link passkeySessionID}, {@link accountsUrl} + * + * 3. If the user has only TOTP based second factor enabled, then the following + * will be set: + * - {@link twoFactorSessionID} + * + * 4. If the user doesn't have any second factor, but has already setup their + * key attributes, then the following will be set: + * - {@link keyAttributes} + * - {@link encryptedToken} + * + * 5. Finally, in the rare case that the user has not yet setup their key + * attributes, then the following will be set: + * - {@link token} + */ +export interface EmailOrSRPVerificationResponse { + /** + * The user's ID. + */ + id: number; + /** + * The user's key attributes. + * + * These will be set (along with the {@link encryptedToken}) if the user + * does not have a second factor. + */ + keyAttributes?: KeyAttributes; + /** + * The base64 representation of an encrypted auth token, encrypted using the + * user's public key. + * + * These will be set (along with the {@link keyAttributes}) if the user + * does not have a second factor. + */ + encryptedToken?: string; + /** + * The base64 representation of an auth token. + * + * This will be set in the rare edge case for when the user has not yet + * setup their key attributes. + */ + token?: string; + /** + * A session ID that can be used to complete the TOTP based second factor. + * + * This will be set if the user has enabled a TOTP based second factor but + * has not enabled passkeys. + */ + twoFactorSessionID?: string; + /** + * A session ID that can be used to complete passkey verification. + * + * This will be set if the user has added a passkey to their account. + */ + passkeySessionID?: string; + /** + * Base URL for the accounts app where we should redirect to for passkey + * verification. + * + * This will only be set if the user has setup a passkey (i.e., whenever + * {@link passkeySessionID} is defined). + */ + accountsUrl?: string; + /** + * A session ID that can be used to complete the TOTP based second fator. + * + * This will be set in lieu of {@link twoFactorSessionID} if the user has + * setup both passkeys and TOTP based two factors are enabled for their + * account. + * + * --- + * + * Historical context: {@link twoFactorSessionIDV2} is only set if user has + * both passkey and two factor enabled. This is to ensure older clients keep + * using passkey flow when both are set. It is intended to be removed once + * all clients starts surfacing both options for performing 2FA. + * + * See also {@link useSecondFactorChoiceIfNeeded}. + */ + twoFactorSessionIDV2?: string; +} + +/** + * Zod schema for the {@link EmailOrSRPVerificationResponse} type. + * + * See: [Note: Duplicated Zod schema and TypeScript type] + */ +const RemoteEmailOrSRPVerificationResponse = z.object({ + id: z.number(), + keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined), + encryptedToken: z.string().nullish().transform(nullToUndefined), + token: z.string().nullish().transform(nullToUndefined), + twoFactorSessionID: z.string().nullish().transform(nullToUndefined), + passkeySessionID: z.string().nullish().transform(nullToUndefined), + accountsUrl: z.string().nullish().transform(nullToUndefined), + twoFactorSessionIDV2: z.string().nullish().transform(nullToUndefined), +}); + +/** + * A specialization of {@link RemoteEmailOrSRPVerificationResponse} for SRP + * verification, which results in the {@link srpM2} field in addition to the + * other ones. + * + * The declaration conceptually belongs to `srp.ts`, but is here to avoid cyclic + * dependencies. + */ +export const RemoteSRPVerificationResponse = z.object({ + ...RemoteEmailOrSRPVerificationResponse.shape, + /** + * The SRP M2 (evidence message), the proof that the server has the + * verifier. + */ + srpM2: z.string(), +}); + /** * Verify user's access to the given {@link email} by comparing the OTT that * remote previously sent to that email. @@ -521,50 +647,16 @@ export const verifyEmail = async ( email: string, ott: string, source: string | undefined, -): Promise => { +): Promise => { const res = await fetch(await apiURL("/users/verify-email"), { method: "POST", headers: publicRequestHeaders(), body: JSON.stringify({ email, ott, ...(source ? { source } : {}) }), }); ensureOk(res); - // See: [Note: strict mode migration] - // - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return EmailOrSRPAuthorizationResponse.parse(await res.json()); + return RemoteEmailOrSRPVerificationResponse.parse(await res.json()); }; -/** - * Zod schema for response from remote on a successful user verification, either - * via {@link verifyEmail} or {@link verifySRPSession}. - * - * If a second factor is enabled than one of the two factor session IDs - * (`passkeySessionID`, `twoFactorSessionID` / `twoFactorSessionIDV2`) will be - * set. Otherwise `keyAttributes` and `encryptedToken` will be set. - */ -export const EmailOrSRPAuthorizationResponse = z.object({ - id: z.number(), - keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined), - encryptedToken: z.string().nullish().transform(nullToUndefined), - token: z.string().nullish().transform(nullToUndefined), - twoFactorSessionID: z.string().nullish().transform(nullToUndefined), - passkeySessionID: z.string().nullish().transform(nullToUndefined), - // Base URL for the accounts app where we should redirect to for passkey - // verification. - accountsUrl: z.string().nullish().transform(nullToUndefined), - // TwoFactorSessionIDV2 is only set if user has both passkey and two factor - // enabled. This is to ensure older clients keep using passkey flow when - // both are set. It is intended to be removed once all clients starts - // surfacing both options for performing 2FA. - // - // See `useSecondFactorChoiceIfNeeded`. - twoFactorSessionIDV2: z.string().nullish().transform(nullToUndefined), - // srpM2 is sent only if the user is logging via SRP. It is is the SRP M2 - // value aka the proof that the server has the verifier. - srpM2: z.string().nullish().transform(nullToUndefined), -}); - /** * Log the user out on remote, if possible and needed. */ @@ -647,13 +739,22 @@ export const generateAndSaveInteractiveKeyAttributes = async ( }; /** - * Change the email associated with the user's account on remote. + * Change the email associated with the user's account (both locally and on + * remote) * * @param email The new email. * * @param ott The verification code that was sent to the new email. */ -export const changeEmail = async (email: string, ott: string) => +export const changeEmail = async (email: string, ott: string) => { + await postChangeEmail(email, ott); + await setLSUser({ ...getData("user"), email }); +}; + +/** + * Change the email associated with the user's account on remote. + */ +const postChangeEmail = async (email: string, ott: string) => ensureOk( await fetch(await apiURL("/users/change-email"), { method: "POST", @@ -662,6 +763,60 @@ export const changeEmail = async (email: string, ott: string) => }), ); +/** + * Change the user's password on both remote and locally. + * + * @param password The new password. + */ +export const changePassword = async (password: string) => { + const user = ensureLocalUser(); + const masterKey = await ensureMasterKeyFromSession(); + const keyAttributes = ensureSavedKeyAttributes(); + + // Generate new KEK. + const { + key: kek, + salt: kekSalt, + opsLimit, + memLimit, + } = await deriveSensitiveKey(password); + + // Generate new key attributes. + const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } = + await encryptBox(masterKey, kek); + const updatedKeyAttr: UpdatedKeyAttr = { + encryptedKey, + keyDecryptionNonce, + kekSalt, + opsLimit, + memLimit, + }; + + // Update SRP and key attributes on remote. + await updateSRPAndKeyAttributes( + await generateSRPSetupAttributes(kek), + updatedKeyAttr, + ); + + // Update SRP attributes locally. + const srpAttributes = await getSRPAttributes(user.email); + saveSRPAttributes(ensure(srpAttributes)); + + // Update key attributes locally, generating a new interactive kek while + // we're at it. + await generateAndSaveInteractiveKeyAttributes( + password, + { ...keyAttributes, ...updatedKeyAttr }, + masterKey, + ); + + // TODO(RE): This shouldn't be needed, remove me. As a soft remove, + // disabling it for dev builds. (tag: Migration) + if (!isDevBuild) { + await saveMasterKeyInSessionAndSafeStore(masterKey); + } +}; + const TwoFactorSecret = z.object({ /** * The 2FA secret code. diff --git a/web/packages/base/crypto/types.ts b/web/packages/base/crypto/types.ts index ac33b0bcd9..8b82a2aef8 100644 --- a/web/packages/base/crypto/types.ts +++ b/web/packages/base/crypto/types.ts @@ -24,9 +24,9 @@ export type SodiumStateAddress = StateAddress; export const streamEncryptionChunkSize = 4 * 1024 * 1024; /** - * The {@link message} of {@link Error} that is thrown by - * {@link deriveSensitiveKey} if we could not find acceptable ops and mem limit - * combinations without exceeded the maximum mem limit. + * The message of the {@link Error} that is thrown by {@link deriveSensitiveKey} + * if we could not find acceptable ops and mem limit combinations without + * exceeded the maximum mem limit. * * Generally, this indicates that the current device is not powerful enough to * perform the key derivation. This is rare for computers, but can happen with diff --git a/web/packages/shared/error/index.ts b/web/packages/shared/error/index.ts index dd6d7d961c..e787cbc331 100644 --- a/web/packages/shared/error/index.ts +++ b/web/packages/shared/error/index.ts @@ -40,7 +40,6 @@ export const CustomError = { BAD_REQUEST: "bad request", SUBSCRIPTION_NEEDED: "subscription not present", NOT_FOUND: "not found ", - INCORRECT_PASSWORD_OR_NO_ACCOUNT: "incorrect password or no such account", UPLOAD_CANCELLED: "upload cancelled", UPDATE_EXPORTED_RECORD_FAILED: "update file exported record failed", EXPORT_STOPPED: "export stopped", diff --git a/web/packages/utils/ensure.ts b/web/packages/utils/ensure.ts index 7d02ee0277..9be5215bf5 100644 --- a/web/packages/utils/ensure.ts +++ b/web/packages/utils/ensure.ts @@ -8,6 +8,28 @@ export const ensurePrecondition = (v: unknown): void => { if (!v) throw new Error("Precondition failed"); }; +/** + * Throw an exception if the given value is `null` or `undefined`. + * + * This is different from TypeScript's built in null assertion operator `!` in + * that `ensure` involves a runtime check, and will throw if the given value is + * null-ish. On the other hand the TypeScript null assertion is only an + * indication to the type system and does not involve any runtime checks. + * + * However, still it is preferable to use the TypeScript build in null assertion + * since the stack traces are more informative. The stack trace is not at the + * point of the assertion, but later at the point of the use, so it is not + * _directly_ pointing at the issue, but usually it is not hard to backtrace. + * + * Still, in rare cases we might want to, well, ensure that a undefined value + * doesn't sneak into the machinery. So this. + */ +export const ensure = (v: T | null | undefined): T => { + if (v === null) throw new Error("Required value was null"); + if (v === undefined) throw new Error("Required value was undefined"); + return v; +}; + /** * Throw an exception if the given value is not a string. */