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 = ({ appContext }) => { const { appName, logout, setDialogBoxAttributesV2 } = appContext; const [srpAttributes, setSrpAttributes] = useState(); const [keyAttributes, setKeyAttributes] = useState(); const [user, setUser] = useState(); 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 ( ); } 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 ( ); } return ( openPasskeyVerificationURL(passkeyVerificationData) } appContext={appContext} /> ); } // TODO: Handle the case when user is not present, or exclude that // possibility using types. return ( {user?.email ?? ""} router.push(PAGES.RECOVER)}> {t("FORGOT_PASSWORD")} {t("CHANGE_EMAIL")} ); }; 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; } };