diff --git a/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx b/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx new file mode 100644 index 000000000..aea836f6e --- /dev/null +++ b/web/apps/accounts/src/pages/passkeys/flow/Recover.tsx @@ -0,0 +1,19 @@ +import { TwoFactorType } from "@ente/accounts/constants/twofactor"; +import RecoverPage from "@ente/accounts/pages/recover"; +import { APPS } from "@ente/shared/apps/constants"; +import { useRouter } from "next/router"; +import { AppContext } from "pages/_app"; +import { useContext } from "react"; + +export default function Recover() { + const appContext = useContext(AppContext); + const router = useRouter(); + return ( + + ); +} diff --git a/web/apps/accounts/src/pages/passkeys/flow/index.tsx b/web/apps/accounts/src/pages/passkeys/flow/index.tsx index 517777b9c..1f082bf6b 100644 --- a/web/apps/accounts/src/pages/passkeys/flow/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/flow/index.tsx @@ -257,6 +257,18 @@ const PasskeysFlow = () => { > {t("TRY_AGAIN")} + + {t("RECOVER_TWO_FACTOR")} + diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index 1a088c99d..e0098cd36 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -56,7 +56,7 @@ "sanitize-filename": "^1.6.3", "similarity-transformation": "^0.0.1", "transformation-matrix": "^2.15.0", - "uuid": "^9.0.0", + "uuid": "^9.0.1", "vscode-uri": "^3.0.7", "xml-js": "^1.6.11", "zxcvbn": "^4.4.2" diff --git a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx b/web/apps/photos/src/components/Sidebar/UtilitySection.tsx index bf9782e3b..b297f36ee 100644 --- a/web/apps/photos/src/components/Sidebar/UtilitySection.tsx +++ b/web/apps/photos/src/components/Sidebar/UtilitySection.tsx @@ -11,8 +11,17 @@ import TwoFactorModal from "components/TwoFactor/Modal"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; // import mlIDbStorage from 'utils/storage/mlIDbStorage'; +import { + configurePasskeyRecovery, + isPasskeyRecoveryEnabled, +} from "@ente/accounts/services/passkey"; import { APPS, CLIENT_PACKAGE_NAMES } from "@ente/shared/apps/constants"; import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher"; +import { getRecoveryKey } from "@ente/shared/crypto/helpers"; +import { + encryptToB64, + generateEncryptionKey, +} from "@ente/shared/crypto/internal/libsodium"; import { getAccountsURL } from "@ente/shared/network/api"; import { logError } from "@ente/shared/sentry"; import { THEME_COLOR } from "@ente/shared/themes/constants"; @@ -72,13 +81,35 @@ export default function UtilitySection({ closeSidebar }) { closeSidebar(); try { + // check if the user has passkey recovery enabled + const recoveryEnabled = await isPasskeyRecoveryEnabled(); + if (!recoveryEnabled) { + // let's create the necessary recovery information + const recoveryKey = await getRecoveryKey(); + + const resetSecret = await generateEncryptionKey(); + + const encryptionResult = await encryptToB64( + resetSecret, + recoveryKey, + ); + + await configurePasskeyRecovery( + resetSecret, + encryptionResult.encryptedData, + encryptionResult.nonce, + ); + } + const accountsToken = await getAccountsToken(); - window.location.href = `${getAccountsURL()}${ - ACCOUNTS_PAGES.ACCOUNT_HANDOFF - }?package=${CLIENT_PACKAGE_NAMES.get( - APPS.PHOTOS, - )}&token=${accountsToken}`; + window.open( + `${getAccountsURL()}${ + ACCOUNTS_PAGES.ACCOUNT_HANDOFF + }?package=${CLIENT_PACKAGE_NAMES.get( + APPS.PHOTOS, + )}&token=${accountsToken}`, + ); } catch (e) { logError(e, "failed to redirect to accounts page"); } diff --git a/web/packages/accounts/api/user.ts b/web/packages/accounts/api/user.ts index def8fa8c4..0aa534306 100644 --- a/web/packages/accounts/api/user.ts +++ b/web/packages/accounts/api/user.ts @@ -15,6 +15,7 @@ import { logError } from "@ente/shared/sentry"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; import { KeyAttributes } from "@ente/shared/user/types"; import { HttpStatusCode } from "axios"; +import { TwoFactorType } from "../constants/twofactor"; const ENDPOINT = getEndpoint(); @@ -79,17 +80,26 @@ export const verifyTwoFactor = async (code: string, sessionID: string) => { return resp.data as UserVerificationResponse; }; -export const recoverTwoFactor = async (sessionID: string) => { +export const recoverTwoFactor = async ( + sessionID: string, + twoFactorType: TwoFactorType = TwoFactorType.TOTP, +) => { const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, { sessionID, + twoFactorType, }); return resp.data as TwoFactorRecoveryResponse; }; -export const removeTwoFactor = async (sessionID: string, secret: string) => { +export const removeTwoFactor = async ( + sessionID: string, + secret: string, + twoFactorType: TwoFactorType = TwoFactorType.TOTP, +) => { const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, { sessionID, secret, + twoFactorType, }); return resp.data as TwoFactorVerificationResponse; }; diff --git a/web/packages/accounts/constants/twofactor.ts b/web/packages/accounts/constants/twofactor.ts new file mode 100644 index 000000000..92b10730d --- /dev/null +++ b/web/packages/accounts/constants/twofactor.ts @@ -0,0 +1,4 @@ +export enum TwoFactorType { + PASSKEY = "passkey", + TOTP = "totp", +} diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 67bd709e1..fcaf05eab 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { recoverTwoFactor, removeTwoFactor } from "@ente/accounts/api/user"; import { PAGES } from "@ente/accounts/constants/pages"; +import { TwoFactorType } from "@ente/accounts/constants/twofactor"; import { logoutUser } from "@ente/accounts/services/user"; import { PageProps } from "@ente/shared/apps/types"; import { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; @@ -28,7 +29,11 @@ const bip39 = require("bip39"); // mobile client library only supports english. bip39.setDefaultWordlist("english"); -export default function Recover({ router, appContext }: PageProps) { +export default function Recover({ + router, + appContext, + twoFactorType = TwoFactorType.TOTP, +}: PageProps) { const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = useState(null); const [sessionID, setSessionID] = useState(null); @@ -49,7 +54,10 @@ export default function Recover({ router, appContext }: PageProps) { } const main = async () => { try { - const resp = await recoverTwoFactor(user.twoFactorSessionID); + const resp = await recoverTwoFactor( + user.twoFactorSessionID, + twoFactorType, + ); setDoesHaveEncryptedRecoveryKey(!!resp.encryptedSecret); if (!resp.encryptedSecret) { showContactSupportDialog({ @@ -106,7 +114,11 @@ export default function Recover({ router, appContext }: PageProps) { encryptedTwoFactorSecret.nonce, await cryptoWorker.fromHex(recoveryKey), ); - const resp = await removeTwoFactor(sessionID, twoFactorSecret); + const resp = await removeTwoFactor( + sessionID, + twoFactorSecret, + twoFactorType, + ); const { keyAttributes, encryptedToken, token, id } = resp; setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts new file mode 100644 index 000000000..89a9ae61a --- /dev/null +++ b/web/packages/accounts/services/passkey.ts @@ -0,0 +1,56 @@ +import { CustomError } from "@ente/shared/error"; +import HTTPService from "@ente/shared/network/HTTPService"; +import { logError } from "@ente/shared/sentry"; +import { getToken } from "@ente/shared/storage/localStorage/helpers"; + +export const isPasskeyRecoveryEnabled = async () => { + try { + const token = getToken(); + + const resp = await HTTPService.get( + "/users/two-factor/recovery-status", + {}, + { + "X-Auth-Token": token, + }, + ); + + if (typeof resp.data === "undefined") { + throw Error(CustomError.REQUEST_FAILED); + } + + return resp.data["isPasskeyRecoveryEnabled"] as boolean; + } catch (e) { + logError(e, "failed to get passkey recovery status"); + throw e; + } +}; + +export const configurePasskeyRecovery = async ( + secret: string, + userEncryptedSecret: string, + userSecretNonce: string, +) => { + try { + const token = getToken(); + + const resp = await HTTPService.post( + "/users/two-factor/passkeys/configure-recovery", + { + secret, + userEncryptedSecret, + userSecretNonce, + }, + { + "X-Auth-Token": token, + }, + ); + + if (typeof resp.data === "undefined") { + throw Error(CustomError.REQUEST_FAILED); + } + } catch (e) { + logError(e, "failed to configure passkey recovery"); + throw e; + } +}; diff --git a/web/packages/shared/apps/types.ts b/web/packages/shared/apps/types.ts index da5451311..364342516 100644 --- a/web/packages/shared/apps/types.ts +++ b/web/packages/shared/apps/types.ts @@ -1,4 +1,5 @@ import { EmotionCache } from "@emotion/react"; +import { TwoFactorType } from "@ente/accounts/constants/twofactor"; import { SetDialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; import { AppProps } from "next/app"; import { NextRouter } from "next/router"; @@ -16,4 +17,5 @@ export interface PageProps { }; router: NextRouter; appName: APPS; + twoFactorType?: TwoFactorType; } diff --git a/web/yarn.lock b/web/yarn.lock index 2f5cbb105..4e31afe7e 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1979,11 +1979,7 @@ fastq@^1.6.0: reusify "^1.0.4" "ffmpeg-wasm@file:./apps/photos/thirdparty/ffmpeg-wasm": - version "0.10.1" - dependencies: - is-url "^1.2.4" - node-fetch "^2.6.1" - regenerator-runtime "^0.13.7" + version "0.0.0" file-entry-cache@^6.0.1: version "6.0.1" @@ -2618,11 +2614,6 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" -is-url@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" - integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== - is-weakmap@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" @@ -3253,7 +3244,7 @@ peek-readable@^4.1.0: integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== "photoswipe@file:./apps/photos/thirdparty/photoswipe": - version "4.1.6" + version "0.0.0" picocolors@^1.0.0: version "1.0.0" @@ -3585,11 +3576,6 @@ reflect.getprototypeof@^1.0.4: globalthis "^1.0.3" which-builtin-type "^1.1.3" -regenerator-runtime@^0.13.7: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" @@ -4201,7 +4187,7 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@^9.0.0: +uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==