Web Passkey Recovery (#1047)

## Description

* Adds passkey recovery feature parity from #1013

## Tests
This commit is contained in:
Manav Rathi 2024-03-17 11:44:13 +05:30 committed by GitHub
commit f85f220c1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 160 additions and 28 deletions

View file

@ -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 (
<RecoverPage
appContext={appContext}
router={router}
appName={APPS.PHOTOS}
twoFactorType={TwoFactorType.PASSKEY}
/>
);
}

View file

@ -257,6 +257,18 @@ const PasskeysFlow = () => {
>
{t("TRY_AGAIN")}
</EnteButton>
<EnteButton
href="/passkeys/flow/recover"
fullWidth
style={{
marginTop: "1rem",
}}
color="primary"
type="button"
variant="text"
>
{t("RECOVER_TWO_FACTOR")}
</EnteButton>
</FormPaper>
</Box>
</Box>

View file

@ -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"

View file

@ -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");
}

View file

@ -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;
};

View file

@ -0,0 +1,4 @@
export enum TwoFactorType {
PASSKEY = "passkey",
TOTP = "totp",
}

View file

@ -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<B64EncryptionResult>(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),

View file

@ -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;
}
};

View file

@ -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;
}

View file

@ -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==