mirror of
https://github.com/ente-io/ente.git
synced 2025-08-05 22:27:58 +00:00
405 lines
15 KiB
TypeScript
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;
|
|
}
|
|
};
|