[web] SRP code improvements (#6241)

This commit is contained in:
Manav Rathi
2025-06-11 11:35:10 +05:30
committed by GitHub
13 changed files with 425 additions and 269 deletions

View File

@@ -293,8 +293,7 @@ const {
// ...
twoFactorSessionID,
passkeySessionID,
} = await loginViaSRP(srpAttributes, kek);
setIsFirstLogin(true);
} = await verifySRP(srpAttributes, kek);
if (passkeySessionID) {
// ...
}

View File

@@ -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<KeyAttributes | undefined>;
/**
@@ -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;
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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<PageContentsProps> = ({ 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 (
<AccountsPageContents>

View File

@@ -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);

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<typeof UpdateSRPAndKeysResponse>;
/**
* 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<UpdateSRPAndKeysResponse> => {
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<EmailOrSRPVerificationResponse> => {
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<UpdateSRPAndKeysResponse> => {
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<UserVerificationResponse> => {
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<typeof CreateSRPSessionResponse>;
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<CreateSRPSessionResponse> => {
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<typeof RemoteSRPVerificationResponse>;
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<SRPVerificationResponse> => {
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());
};

View File

@@ -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: 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<UserVerificationResponse> => {
): Promise<EmailOrSRPVerificationResponse> => {
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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = <T>(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.
*/