[web] SRP code refactoring (#6232)

This commit is contained in:
Manav Rathi
2025-06-10 10:26:36 +05:30
committed by GitHub
12 changed files with 446 additions and 284 deletions

View File

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

View File

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

View File

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

View File

@@ -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"),
);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");
};
/**