[web] SRP code improvements (#6241)
This commit is contained in:
@@ -293,8 +293,7 @@ const {
|
||||
// ...
|
||||
twoFactorSessionID,
|
||||
passkeySessionID,
|
||||
} = await loginViaSRP(srpAttributes, kek);
|
||||
setIsFirstLogin(true);
|
||||
} = await verifySRP(srpAttributes, kek);
|
||||
if (passkeySessionID) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user