ente/web/packages/accounts/pages/credentials.tsx
2024-06-25 15:58:27 +05:30

405 lines
15 KiB
TypeScript

import log from "@/next/log";
import { ensure } from "@/utils/ensure";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import LinkButton from "@ente/shared/components/LinkButton";
import {
LoginFlowFormFooter,
PasswordHeader,
VerifyingPasskey,
sessionExpiredDialogAttributes,
} from "@ente/shared/components/LoginComponents";
import VerifyMasterPasswordForm, {
type VerifyMasterPasswordFormProps,
} from "@ente/shared/components/VerifyMasterPasswordForm";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import {
decryptAndStoreToken,
generateAndSaveIntermediateKeyAttributes,
generateLoginSubKey,
saveKeyInSessionStore,
} from "@ente/shared/crypto/helpers";
import type { B64EncryptionResult } from "@ente/shared/crypto/types";
import { CustomError } from "@ente/shared/error";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import {
LS_KEYS,
clearData,
getData,
setData,
} from "@ente/shared/storage/localStorage";
import {
getToken,
isFirstLogin,
setIsFirstLogin,
} from "@ente/shared/storage/localStorage/helpers";
import {
SESSION_KEYS,
getKey,
removeKey,
setKey,
} from "@ente/shared/storage/sessionStorage";
import type { KeyAttributes, User } from "@ente/shared/user/types";
import { Stack } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { getSRPAttributes } from "../api/srp";
import { PAGES } from "../constants/pages";
import {
openPasskeyVerificationURL,
passkeyVerificationRedirectURL,
} from "../services/passkey";
import { appHomeRoute } from "../services/redirect";
import {
configureSRP,
generateSRPSetupAttributes,
loginViaSRP,
} from "../services/srp";
import type { PageProps } from "../types/page";
import type { SRPAttributes } from "../types/srp";
const Page: React.FC<PageProps> = ({ appContext }) => {
const { appName, logout, setDialogBoxAttributesV2 } = appContext;
const [srpAttributes, setSrpAttributes] = useState<SRPAttributes>();
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
const [user, setUser] = useState<User>();
const [passkeyVerificationData, setPasskeyVerificationData] = useState<
{ passkeySessionID: string; url: string } | undefined
>();
const router = useRouter();
const showSessionExpiredDialog = () =>
setDialogBoxAttributesV2(sessionExpiredDialogAttributes(logout));
useEffect(() => {
const main = async () => {
const user: User = getData(LS_KEYS.USER);
if (!user?.email) {
router.push(PAGES.ROOT);
return;
}
setUser(user);
let key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
const electron = globalThis.electron;
if (!key && electron) {
try {
key = await electron.encryptionKey();
} catch (e) {
log.error("Failed to get encryption key from electron", e);
}
if (key) {
await saveKeyInSessionStore(
SESSION_KEYS.ENCRYPTION_KEY,
key,
true,
);
}
}
const token = getToken();
if (key && token) {
router.push(appHomeRoute(appName));
return;
}
const kekEncryptedAttributes: B64EncryptionResult = getKey(
SESSION_KEYS.KEY_ENCRYPTION_KEY,
);
const keyAttributes: KeyAttributes = getData(
LS_KEYS.KEY_ATTRIBUTES,
);
const srpAttributes: SRPAttributes = getData(
LS_KEYS.SRP_ATTRIBUTES,
);
if (srpAttributes) {
const email = user.email;
if (email) {
void didPasswordChangeElsewhere(email, srpAttributes).then(
(changed) => changed && showSessionExpiredDialog(),
);
}
}
if (kekEncryptedAttributes && keyAttributes) {
removeKey(SESSION_KEYS.KEY_ENCRYPTION_KEY);
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const kek = await cryptoWorker.decryptB64(
kekEncryptedAttributes.encryptedData,
kekEncryptedAttributes.nonce,
kekEncryptedAttributes.key,
);
const key = await cryptoWorker.decryptB64(
keyAttributes.encryptedKey,
keyAttributes.keyDecryptionNonce,
kek,
);
useMasterPassword(key, kek, keyAttributes);
return;
}
if (keyAttributes) {
if (
(!user?.token && !user?.encryptedToken) ||
(keyAttributes && !keyAttributes.memLimit)
) {
clearData();
router.push(PAGES.ROOT);
return;
}
setKeyAttributes(keyAttributes);
return;
}
if (srpAttributes) {
setSrpAttributes(srpAttributes);
} else {
router.push(PAGES.ROOT);
}
};
main();
appContext.showNavBar(true);
}, []);
const getKeyAttributes: VerifyMasterPasswordFormProps["getKeyAttributes"] =
async (kek: string) => {
try {
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const {
keyAttributes,
encryptedToken,
token,
id,
twoFactorSessionID,
passkeySessionID,
} = await loginViaSRP(ensure(srpAttributes), kek);
setIsFirstLogin(true);
if (passkeySessionID) {
const sessionKeyAttributes =
await cryptoWorker.generateKeyAndEncryptToB64(kek);
setKey(
SESSION_KEYS.KEY_ENCRYPTION_KEY,
sessionKeyAttributes,
);
const user = getData(LS_KEYS.USER);
setData(LS_KEYS.USER, {
...user,
passkeySessionID,
isTwoFactorEnabled: true,
isTwoFactorPasskeysEnabled: true,
});
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
const url = passkeyVerificationRedirectURL(
appName,
passkeySessionID,
);
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
throw Error(CustomError.TWO_FACTOR_ENABLED);
} else if (twoFactorSessionID) {
const sessionKeyAttributes =
await cryptoWorker.generateKeyAndEncryptToB64(kek);
setKey(
SESSION_KEYS.KEY_ENCRYPTION_KEY,
sessionKeyAttributes,
);
const user = getData(LS_KEYS.USER);
setData(LS_KEYS.USER, {
...user,
twoFactorSessionID,
isTwoFactorEnabled: true,
});
router.push(PAGES.TWO_FACTOR_VERIFY);
throw Error(CustomError.TWO_FACTOR_ENABLED);
} else {
const user = getData(LS_KEYS.USER);
setData(LS_KEYS.USER, {
...user,
token,
encryptedToken,
id,
isTwoFactorEnabled: false,
});
if (keyAttributes)
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
return keyAttributes;
}
} catch (e) {
if (
e instanceof Error &&
e.message != CustomError.TWO_FACTOR_ENABLED
) {
log.error("getKeyAttributes failed", e);
}
throw e;
}
};
const useMasterPassword: VerifyMasterPasswordFormProps["callback"] = async (
key,
kek,
keyAttributes,
passphrase,
) => {
try {
if (isFirstLogin() && passphrase) {
await generateAndSaveIntermediateKeyAttributes(
passphrase,
keyAttributes,
key,
);
}
await saveKeyInSessionStore(SESSION_KEYS.ENCRYPTION_KEY, key);
await decryptAndStoreToken(keyAttributes, key);
try {
let srpAttributes: SRPAttributes | null = getData(
LS_KEYS.SRP_ATTRIBUTES,
);
if (!srpAttributes && user) {
srpAttributes = await getSRPAttributes(user.email);
if (srpAttributes) {
setData(LS_KEYS.SRP_ATTRIBUTES, srpAttributes);
}
}
log.debug(() => `userSRPSetupPending ${!srpAttributes}`);
if (!srpAttributes) {
const loginSubKey = await generateLoginSubKey(kek);
const srpSetupAttributes =
await generateSRPSetupAttributes(loginSubKey);
await configureSRP(srpSetupAttributes);
}
} catch (e) {
log.error("migrate to srp failed", e);
}
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
router.push(redirectURL ?? appHomeRoute(appName));
} catch (e) {
log.error("useMasterPassword failed", e);
}
};
if (!keyAttributes && !srpAttributes) {
return (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
}
if (passkeyVerificationData) {
// We only need to handle this scenario when running in the desktop app
// because the web app will navigate to Passkey verification URL.
// However, still we add an additional `globalThis.electron` check to
// show a spinner. This prevents the VerifyingPasskey component from
// being disorientingly shown for a fraction of a second as the redirect
// happens on the web app.
//
// See: [Note: Passkey verification in the desktop app]
if (!globalThis.electron) {
return (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
}
return (
<VerifyingPasskey
email={user?.email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(passkeyVerificationData)
}
appContext={appContext}
/>
);
}
// TODO: Handle the case when user is not present, or exclude that
// possibility using types.
return (
<VerticallyCentered>
<FormPaper style={{ minWidth: "320px" }}>
<PasswordHeader>{user?.email ?? ""}</PasswordHeader>
<VerifyMasterPasswordForm
buttonText={t("VERIFY_PASSPHRASE")}
callback={useMasterPassword}
user={user}
keyAttributes={keyAttributes}
getKeyAttributes={getKeyAttributes}
srpAttributes={srpAttributes}
/>
<LoginFlowFormFooter>
<Stack direction="row" justifyContent="space-between">
<LinkButton onClick={() => router.push(PAGES.RECOVER)}>
{t("FORGOT_PASSWORD")}
</LinkButton>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</Stack>
</LoginFlowFormFooter>
</FormPaper>
</VerticallyCentered>
);
};
export default Page;
/**
* If the user changes their password on a different device, then we need to log
* them out here.
*
* There is a straightforward way of doing this by always making a blocking API
* call before showing this page, however that would add latency to the 99% user
* experience (of normal unlocks) for the 1% cases (they've changed their
* password elsewhere).
*
* If we don't do anything though, the behaviour is confusing:
*
* 1. The data on this device is encrypted with their old password, so entering
* their old password will successfully log them in. This appears as a bug to
* the user.
*
* 2. However, more critically, if they try to enter their new password, it does
* not get accepted (since the data on this device is encrypted with their
* old password). This causes user alarm.
*
* As a way to handle primarily case 2 (but also case 1), without adding latency
* to the normal unlocks, we do an non-blocking API call to get the user's SRP
* attributes when they enter this page. SRP attributes change when the password
* is changed, and thus when we compare the server's response with what is
* present locally, we'll find that the SRP attributes have changed. In such
* cases, we invalidate their session on this device and ask them to login
* afresh.
*
* @param email The user's email.
*
* @param localSRPAttributes The local SRP attributes.
*/
const didPasswordChangeElsewhere = async (
email: string,
localSRPAttributes: SRPAttributes,
) => {
try {
const serverAttributes = await getSRPAttributes(email);
// (Arbitrarily) compare the salt to figure out if something changed
// (salt will always change on password changes).
if (serverAttributes?.kekSalt !== localSRPAttributes.kekSalt)
return true; /* password indeed did change */
return false;
} catch (e) {
// Ignore errors here. In rare cases, the stars may align and cause the
// API calls to fail in that 1 case where the user indeed changed their
// password, but we also don't want to start logging people out for
// harmless transient issues like network errors.
log.error("Failed to compare SRP attributes", e);
return false;
}
};