[web] General improvements to code dealing with keys (#6155)

(Non functional)
This commit is contained in:
Manav Rathi
2025-06-03 17:28:26 +05:30
committed by GitHub
16 changed files with 217 additions and 189 deletions

View File

@@ -70,7 +70,7 @@ const createCollection = async (
const cryptoWorker = await sharedCryptoWorker();
const encryptionKey = await getActualKey();
const token = getToken();
const collectionKey = await cryptoWorker.generateEncryptionKey();
const collectionKey = await cryptoWorker.generateKey();
const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } =
await cryptoWorker.encryptToB64(collectionKey, encryptionKey);
const { encryptedData: encryptedName, nonce: nameDecryptionNonce } =

View File

@@ -205,6 +205,9 @@ via [@fontsource-variable/inter](https://fontsource.org/fonts/inter/install).
[pDebounce](https://github.com/sindresorhus/p-debounce) are used for
debouncing operations (See also: `[Note: Throttle and debounce]`).
- [bip39](https://github.com/bitcoinjs/bip39) is used for generating the 24-word
recovery key mnemonic.
- [zxcvbn](https://github.com/dropbox/zxcvbn) is used for password strength
estimation.

View File

@@ -6,7 +6,6 @@ import {
Stack,
Typography,
} from "@mui/material";
import * as bip39 from "bip39";
import { type MiniDialogAttributes } from "ente-base/components/MiniDialog";
import { SpacedRow } from "ente-base/components/containers";
import { DialogCloseIconButton } from "ente-base/components/mui/DialogCloseIconButton";
@@ -16,14 +15,14 @@ import { useIsSmallWidth } from "ente-base/components/utils/hooks";
import type { ModalVisibilityProps } from "ente-base/components/utils/modal";
import log from "ente-base/log";
import { downloadString } from "ente-base/utils/web";
import { getRecoveryKey } from "ente-shared/crypto/helpers";
import { t } from "i18next";
import { useCallback, useEffect, useState } from "react";
import {
getUserRecoveryKeyB64,
recoveryKeyB64ToMnemonic,
} from "../services/recovery-key";
import { CodeBlock } from "./CodeBlock";
// mobile client library only supports english.
bip39.setDefaultWordlist("english");
type RecoveryKeyProps = ModalVisibilityProps & {
showMiniDialog: (attributes: MiniDialogAttributes) => void;
};
@@ -116,7 +115,7 @@ export const RecoveryKey: React.FC<RecoveryKeyProps> = ({
};
const getRecoveryKeyMnemonic = async () =>
bip39.entropyToMnemonic(await getRecoveryKey());
recoveryKeyB64ToMnemonic(await getUserRecoveryKeyB64());
const downloadRecoveryKeyMnemonic = (key: string) =>
downloadString(key, "ente-recovery-key.txt");

View File

@@ -3,11 +3,12 @@ import {
AccountsPageFooter,
AccountsPageTitle,
} from "ente-accounts/components/layouts/centered-paper";
import { recoveryKeyB64FromMnemonic } from "ente-accounts/services/recovery-key";
import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect";
import { sendOTT } from "ente-accounts/services/user";
import { LinkButton } from "ente-base/components/LinkButton";
import { useBaseContext } from "ente-base/context";
import { sharedCryptoWorker } from "ente-base/crypto";
import { decryptBoxB64 } from "ente-base/crypto";
import log from "ente-base/log";
import SingleInputForm, {
type SingleInputFormProps,
@@ -23,11 +24,6 @@ import { t } from "i18next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bip39 = require("bip39");
// mobile client library only supports english.
bip39.setDefaultWordlist("english");
const Page: React.FC = () => {
const { showMiniDialog } = useBaseContext();
@@ -65,25 +61,13 @@ const Page: React.FC = () => {
setFieldError,
) => {
try {
recoveryKey = recoveryKey
.trim()
.split(" ")
.map((part) => part.trim())
.filter((part) => !!part)
.join(" ");
// check if user is entering mnemonic recovery key
if (recoveryKey.indexOf(" ") > 0) {
if (recoveryKey.split(" ").length !== 24) {
throw new Error("recovery code should have 24 words");
}
recoveryKey = bip39.mnemonicToEntropy(recoveryKey);
}
const cryptoWorker = await sharedCryptoWorker();
const keyAttr = keyAttributes!;
const masterKey = await cryptoWorker.decryptB64(
keyAttr.masterKeyEncryptedWithRecoveryKey!,
keyAttr.masterKeyDecryptionNonce!,
await cryptoWorker.fromHex(recoveryKey),
const masterKey = await decryptBoxB64(
{
encryptedData: keyAttr.masterKeyEncryptedWithRecoveryKey!,
nonce: keyAttr.masterKeyDecryptionNonce!,
},
await recoveryKeyB64FromMnemonic(recoveryKey),
);
await saveKeyInSessionStore("encryptionKey", masterKey);
await decryptAndStoreToken(keyAttr, masterKey);

View File

@@ -5,6 +5,7 @@ import {
AccountsPageFooter,
AccountsPageTitle,
} from "ente-accounts/components/layouts/centered-paper";
import { recoveryKeyB64FromMnemonic } from "ente-accounts/services/recovery-key";
import {
recoverTwoFactor,
removeTwoFactor,
@@ -13,7 +14,7 @@ import {
import { LinkButton } from "ente-base/components/LinkButton";
import type { MiniDialogAttributes } from "ente-base/components/MiniDialog";
import { useBaseContext } from "ente-base/context";
import { sharedCryptoWorker } from "ente-base/crypto";
import { decryptBoxB64 } from "ente-base/crypto";
import type { B64EncryptionResult } from "ente-base/crypto/libsodium";
import log from "ente-base/log";
import SingleInputForm, {
@@ -26,11 +27,6 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bip39 = require("bip39");
// mobile client library only supports english.
bip39.setDefaultWordlist("english");
export interface RecoverPageProps {
twoFactorType: TwoFactorType;
}
@@ -95,25 +91,10 @@ const Page: React.FC<RecoverPageProps> = ({ twoFactorType }) => {
setFieldError,
) => {
try {
recoveryKey = recoveryKey
.trim()
.split(" ")
.map((part) => part.trim())
.filter((part) => !!part)
.join(" ");
// check if user is entering mnemonic recovery key
if (recoveryKey.indexOf(" ") > 0) {
if (recoveryKey.split(" ").length !== 24) {
throw new Error("recovery code should have 24 words");
}
recoveryKey = bip39.mnemonicToEntropy(recoveryKey);
}
const cryptoWorker = await sharedCryptoWorker();
const { encryptedData, nonce } = encryptedTwoFactorSecret!;
const twoFactorSecret = await cryptoWorker.decryptB64(
encryptedData,
nonce,
await cryptoWorker.fromHex(recoveryKey),
const twoFactorSecret = await decryptBoxB64(
{ encryptedData, nonce },
await recoveryKeyB64FromMnemonic(recoveryKey),
);
const resp = await removeTwoFactor(
sessionID!,

View File

@@ -8,11 +8,12 @@ import { CenteredFill } from "ente-base/components/containers";
import { LinkButton } from "ente-base/components/LinkButton";
import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator";
import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton";
import { encryptWithRecoveryKey } from "ente-shared/crypto/helpers";
import { encryptBoxB64 } from "ente-base/crypto";
import { getData, setLSUser } from "ente-shared/storage/localStorage";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { getUserRecoveryKeyB64 } from "../../services/recovery-key";
const Page: React.FC = () => {
const [twoFactorSecret, setTwoFactorSecret] = useState<
@@ -26,14 +27,14 @@ const Page: React.FC = () => {
}, []);
const handleSubmit = async (otp: string) => {
const {
encryptedData: encryptedTwoFactorSecret,
nonce: twoFactorSecretDecryptionNonce,
} = await encryptWithRecoveryKey(twoFactorSecret!.secretCode);
const box = await encryptBoxB64(
twoFactorSecret!.secretCode,
await getUserRecoveryKeyB64(),
);
await enableTwoFactor({
code: otp,
encryptedTwoFactorSecret,
twoFactorSecretDecryptionNonce,
encryptedTwoFactorSecret: box.encryptedData,
twoFactorSecretDecryptionNonce: box.nonce,
});
await setLSUser({ ...getData("user"), isTwoFactorEnabled: true });
await router.push(appHomeRoute);

View File

@@ -1,10 +1,6 @@
import { TwoFactorAuthorizationResponse } from "ente-accounts/services/user";
import { clientPackageName, isDesktop } from "ente-base/app";
import { sharedCryptoWorker } from "ente-base/crypto";
import {
encryptToB64,
generateEncryptionKey,
} from "ente-base/crypto/libsodium";
import { encryptBoxB64, generateKey } from "ente-base/crypto";
import {
authenticatedRequestHeaders,
ensureOk,
@@ -13,11 +9,11 @@ import {
} from "ente-base/http";
import log from "ente-base/log";
import { apiURL } from "ente-base/origins";
import { getRecoveryKey } from "ente-shared/crypto/helpers";
import HTTPService from "ente-shared/network/HTTPService";
import { getData, setData, setLSUser } from "ente-shared/storage/localStorage";
import { getToken } from "ente-shared/storage/localStorage/helpers";
import { z } from "zod";
import { getUserRecoveryKeyB64 } from "./recovery-key";
import { unstashRedirect } from "./redirect";
/**
@@ -111,20 +107,15 @@ export const openAccountsManagePasskeysPage = async () => {
if (!recoveryEnabled) {
// If not, enable it for them by creating the necessary recovery
// information to prevent them from getting locked out.
const recoveryKey = await getRecoveryKey();
const resetSecret = await generateEncryptionKey();
const cryptoWorker = await sharedCryptoWorker();
const encryptionResult = await encryptToB64(
const resetSecret = await generateKey();
const box = await encryptBoxB64(
resetSecret,
await cryptoWorker.fromHex(recoveryKey),
await getUserRecoveryKeyB64(),
);
await configurePasskeyRecovery(
resetSecret,
encryptionResult.encryptedData,
encryptionResult.nonce,
box.encryptedData,
box.nonce,
);
}

View File

@@ -0,0 +1,122 @@
import * as bip39 from "bip39";
import {
decryptBoxB64,
fromHex,
sharedCryptoWorker,
toHex,
} from "ente-base/crypto";
import { masterKeyFromSession } from "ente-base/session";
import { getData, setData } from "ente-shared/storage/localStorage";
import type { KeyAttributes } from "ente-shared/user/types";
import { putUserRecoveryKeyAttributes } from "./user";
// Mobile client library only supports English.
bip39.setDefaultWordlist("english");
/**
* Convert the provided BIP-39 mnemonic string into its base64 representation.
*
* @param recoveryKeyMnemonicOrHex The BIP-39 mnemonic (24 word) string
* representing the recovery key. For legacy compatibility, the function also
* works if provided the hex representation of the recovery key.
*
* @returns A base64 string representing the underlying bytes of the recovery key.
*/
export const recoveryKeyB64FromMnemonic = (
recoveryKeyMnemonicOrHex: string,
) => {
const trimmedInput = recoveryKeyMnemonicOrHex
.trim()
.split(" ")
.map((part) => part.trim())
.filter((part) => !!part)
.join(" ");
let recoveryKeyHex: string;
// Check if user is entering mnemonic recovery key.
if (trimmedInput.indexOf(" ") > 0) {
if (trimmedInput.split(" ").length != 24) {
throw new Error("recovery code should have 24 words");
}
recoveryKeyHex = bip39.mnemonicToEntropy(trimmedInput);
} else {
recoveryKeyHex = trimmedInput;
}
return fromHex(recoveryKeyHex);
};
/**
* Convert the provided base64 encoded recovery key into its BIP-39 mnemonic.
*
* @param recoveryKeyB64 The base64 encoded recovery key to mnemonize.
*
* @returns A 24-word mnemonic that serves as the user visible recovery key.
*/
export const recoveryKeyB64ToMnemonic = async (recoveryKeyB64: string) =>
bip39.entropyToMnemonic(await toHex(recoveryKeyB64));
/**
* Return the (decrypted) recovery key of the logged in user.
*
* @returns The user's base64 encoded recovery key.
*/
export const getUserRecoveryKeyB64 = async () => {
const masterKey = await masterKeyFromSession();
const keyAttributes: KeyAttributes = getData("keyAttributes");
const { recoveryKeyEncryptedWithMasterKey, recoveryKeyDecryptionNonce } =
keyAttributes;
if (recoveryKeyEncryptedWithMasterKey && recoveryKeyDecryptionNonce) {
return decryptBoxB64(
{
encryptedData: recoveryKeyEncryptedWithMasterKey,
nonce: recoveryKeyDecryptionNonce,
},
masterKey,
);
} else {
return createNewRecoveryKey(masterKey);
}
};
/**
* Generate a new recovery key, tell remote about it, update our local state,
* and then return it.
*
* This function will be used only for legacy users for whom we did not generate
* recovery keys during sign up.
*
* @returns a new base64 encoded recovery key.
*/
const createNewRecoveryKey = async (masterKey: Uint8Array) => {
const existingAttributes = getData("keyAttributes");
const cryptoWorker = await sharedCryptoWorker();
const recoveryKey = await cryptoWorker.generateKey();
const encryptedMasterKey = await cryptoWorker.encryptBoxB64(
masterKey,
recoveryKey,
);
const encryptedRecoveryKey = await cryptoWorker.encryptBoxB64(
recoveryKey,
masterKey,
);
const recoveryKeyAttributes = {
masterKeyEncryptedWithRecoveryKey: encryptedMasterKey.encryptedData,
masterKeyDecryptionNonce: encryptedMasterKey.nonce,
recoveryKeyEncryptedWithMasterKey: encryptedRecoveryKey.encryptedData,
recoveryKeyDecryptionNonce: encryptedRecoveryKey.nonce,
};
await putUserRecoveryKeyAttributes(recoveryKeyAttributes);
setData("keyAttributes", {
...existingAttributes,
...recoveryKeyAttributes,
});
return recoveryKey;
};

View File

@@ -171,8 +171,8 @@ export async function generateKeyAndSRPAttributes(
srpSetupAttributes: SRPSetupAttributes;
}> {
const cryptoWorker = await sharedCryptoWorker();
const masterKey = await cryptoWorker.generateEncryptionKey();
const recoveryKey = await cryptoWorker.generateEncryptionKey();
const masterKey = await cryptoWorker.generateKey();
const recoveryKey = await cryptoWorker.generateKey();
const kekSalt = await cryptoWorker.generateSaltToDeriveKey();
const kek = await cryptoWorker.deriveSensitiveKey(passphrase, kekSalt);

View File

@@ -61,13 +61,6 @@ export interface UpdatedKey {
opsLimit: number;
}
export interface RecoveryKey {
masterKeyEncryptedWithRecoveryKey: string;
masterKeyDecryptionNonce: string;
recoveryKeyEncryptedWithMasterKey: string;
recoveryKeyDecryptionNonce: string;
}
/**
* 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
@@ -311,10 +304,28 @@ export const enableTwoFactor = async (req: EnableTwoFactorRequest) =>
}),
);
export const setRecoveryKey = async (token: string, recoveryKey: RecoveryKey) =>
HTTPService.put(
await apiURL("/users/recovery-key"),
recoveryKey,
undefined,
{ "X-Auth-Token": token },
export interface RecoveryKeyAttributes {
masterKeyEncryptedWithRecoveryKey: string;
masterKeyDecryptionNonce: string;
recoveryKeyEncryptedWithMasterKey: string;
recoveryKeyDecryptionNonce: string;
}
/**
* Update the encrypted recovery key attributes for the logged in user.
*
* In practice, this is not expected to be called and is meant as a rare
* fallback for very old accounts created prior to recovery key related
* attributes being assigned on account setup. Even for these, it'll be called
* only once.
*/
export const putUserRecoveryKeyAttributes = async (
recoveryKeyAttributes: RecoveryKeyAttributes,
) =>
ensureOk(
await fetch(await apiURL("/users/recovery-key"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(recoveryKeyAttributes),
}),
);

View File

@@ -12,7 +12,7 @@ export const _toHex = libsodium.toHex;
export const _fromHex = libsodium.fromHex;
export const _generateBoxKey = libsodium.generateBoxKey;
export const _generateKey = libsodium.generateKey;
export const _generateBlobOrStreamKey = libsodium.generateBlobOrStreamKey;

View File

@@ -137,13 +137,15 @@ export const fromHex = (hexString: string) =>
: sharedWorker().then((w) => w.fromHex(hexString));
/**
* Return a new randomly generated 256-bit key (as a base64 string) suitable for
* use with the *Box encryption functions.
* Return a new randomly generated 256-bit key (as a base64 string).
*
* The returned key is suitable for use with the *Box encryption functions, and
* as a general encryption key (e.g. as the user's master key or recovery key).
*/
export const generateBoxKey = () =>
export const generateKey = () =>
inWorker()
? ei._generateBoxKey()
: sharedWorker().then((w) => w.generateBoxKey());
? ei._generateKey()
: sharedWorker().then((w) => w.generateKey());
/**
* Return a new randomly generated 256-bit key (as a base64 string) suitable for

View File

@@ -130,13 +130,29 @@ const bytes = async (bob: BytesOrB64) =>
typeof bob == "string" ? fromB64(bob) : bob;
/**
* Generate a new key for use with the *Box encryption functions, and return its
* base64 string representation.
* Generate a new randomly generated 256-bit key for use as a general encryption
* key and return its base64 string representation.
*
* This returns a new randomly generated 256-bit key suitable for being used
* with libsodium's secretbox APIs.
* From the architecture docs:
*
* > [`crypto_secretbox_keygen`](https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes)
* > is used to generate all random keys within the application. Your
* > `masterKey`, `recoveryKey`, `collectionKey`, `fileKey` are all 256-bit keys
* > generated using this API.
*
* {@link generateKey} can be contrasted with {@link generateBlobOrStreamKey}
* and can be thought of as a hypothetical "generateBoxKey". That is, the key
* returned by this function is suitable for being used with the *Box encryption
* functions (which eventually delegate to the libsodium's secretbox APIs).
*
* While this is a reasonable semantic distinction, in terms of implementation
* there is no difference: currently both {@link generateKey} (or the
* hypothetical "generateBoxKey") and {@link generateBlobOrStreamKey} produce
* 256-bits of entropy that does not have any ties to a particular algorithm.
*
* @returns A new randomly generated 256-bit key.
*/
export const generateBoxKey = async () => {
export const generateKey = async () => {
await sodium.ready;
return toB64(sodium.crypto_secretbox_keygen());
};
@@ -863,11 +879,6 @@ export const deriveInteractiveKey = async (
return { key, opsLimit, memLimit };
};
export async function generateEncryptionKey() {
await sodium.ready;
return await toB64(sodium.crypto_kdf_keygen());
}
export async function generateSaltToDeriveKey() {
await sodium.ready;
return await toB64(sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES));

View File

@@ -18,7 +18,7 @@ export class CryptoWorker {
fromB64 = ei._fromB64;
toHex = ei._toHex;
fromHex = ei._fromHex;
generateBoxKey = ei._generateBoxKey;
generateKey = ei._generateKey;
generateBlobOrStreamKey = ei._generateBlobOrStreamKey;
encryptBoxB64 = ei._encryptBoxB64;
encryptThumbnail = ei._encryptThumbnail;
@@ -70,10 +70,6 @@ export class CryptoWorker {
return libsodium.encryptUTF8(data, key);
}
async generateEncryptionKey() {
return libsodium.generateEncryptionKey();
}
async generateSaltToDeriveKey() {
return libsodium.generateSaltToDeriveKey();
}

View File

@@ -10,10 +10,10 @@
* is a needed for fast refresh to work.
*/
import { getUserRecoveryKeyB64 } from "ente-accounts/services/recovery-key";
import log from "ente-base/log";
import type { Collection } from "ente-media/collection";
import type { FamilyData } from "ente-new/photos/services/user-details";
import { getRecoveryKey } from "ente-shared/crypto/helpers";
import type { User } from "ente-shared/user/types";
/**
@@ -28,7 +28,7 @@ import type { User } from "ente-shared/user/types";
*/
export const validateKey = async () => {
try {
await getRecoveryKey();
await getUserRecoveryKeyB64();
return true;
} catch (e) {
log.warn("Failed to validate key" /*, caller will logout */, e);

View File

@@ -1,11 +1,7 @@
import { setRecoveryKey } from "ente-accounts/services/user";
import { sharedCryptoWorker } from "ente-base/crypto";
import log from "ente-base/log";
import { masterKeyFromSession } from "ente-base/session";
import { getData, setData, setLSUser } from "ente-shared/storage/localStorage";
import { getToken } from "ente-shared/storage/localStorage/helpers";
import { type SessionKey, setKey } from "ente-shared/storage/sessionStorage";
import { getActualKey } from "ente-shared/user";
import type { KeyAttributes } from "ente-shared/user/types";
const LOGIN_SUB_KEY_LENGTH = 32;
@@ -108,75 +104,6 @@ export const saveKeyInSessionStore = async (
}
};
export const encryptWithRecoveryKey = async (data: string) => {
const cryptoWorker = await sharedCryptoWorker();
const hexRecoveryKey = await getRecoveryKey();
const recoveryKey = await cryptoWorker.fromHex(hexRecoveryKey);
return cryptoWorker.encryptBoxB64(data, recoveryKey);
};
export const getRecoveryKey = async () => {
try {
const cryptoWorker = await sharedCryptoWorker();
const keyAttributes: KeyAttributes = getData("keyAttributes");
const {
recoveryKeyEncryptedWithMasterKey,
recoveryKeyDecryptionNonce,
} = keyAttributes;
const masterKey = await getActualKey();
let recoveryKey: string;
if (recoveryKeyEncryptedWithMasterKey) {
recoveryKey = await cryptoWorker.decryptB64(
recoveryKeyEncryptedWithMasterKey!,
recoveryKeyDecryptionNonce!,
masterKey,
);
} else {
recoveryKey = await createNewRecoveryKey();
}
recoveryKey = await cryptoWorker.toHex(recoveryKey);
return recoveryKey;
} catch (e) {
log.error("getRecoveryKey failed", e);
throw e;
}
};
// Used only for legacy users for whom we did not generate recovery keys during
// sign up
async function createNewRecoveryKey() {
const masterKey = await getActualKey();
const existingAttributes = getData("keyAttributes");
const cryptoWorker = await sharedCryptoWorker();
const recoveryKey = await cryptoWorker.generateEncryptionKey();
const encryptedMasterKey = await cryptoWorker.encryptToB64(
masterKey,
recoveryKey,
);
const encryptedRecoveryKey = await cryptoWorker.encryptToB64(
recoveryKey,
masterKey,
);
const recoveryKeyAttributes = {
masterKeyEncryptedWithRecoveryKey: encryptedMasterKey.encryptedData,
masterKeyDecryptionNonce: encryptedMasterKey.nonce,
recoveryKeyEncryptedWithMasterKey: encryptedRecoveryKey.encryptedData,
recoveryKeyDecryptionNonce: encryptedRecoveryKey.nonce,
};
await setRecoveryKey(getToken(), recoveryKeyAttributes);
const updatedKeyAttributes = Object.assign(
existingAttributes,
recoveryKeyAttributes,
);
setData("keyAttributes", updatedKeyAttributes);
return recoveryKey;
}
/**
* Decrypt the {@link encryptedChallenge} sent by remote during the delete
* account flow ({@link getAccountDeleteChallenge}), returning a value that can