[web] SRP code refactoring (#6232)
This commit is contained in:
@@ -13,8 +13,14 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { generateKeyAndSRPAttributes } from "ente-accounts/services/srp";
|
||||
import { sendOTT } from "ente-accounts/services/user";
|
||||
import {
|
||||
deriveSRPPassword,
|
||||
generateSRPSetupAttributes,
|
||||
} from "ente-accounts/services/srp";
|
||||
import {
|
||||
generateKeysAndAttributes,
|
||||
sendOTT,
|
||||
} from "ente-accounts/services/user";
|
||||
import { generateAndSaveIntermediateKeyAttributes } from "ente-accounts/utils/helpers";
|
||||
import { isWeakPassword } from "ente-accounts/utils/password";
|
||||
import { LinkButton } from "ente-base/components/LinkButton";
|
||||
@@ -96,8 +102,12 @@ export const SignUpContents: React.FC<SignUpContentsProps> = ({
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const { keyAttributes, masterKey, srpSetupAttributes } =
|
||||
await generateKeyAndSRPAttributes(passphrase);
|
||||
const { masterKey, kek, keyAttributes } =
|
||||
await generateKeysAndAttributes(passphrase);
|
||||
|
||||
const srpSetupAttributes = await generateSRPSetupAttributes(
|
||||
await deriveSRPPassword(kek),
|
||||
);
|
||||
|
||||
setData("originalKeyAttributes", keyAttributes);
|
||||
setData("srpSetupAttributes", srpSetupAttributes);
|
||||
|
||||
@@ -8,24 +8,24 @@ import SetPasswordForm, {
|
||||
} from "ente-accounts/components/SetPasswordForm";
|
||||
import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect";
|
||||
import {
|
||||
convertBase64ToBuffer,
|
||||
convertBufferToBase64,
|
||||
deriveSRPPassword,
|
||||
generateSRPClient,
|
||||
generateSRPSetupAttributes,
|
||||
getSRPAttributes,
|
||||
startSRPSetup,
|
||||
srpSetupOrReconfigure,
|
||||
updateSRPAndKeys,
|
||||
type UpdatedKeyAttr,
|
||||
} from "ente-accounts/services/srp";
|
||||
import type {
|
||||
KeyAttributes,
|
||||
UpdatedKey,
|
||||
User,
|
||||
import {
|
||||
ensureSavedKeyAttributes,
|
||||
localUser,
|
||||
type LocalUser,
|
||||
} from "ente-accounts/services/user";
|
||||
import { generateAndSaveIntermediateKeyAttributes } from "ente-accounts/utils/helpers";
|
||||
import { LinkButton } from "ente-base/components/LinkButton";
|
||||
import { LoadingIndicator } from "ente-base/components/loaders";
|
||||
import { sharedCryptoWorker } from "ente-base/crypto";
|
||||
import type { DerivedKey } from "ente-base/crypto/types";
|
||||
import { deriveKeyInsufficientMemoryErrorMessage } from "ente-base/crypto/types";
|
||||
import log from "ente-base/log";
|
||||
import {
|
||||
ensureMasterKeyFromSession,
|
||||
saveMasterKeyInSessionAndSafeStore,
|
||||
@@ -33,91 +33,98 @@ import {
|
||||
import { getData, setData } from "ente-shared/storage/localStorage";
|
||||
import { t } from "i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* A page that allows a user to reset or change their password.
|
||||
*/
|
||||
const Page: React.FC = () => {
|
||||
const [token, setToken] = useState<string>();
|
||||
const [user, setUser] = useState<User>();
|
||||
const [user, setUser] = useState<LocalUser>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const user = getData("user");
|
||||
setUser(user);
|
||||
if (!user?.token) {
|
||||
const user = localUser();
|
||||
if (user) {
|
||||
setUser(user);
|
||||
} else {
|
||||
stashRedirect("/change-password");
|
||||
void router.push("/");
|
||||
} else {
|
||||
setToken(user.token);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return user ? <PageContents {...{ user }} /> : <LoadingIndicator />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
interface PageContentsProps {
|
||||
user: LocalUser;
|
||||
}
|
||||
|
||||
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: KeyAttributes = getData("keyAttributes");
|
||||
let kek: DerivedKey;
|
||||
try {
|
||||
kek = await cryptoWorker.deriveSensitiveKey(passphrase);
|
||||
} catch {
|
||||
setFieldError("confirm", t("password_generation_failed"));
|
||||
return;
|
||||
}
|
||||
const keyAttributes = ensureSavedKeyAttributes();
|
||||
const {
|
||||
key: kek,
|
||||
salt: kekSalt,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
} = await cryptoWorker.deriveSensitiveKey(passphrase);
|
||||
const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } =
|
||||
await cryptoWorker.encryptBox(masterKey, kek.key);
|
||||
const updatedKey: UpdatedKey = {
|
||||
await cryptoWorker.encryptBox(masterKey, kek);
|
||||
const updatedKeyAttr: UpdatedKeyAttr = {
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
kekSalt: kek.salt,
|
||||
opsLimit: kek.opsLimit,
|
||||
memLimit: kek.memLimit,
|
||||
kekSalt,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
};
|
||||
|
||||
const loginSubKey = await deriveSRPPassword(kek.key);
|
||||
const loginSubKey = await deriveSRPPassword(kek);
|
||||
|
||||
const { srpUserID, srpSalt, srpVerifier } =
|
||||
await generateSRPSetupAttributes(loginSubKey);
|
||||
|
||||
const srpClient = await generateSRPClient(
|
||||
srpSalt,
|
||||
srpUserID,
|
||||
loginSubKey,
|
||||
await srpSetupOrReconfigure(
|
||||
{ srpSalt, srpUserID, srpVerifier, loginSubKey },
|
||||
({ setupID, srpM1 }) =>
|
||||
updateSRPAndKeys(token, { setupID, srpM1, updatedKeyAttr }),
|
||||
);
|
||||
|
||||
const srpA = convertBufferToBase64(srpClient.computeA());
|
||||
|
||||
const { setupID, srpB } = await startSRPSetup(token!, {
|
||||
srpUserID,
|
||||
srpSalt,
|
||||
srpVerifier,
|
||||
srpA,
|
||||
});
|
||||
|
||||
srpClient.setB(convertBase64ToBuffer(srpB));
|
||||
|
||||
const srpM1 = convertBufferToBase64(srpClient.computeM1());
|
||||
|
||||
await updateSRPAndKeys(token!, {
|
||||
setupID,
|
||||
srpM1,
|
||||
updatedKeyAttr: updatedKey,
|
||||
});
|
||||
|
||||
// Update the SRP attributes that are stored locally.
|
||||
if (user?.email) {
|
||||
const srpAttributes = await getSRPAttributes(user.email);
|
||||
if (srpAttributes) {
|
||||
setData("srpAttributes", srpAttributes);
|
||||
}
|
||||
const srpAttributes = await getSRPAttributes(user.email);
|
||||
if (srpAttributes) {
|
||||
setData("srpAttributes", srpAttributes);
|
||||
}
|
||||
|
||||
const updatedKeyAttributes = Object.assign(keyAttributes, updatedKey);
|
||||
await generateAndSaveIntermediateKeyAttributes(
|
||||
passphrase,
|
||||
updatedKeyAttributes,
|
||||
{ ...keyAttributes, ...updatedKeyAttr },
|
||||
masterKey,
|
||||
);
|
||||
|
||||
@@ -131,12 +138,11 @@ const Page: React.FC = () => {
|
||||
void router.push(appHomeRoute);
|
||||
};
|
||||
|
||||
// TODO: Handle the case where user is not loaded yet.
|
||||
return (
|
||||
<AccountsPageContents>
|
||||
<AccountsPageTitle>{t("change_password")}</AccountsPageTitle>
|
||||
<SetPasswordForm
|
||||
userEmail={user?.email ?? ""}
|
||||
userEmail={user.email}
|
||||
callback={onSubmit}
|
||||
buttonText={t("change_password")}
|
||||
/>
|
||||
@@ -150,5 +156,3 @@ const Page: React.FC = () => {
|
||||
</AccountsPageContents>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
unstashRedirect,
|
||||
} from "ente-accounts/services/redirect";
|
||||
import { checkSessionValidity } from "ente-accounts/services/session";
|
||||
import type { SRPAttributes } from "ente-accounts/services/srp";
|
||||
import {
|
||||
configureSRP,
|
||||
deriveSRPPassword,
|
||||
generateSRPSetupAttributes,
|
||||
getSRPAttributes,
|
||||
loginViaSRP,
|
||||
type SRPAttributes,
|
||||
} from "ente-accounts/services/srp";
|
||||
import type { KeyAttributes, User } from "ente-accounts/services/user";
|
||||
import {
|
||||
|
||||
@@ -10,14 +10,19 @@ import SetPasswordForm, {
|
||||
import { appHomeRoute } from "ente-accounts/services/redirect";
|
||||
import {
|
||||
configureSRP,
|
||||
generateKeyAndSRPAttributes,
|
||||
deriveSRPPassword,
|
||||
generateSRPSetupAttributes,
|
||||
} from "ente-accounts/services/srp";
|
||||
import type { KeyAttributes, User } from "ente-accounts/services/user";
|
||||
import { putUserKeyAttributes } from "ente-accounts/services/user";
|
||||
import {
|
||||
generateKeysAndAttributes,
|
||||
putUserKeyAttributes,
|
||||
} from "ente-accounts/services/user";
|
||||
import { generateAndSaveIntermediateKeyAttributes } from "ente-accounts/utils/helpers";
|
||||
import { LinkButton } from "ente-base/components/LinkButton";
|
||||
import { LoadingIndicator } from "ente-base/components/loaders";
|
||||
import { useBaseContext } from "ente-base/context";
|
||||
import { deriveKeyInsufficientMemoryErrorMessage } from "ente-base/crypto/types";
|
||||
import log from "ente-base/log";
|
||||
import {
|
||||
haveCredentialsInSession,
|
||||
@@ -66,8 +71,12 @@ const Page: React.FC = () => {
|
||||
setFieldError,
|
||||
) => {
|
||||
try {
|
||||
const { keyAttributes, masterKey, srpSetupAttributes } =
|
||||
await generateKeyAndSRPAttributes(passphrase);
|
||||
const { masterKey, kek, keyAttributes } =
|
||||
await generateKeysAndAttributes(passphrase);
|
||||
|
||||
const srpSetupAttributes = await generateSRPSetupAttributes(
|
||||
await deriveSRPPassword(kek),
|
||||
);
|
||||
|
||||
await putUserKeyAttributes(keyAttributes);
|
||||
await configureSRP(srpSetupAttributes);
|
||||
@@ -81,7 +90,13 @@ const Page: React.FC = () => {
|
||||
setOpenRecoveryKey(true);
|
||||
} catch (e) {
|
||||
log.error("failed to generate password", e);
|
||||
setFieldError("passphrase", t("password_generation_failed"));
|
||||
setFieldError(
|
||||
"passphrase",
|
||||
e instanceof Error &&
|
||||
e.message == deriveKeyInsufficientMemoryErrorMessage
|
||||
? t("password_generation_failed")
|
||||
: t("generic_error"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
stashedRedirect,
|
||||
unstashRedirect,
|
||||
} from "ente-accounts/services/redirect";
|
||||
import type {
|
||||
SRPAttributes,
|
||||
SRPSetupAttributes,
|
||||
import {
|
||||
configureSRP,
|
||||
getSRPAttributes,
|
||||
type SRPAttributes,
|
||||
type SRPSetupAttributes,
|
||||
} from "ente-accounts/services/srp";
|
||||
import { configureSRP, getSRPAttributes } from "ente-accounts/services/srp";
|
||||
import type { KeyAttributes, User } from "ente-accounts/services/user";
|
||||
import {
|
||||
putUserKeyAttributes,
|
||||
|
||||
@@ -6,8 +6,7 @@ import { getAuthToken } from "ente-base/token";
|
||||
import { getData } from "ente-shared/storage/localStorage";
|
||||
import { nullToUndefined } from "ente-utils/transform";
|
||||
import { z } from "zod/v4";
|
||||
import type { SRPAttributes } from "./srp";
|
||||
import { getSRPAttributes } from "./srp";
|
||||
import { getSRPAttributes, type SRPAttributes } from "./srp";
|
||||
import {
|
||||
ensureLocalUser,
|
||||
putUserKeyAttributes,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HttpStatusCode } from "axios";
|
||||
import type { KeyAttributes } from "ente-accounts/services/user";
|
||||
import { deriveSubKeyBytes, sharedCryptoWorker, toB64 } from "ente-base/crypto";
|
||||
import { ensureOk, publicRequestHeaders } from "ente-base/http";
|
||||
import log from "ente-base/log";
|
||||
@@ -9,7 +8,92 @@ import HTTPService from "ente-shared/network/HTTPService";
|
||||
import { getToken } from "ente-shared/storage/localStorage/helpers";
|
||||
import { SRP, SrpClient } from "fast-srp-hap";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { UpdatedKey, UserVerificationResponse } from "./user";
|
||||
import type { UserVerificationResponse } from "./user";
|
||||
|
||||
/**
|
||||
* The SRP attributes for a user.
|
||||
*
|
||||
* [Note: SRP]
|
||||
*
|
||||
* The SRP (Secure Remote Password) protocol is a modified Diffie-Hellman key
|
||||
* exchange that allows the remote to verify the user's possession of a
|
||||
* passphrase, and the user to ensure that remote is not being impersonated,
|
||||
* without the passphrase ever leaving the device.
|
||||
*
|
||||
* It is used as an alternative to email verification flows, though the user
|
||||
* also has an option to enable it in addition to SRP.
|
||||
*
|
||||
* For more about the what and why, see the announcement blog post
|
||||
* https://ente.io/blog/ente-adopts-secure-remote-passwords/
|
||||
*
|
||||
* Here we do not focus on the math (the above blog post links to reference
|
||||
* material, and there is also an RFC), but instead of the various bits of
|
||||
* information that get exchanged.
|
||||
*
|
||||
* Broadly, there are two scenarios: SRP setup, and SRP verification.
|
||||
*
|
||||
* [Note: SRP setup]
|
||||
*
|
||||
* During SRP setup, client generates
|
||||
*
|
||||
* 01. A SRP user ID (a new UUID-v4)
|
||||
* 02. A SRP password (deterministically derived from their regular KEK)
|
||||
* 03. A SRP salt (randomly generated)
|
||||
*
|
||||
* These 3 things are enough to create a SRP verifier and client
|
||||
*
|
||||
* 04. verifier = computeSRPVerifier({ userID, password, salt })
|
||||
* 05. client = new SRPClient({ userID, password, salt })
|
||||
*
|
||||
* The SRP client can just be thought of an ephemeral stateful mechanism to
|
||||
* avoid passing all the state accrued so far to each operation.
|
||||
*
|
||||
* The client (app) then starts the setup ceremony with remote:
|
||||
*
|
||||
* 06. Use SRP client to conjure secret `a` and use that to compute a public A
|
||||
* 07. Send { userID, salt, verifier, A } to remote ("/users/srp/setup")
|
||||
*
|
||||
* Remote then:
|
||||
*
|
||||
* 08. Generates a SRP serverKey (random)
|
||||
* 09. Saves { userID, serverKey, A } into SRP sessions table
|
||||
* 10. Creates server = new SRPServer({ verifier, serverKey })
|
||||
* 11. Uses SRP server to conjure secret `b` and use that to compute a public B
|
||||
* 12. Stashes { sessionID, userID, salt, verifier } into SRP setups table
|
||||
* 13. Returns { setupID, B } to client
|
||||
*
|
||||
* Client then
|
||||
*
|
||||
* 14. Tells its SRP client about B
|
||||
* 15. Computes SRP M1 (evidence message) using the SRP client
|
||||
* 16. Sends { setupID, M1 } to remote ("/users/srp/complete")
|
||||
*
|
||||
* Remote then
|
||||
*
|
||||
* 17. Uses setupID to read the stashed { sessionID, userID, salt, verifier }
|
||||
* 18. Uses sessionID to read { serverKey, A }
|
||||
* 19. Recreates server = new SRPServer({ verifier, serverKey }), sets server.A
|
||||
* 20. Verifies M1 using the SRP server, obtaining a SRP M2 (evidence message)
|
||||
* 21. Returns M2
|
||||
*
|
||||
* Client then
|
||||
*
|
||||
* 22. Verifies M2
|
||||
*
|
||||
* SRP setup is now complete.
|
||||
*
|
||||
* A similar flow is used when the user changes their passphrase. On passphrase
|
||||
* change, a new KEK is generated, thus the SRP password also changes, and so a
|
||||
* subset of the steps above are done to update both client and remote.
|
||||
*/
|
||||
export interface SRPAttributes {
|
||||
srpUserID: string;
|
||||
srpSalt: string;
|
||||
memLimit: number;
|
||||
opsLimit: number;
|
||||
kekSalt: string;
|
||||
isEmailMFAEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a "password" (which is really an arbitrary binary value, not human
|
||||
@@ -27,16 +111,7 @@ export const deriveSRPPassword = async (kek: string) => {
|
||||
return toB64(kekSubKeyBytes.slice(0, 16));
|
||||
};
|
||||
|
||||
export interface SRPAttributes {
|
||||
srpUserID: string;
|
||||
srpSalt: string;
|
||||
memLimit: number;
|
||||
opsLimit: number;
|
||||
kekSalt: string;
|
||||
isEmailMFAEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface GetSRPAttributesResponse {
|
||||
interface GetSRPAttributesResponse {
|
||||
attributes: SRPAttributes;
|
||||
}
|
||||
|
||||
@@ -47,29 +122,29 @@ export interface SRPSetupAttributes {
|
||||
loginSubKey: string;
|
||||
}
|
||||
|
||||
export interface SetupSRPRequest {
|
||||
interface SetupSRPRequest {
|
||||
srpUserID: string;
|
||||
srpSalt: string;
|
||||
srpVerifier: string;
|
||||
srpA: string;
|
||||
}
|
||||
|
||||
export interface SetupSRPResponse {
|
||||
interface SetupSRPResponse {
|
||||
setupID: string;
|
||||
srpB: string;
|
||||
}
|
||||
|
||||
export interface CompleteSRPSetupRequest {
|
||||
interface CompleteSRPSetupRequest {
|
||||
setupID: string;
|
||||
srpM1: string;
|
||||
}
|
||||
|
||||
export interface CompleteSRPSetupResponse {
|
||||
interface CompleteSRPSetupResponse {
|
||||
setupID: string;
|
||||
srpM2: string;
|
||||
}
|
||||
|
||||
export interface CreateSRPSessionResponse {
|
||||
interface CreateSRPSessionResponse {
|
||||
sessionID: string;
|
||||
srpB: string;
|
||||
}
|
||||
@@ -78,10 +153,18 @@ export interface SRPVerificationResponse extends UserVerificationResponse {
|
||||
srpM2: string;
|
||||
}
|
||||
|
||||
export interface UpdatedKeyAttr {
|
||||
kekSalt: string;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
opsLimit: number;
|
||||
memLimit: number;
|
||||
}
|
||||
|
||||
export interface UpdateSRPAndKeysRequest {
|
||||
srpM1: string;
|
||||
setupID: string;
|
||||
updatedKeyAttr: UpdatedKey;
|
||||
updatedKeyAttr: UpdatedKeyAttr;
|
||||
/**
|
||||
* If true (default), then all existing sessions for the user will be
|
||||
* invalidated.
|
||||
@@ -128,7 +211,7 @@ export const startSRPSetup = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const completeSRPSetup = async (
|
||||
const completeSRPSetup = async (
|
||||
token: string,
|
||||
completeSRPSetupRequest: CompleteSRPSetupRequest,
|
||||
) => {
|
||||
@@ -146,46 +229,6 @@ export const completeSRPSetup = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const createSRPSession = async (srpUserID: string, srpA: string) => {
|
||||
const res = await fetch(await apiURL("/users/srp/create-session"), {
|
||||
method: "POST",
|
||||
headers: publicRequestHeaders(),
|
||||
body: JSON.stringify({ srpUserID, srpA }),
|
||||
});
|
||||
ensureOk(res);
|
||||
const data = await res.json();
|
||||
// TODO: Use zod
|
||||
return data as CreateSRPSessionResponse;
|
||||
};
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSRPAndKeys = async (
|
||||
token: string,
|
||||
updateSRPAndKeyRequest: UpdateSRPAndKeysRequest,
|
||||
@@ -204,43 +247,89 @@ export const updateSRPAndKeys = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const configureSRP = async ({
|
||||
srpSalt,
|
||||
srpUserID,
|
||||
srpVerifier,
|
||||
loginSubKey,
|
||||
}: SRPSetupAttributes) => {
|
||||
try {
|
||||
const srpClient = await generateSRPClient(
|
||||
srpSalt,
|
||||
srpUserID,
|
||||
loginSubKey,
|
||||
);
|
||||
export const configureSRP = async (attr: SRPSetupAttributes) =>
|
||||
srpSetupOrReconfigure(attr, (cbAttr) =>
|
||||
completeSRPSetup(getToken(), cbAttr),
|
||||
);
|
||||
|
||||
const srpA = convertBufferToBase64(srpClient.computeA());
|
||||
export const srpSetupOrReconfigure = async (
|
||||
{ srpSalt, srpUserID, srpVerifier, loginSubKey }: SRPSetupAttributes,
|
||||
cb: ({
|
||||
setupID,
|
||||
srpM1,
|
||||
}: {
|
||||
setupID: string;
|
||||
srpM1: string;
|
||||
}) => Promise<{ srpM2: string }>,
|
||||
) => {
|
||||
const srpClient = await generateSRPClient(srpSalt, srpUserID, loginSubKey);
|
||||
|
||||
log.debug(() => `srp a: ${srpA}`);
|
||||
const token = getToken();
|
||||
const { setupID, srpB } = await startSRPSetup(token, {
|
||||
srpA,
|
||||
srpUserID,
|
||||
srpSalt,
|
||||
srpVerifier,
|
||||
});
|
||||
const srpA = convertBufferToBase64(srpClient.computeA());
|
||||
|
||||
srpClient.setB(convertBase64ToBuffer(srpB));
|
||||
const token = getToken();
|
||||
const { setupID, srpB } = await startSRPSetup(token, {
|
||||
srpA,
|
||||
srpUserID,
|
||||
srpSalt,
|
||||
srpVerifier,
|
||||
});
|
||||
|
||||
const srpM1 = convertBufferToBase64(srpClient.computeM1());
|
||||
srpClient.setB(convertBase64ToBuffer(srpB));
|
||||
|
||||
const { srpM2 } = await completeSRPSetup(token, { srpM1, setupID });
|
||||
const srpM1 = convertBufferToBase64(srpClient.computeM1());
|
||||
|
||||
srpClient.checkM2(convertBase64ToBuffer(srpM2));
|
||||
} catch (e) {
|
||||
log.error("Failed to configure SRP", e);
|
||||
throw e;
|
||||
}
|
||||
const { srpM2 } = await cb({ srpM1, setupID });
|
||||
|
||||
srpClient.checkM2(convertBase64ToBuffer(srpM2));
|
||||
};
|
||||
|
||||
export const generateSRPClient = async (
|
||||
srpSalt: string,
|
||||
srpUserID: string,
|
||||
loginSubKey: string,
|
||||
) => {
|
||||
return new Promise<SrpClient>((resolve, reject) => {
|
||||
SRP.genKey(function (err, secret1) {
|
||||
try {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
if (!secret1) {
|
||||
throw Error("secret1 gen failed");
|
||||
}
|
||||
const srpClient = new SrpClient(
|
||||
SRP.params["4096"],
|
||||
convertBase64ToBuffer(srpSalt),
|
||||
Buffer.from(srpUserID),
|
||||
convertBase64ToBuffer(loginSubKey),
|
||||
secret1,
|
||||
false,
|
||||
);
|
||||
|
||||
resolve(srpClient);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const convertBufferToBase64 = (buffer: Buffer) => {
|
||||
return buffer.toString("base64");
|
||||
};
|
||||
|
||||
export const convertBase64ToBuffer = (base64: string) => {
|
||||
return Buffer.from(base64, "base64");
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param loginSubKey The user's SRP password (autogenerated, derived
|
||||
* deterministically from their kek by {@link deriveSRPPassword}).
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const generateSRPSetupAttributes = async (
|
||||
loginSubKey: string,
|
||||
): Promise<SRPSetupAttributes> => {
|
||||
@@ -306,97 +395,42 @@ export const loginViaSRP = async (
|
||||
}
|
||||
};
|
||||
|
||||
// ====================
|
||||
// HELPERS
|
||||
// ====================
|
||||
|
||||
export const generateSRPClient = async (
|
||||
srpSalt: string,
|
||||
srpUserID: string,
|
||||
loginSubKey: string,
|
||||
) => {
|
||||
return new Promise<SrpClient>((resolve, reject) => {
|
||||
SRP.genKey(function (err, secret1) {
|
||||
try {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
if (!secret1) {
|
||||
throw Error("secret1 gen failed");
|
||||
}
|
||||
const srpClient = new SrpClient(
|
||||
SRP.params["4096"],
|
||||
convertBase64ToBuffer(srpSalt),
|
||||
Buffer.from(srpUserID),
|
||||
convertBase64ToBuffer(loginSubKey),
|
||||
secret1,
|
||||
false,
|
||||
);
|
||||
|
||||
resolve(srpClient);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
export const createSRPSession = async (srpUserID: string, srpA: string) => {
|
||||
const res = await fetch(await apiURL("/users/srp/create-session"), {
|
||||
method: "POST",
|
||||
headers: publicRequestHeaders(),
|
||||
body: JSON.stringify({ srpUserID, srpA }),
|
||||
});
|
||||
ensureOk(res);
|
||||
const data = await res.json();
|
||||
// TODO: Use zod
|
||||
return data as CreateSRPSessionResponse;
|
||||
};
|
||||
|
||||
export const convertBufferToBase64 = (buffer: Buffer) => {
|
||||
return buffer.toString("base64");
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const convertBase64ToBuffer = (base64: string) => {
|
||||
return Buffer.from(base64, "base64");
|
||||
};
|
||||
|
||||
export async function generateKeyAndSRPAttributes(
|
||||
passphrase: string,
|
||||
): Promise<{
|
||||
keyAttributes: KeyAttributes;
|
||||
masterKey: string;
|
||||
srpSetupAttributes: SRPSetupAttributes;
|
||||
}> {
|
||||
const cryptoWorker = await sharedCryptoWorker();
|
||||
const masterKey = await cryptoWorker.generateKey();
|
||||
const recoveryKey = await cryptoWorker.generateKey();
|
||||
const kek = await cryptoWorker.deriveSensitiveKey(passphrase);
|
||||
|
||||
const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } =
|
||||
await cryptoWorker.encryptBox(masterKey, kek.key);
|
||||
const {
|
||||
encryptedData: masterKeyEncryptedWithRecoveryKey,
|
||||
nonce: masterKeyDecryptionNonce,
|
||||
} = await cryptoWorker.encryptBox(masterKey, recoveryKey);
|
||||
const {
|
||||
encryptedData: recoveryKeyEncryptedWithMasterKey,
|
||||
nonce: recoveryKeyDecryptionNonce,
|
||||
} = await cryptoWorker.encryptBox(recoveryKey, masterKey);
|
||||
|
||||
const keyPair = await cryptoWorker.generateKeyPair();
|
||||
const {
|
||||
encryptedData: encryptedSecretKey,
|
||||
nonce: secretKeyDecryptionNonce,
|
||||
} = await cryptoWorker.encryptBox(keyPair.privateKey, masterKey);
|
||||
|
||||
const loginSubKey = await deriveSRPPassword(kek.key);
|
||||
|
||||
const srpSetupAttributes = await generateSRPSetupAttributes(loginSubKey);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
kekSalt: kek.salt,
|
||||
opsLimit: kek.opsLimit,
|
||||
memLimit: kek.memLimit,
|
||||
publicKey: keyPair.publicKey,
|
||||
encryptedSecretKey,
|
||||
secretKeyDecryptionNonce,
|
||||
masterKeyEncryptedWithRecoveryKey,
|
||||
masterKeyDecryptionNonce,
|
||||
recoveryKeyEncryptedWithMasterKey,
|
||||
recoveryKeyDecryptionNonce,
|
||||
};
|
||||
|
||||
return { keyAttributes, masterKey, srpSetupAttributes };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { decryptBox, encryptBox } from "ente-base/crypto";
|
||||
import {
|
||||
decryptBox,
|
||||
deriveSensitiveKey,
|
||||
encryptBox,
|
||||
generateKey,
|
||||
generateKeyPair,
|
||||
} from "ente-base/crypto";
|
||||
import {
|
||||
authenticatedRequestHeaders,
|
||||
ensureOk,
|
||||
@@ -43,12 +49,16 @@ const LocalUser = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
/** Locally available data for the logged in user */
|
||||
/**
|
||||
* The local storage data about the user after they've logged in.
|
||||
*/
|
||||
export type LocalUser = z.infer<typeof LocalUser>;
|
||||
|
||||
/**
|
||||
* The local storage data about the user before login or signup is complete.
|
||||
*
|
||||
* [Note: Partial local user]
|
||||
*
|
||||
* During login or signup, the user object exists in various partial states in
|
||||
* local storage.
|
||||
*
|
||||
@@ -57,19 +67,41 @@ export type LocalUser = z.infer<typeof LocalUser>;
|
||||
* - When the user enters their email, the email property of the stored object
|
||||
* is set, but nothing else.
|
||||
*
|
||||
* - If they have second factor verification set, then after entering their
|
||||
* password {@link isTwoFactorEnabled} and {@link twoFactorSessionID} will
|
||||
* also get filled in.
|
||||
* - After they verify their password, we have two cases: if second factor
|
||||
* verification is not set, and when it is set.
|
||||
*
|
||||
* - Once they verify their TOTP based second factor, their {@link id} and
|
||||
* {@link encryptedToken} will also get filled in.
|
||||
* - If second factor verification is not set, then after verifying their
|
||||
* password their {@link id} and {@link encryptedToken} will get filled in,
|
||||
* and {@link isTwoFactorEnabled} will be set to false.
|
||||
*
|
||||
* - If they have second factor verification set, then after verifying their
|
||||
* password {@link isTwoFactorEnabled} and {@link twoFactorSessionID} will
|
||||
* also get filled in. Once they verify their TOTP based second factor, their
|
||||
* {@link id} and {@link encryptedToken} will also get filled in.
|
||||
*
|
||||
* So while the underlying storage is the same, we offer two APIs for code to
|
||||
* obtain the user:
|
||||
*
|
||||
* - Before login is complete, or when it is unknown if login is complete or
|
||||
* not, then {@link partialLocalUser} can be used to obtain a
|
||||
* {@link LocalUser} with all of its properties set to be optional.
|
||||
*
|
||||
* - When we know that the login has completed, we can use either
|
||||
* {@link localUser} (which returns `undefined` if our presumption is false)
|
||||
* or {@link ensureLocalUser} (which throws if our presumption is false) to
|
||||
* obtain an object with all the properties expected to be present for a
|
||||
* locally persisted user set to be required.
|
||||
*/
|
||||
// TODO: Start using me.
|
||||
export const PreLoginLocalUser = LocalUser.partial();
|
||||
export const partialLocalUser = (): Partial<LocalUser> | undefined => {
|
||||
// TODO: duplicate of getData("user")
|
||||
const s = localStorage.getItem("user");
|
||||
if (!s) return undefined;
|
||||
return LocalUser.partial().parse(JSON.parse(s));
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the logged-in user, if someone is indeed logged in. Otherwise return
|
||||
* `undefined` (TODO: That's not what it is doing...).
|
||||
* `undefined`.
|
||||
*
|
||||
* The user's data is stored in the browser's localStorage. Thus, this function
|
||||
* only works from the main thread, not from web workers (local storage is not
|
||||
@@ -79,7 +111,8 @@ export const localUser = (): LocalUser | undefined => {
|
||||
// TODO: duplicate of getData("user")
|
||||
const s = localStorage.getItem("user");
|
||||
if (!s) return undefined;
|
||||
return LocalUser.parse(JSON.parse(s));
|
||||
const { success, data } = LocalUser.safeParse(JSON.parse(s));
|
||||
return success ? data : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -288,6 +321,61 @@ export const ensureSavedKeyAttributes = (): KeyAttributes =>
|
||||
export const saveKeyAttributes = (keyAttributes: KeyAttributes) =>
|
||||
localStorage.setItem("keyAttributes", JSON.stringify(keyAttributes));
|
||||
|
||||
/**
|
||||
* Generate a new set of key attributes.
|
||||
*
|
||||
* @param passphrase The passphrase to use for deriving the key encryption key.
|
||||
*
|
||||
* @returns a newly generated master key (base64 string), kek (base64 string)
|
||||
* and the key attributes associated with the combination.
|
||||
*/
|
||||
export async function generateKeysAndAttributes(
|
||||
passphrase: string,
|
||||
): Promise<{ masterKey: string; kek: string; keyAttributes: KeyAttributes }> {
|
||||
const masterKey = await generateKey();
|
||||
const recoveryKey = await generateKey();
|
||||
const {
|
||||
key: kek,
|
||||
salt: kekSalt,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
} = await deriveSensitiveKey(passphrase);
|
||||
|
||||
const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } =
|
||||
await encryptBox(masterKey, kek);
|
||||
const {
|
||||
encryptedData: masterKeyEncryptedWithRecoveryKey,
|
||||
nonce: masterKeyDecryptionNonce,
|
||||
} = await encryptBox(masterKey, recoveryKey);
|
||||
const {
|
||||
encryptedData: recoveryKeyEncryptedWithMasterKey,
|
||||
nonce: recoveryKeyDecryptionNonce,
|
||||
} = await encryptBox(recoveryKey, masterKey);
|
||||
|
||||
const keyPair = await generateKeyPair();
|
||||
const {
|
||||
encryptedData: encryptedSecretKey,
|
||||
nonce: secretKeyDecryptionNonce,
|
||||
} = await encryptBox(keyPair.privateKey, masterKey);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
kekSalt,
|
||||
opsLimit,
|
||||
memLimit,
|
||||
publicKey: keyPair.publicKey,
|
||||
encryptedSecretKey,
|
||||
secretKeyDecryptionNonce,
|
||||
masterKeyEncryptedWithRecoveryKey,
|
||||
masterKeyDecryptionNonce,
|
||||
recoveryKeyEncryptedWithMasterKey,
|
||||
recoveryKeyDecryptionNonce,
|
||||
};
|
||||
|
||||
return { masterKey, kek, keyAttributes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or set the user's {@link KeyAttributes} on remote.
|
||||
*/
|
||||
@@ -459,14 +547,6 @@ export const remoteLogoutIfNeeded = async () => {
|
||||
ensureOk(res);
|
||||
};
|
||||
|
||||
export interface UpdatedKey {
|
||||
kekSalt: string;
|
||||
encryptedKey: string;
|
||||
keyDecryptionNonce: string;
|
||||
memLimit: number;
|
||||
opsLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the email associated with the user's account on remote.
|
||||
*
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { mergeUint8Arrays } from "ente-utils/array";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import {
|
||||
deriveKeyInsufficientMemoryErrorMessage,
|
||||
streamEncryptionChunkSize,
|
||||
type BytesOrB64,
|
||||
type DerivedKey,
|
||||
@@ -837,7 +838,7 @@ export const deriveSensitiveKey = async (
|
||||
memLimit /= 2;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to derive key: memory limit exceeded");
|
||||
throw new Error(deriveKeyInsufficientMemoryErrorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @file types shared between the public API (main thread) and implementation
|
||||
* (worker thread). Also some constants, but no code.
|
||||
*/
|
||||
import { type StateAddress } from "libsodium-wrappers-sumo";
|
||||
|
||||
/**
|
||||
@@ -19,6 +23,18 @@ 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.
|
||||
*
|
||||
* 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
|
||||
* older mobile devices with too little RAM.
|
||||
*/
|
||||
export const deriveKeyInsufficientMemoryErrorMessage =
|
||||
"Failed to derive key (insufficient memory)";
|
||||
|
||||
/**
|
||||
* Data provided either as bytes ({@link Uint8Array}) or their base64 string
|
||||
* representation.
|
||||
|
||||
@@ -141,6 +141,8 @@ export const isHTTP401Error = (e: unknown) =>
|
||||
* Return `true` if this is an error because of a HTTP failure response returned
|
||||
* by museum with the given "code" and HTTP status.
|
||||
*
|
||||
* > The function is async because it needs to parse the payload.
|
||||
*
|
||||
* For some known set of errors, museum returns a payload of the form
|
||||
*
|
||||
* {"code":"USER_NOT_REGISTERED","message":"User is not registered"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Storage (in-memory, local, remote) and update of various settings.
|
||||
*/
|
||||
|
||||
import { localUser } from "ente-accounts/services/user";
|
||||
import { partialLocalUser } from "ente-accounts/services/user";
|
||||
import { isDevBuild } from "ente-base/env";
|
||||
import log from "ente-base/log";
|
||||
import { updateShouldDisableCFUploadProxy } from "ente-gallery/services/upload";
|
||||
@@ -188,16 +188,16 @@ const setSettingsSnapshot = (snapshot: Settings) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Return `true` if this is a development build, and the current user is marked
|
||||
* as an "development" user.
|
||||
* Return `true` if this is a development build, and the current user (if any)
|
||||
* is marked as an "development" user.
|
||||
*
|
||||
* Emails that end in "@ente.io" are considered as dev users.
|
||||
*/
|
||||
export const isDevBuildAndUser = () => isDevBuild && isDevUserViaEmail();
|
||||
|
||||
const isDevUserViaEmail = () => {
|
||||
const user = localUser();
|
||||
return !!user?.email.endsWith("@ente.io");
|
||||
const user = partialLocalUser();
|
||||
return !!user?.email?.endsWith("@ente.io");
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user