Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ledger Sync - Display error when scanning invalid QR Code #7800

Merged
merged 1 commit into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/loud-donkeys-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"live-mobile": patch
"@ledgerhq/trustchain": patch
---

Ledger Sync - Display relevant error when scanning old accounts export qr code or an invalid one
14 changes: 14 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,10 @@
},
"DeleteAppDataError": {
"title": "Error deleting app data"
},
"ScannedNewImportQrCode": {
"title": "Update required",
"description": "To sync your apps, please make sure both are updated to the latest version."
}
},
"crash": {
Expand Down Expand Up @@ -6875,6 +6879,16 @@
"tryAgain": "Try again"
}
},
"scannedInvalidQrCode": {
"title": "Invalid QR Code",
"desc": "It looks like the QR code you scanned isn't valid. Please try again with a Ledger Sync valid QR code.",
"tryAgain": "Try again"
},
"scannedOldQrCode": {
"title": "Update required",
"desc": "To sync your apps, please make sure both are updated to the latest version.",
"tryAgain": "Try again"
},
"unbacked": {
"title": "You need to create your encryption key first",
"description": "Please make sure you’ve created an encryption key on one of your Ledger Live apps before continuing your synchronization.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { useSyncWithQrCode } from "LLM/features/WalletSync/hooks/useSyncWithQrCo
import { SpecificError } from "LLM/features/WalletSync/components/Error/SpecificError";
import { ErrorReason } from "LLM/features/WalletSync/hooks/useSpecificError";
import { useCurrentStep } from "LLM/features/WalletSync/hooks/useCurrentStep";
import ScannedInvalidQrCode from "~/newArch/features/WalletSync/screens/Synchronize/ScannedInvalidQrCode";
import ScannedOldImportQrCode from "~/newArch/features/WalletSync/screens/Synchronize/ScannedOldImportQrCode";

type Props = {
currency?: CryptoCurrency | TokenCurrency | null;
Expand Down Expand Up @@ -97,6 +99,12 @@ const StepFlow = ({
case Steps.SyncError:
return <SyncError tryAgain={navigateToQrCodeMethod} />;

case Steps.ScannedInvalidQrCode:
return <ScannedInvalidQrCode tryAgain={navigateToQrCodeMethod} />;

case Steps.ScannedOldImportQrCode:
return <ScannedOldImportQrCode tryAgain={navigateToQrCodeMethod} />;

case Steps.UnbackedError:
return <SpecificError primaryAction={onCreateKey} error={ErrorReason.NO_BACKUP} />;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { AnalyticsPage } from "../../hooks/useLedgerSyncAnalytics";
import PinCodeDisplay from "../../screens/Synchronize/PinCodeDisplay";
import PinCodeInput from "../../screens/Synchronize/PinCodeInput";
import SyncError from "../../screens/Synchronize/SyncError";
import ScannedInvalidQrCode from "../../screens/Synchronize/ScannedInvalidQrCode";
import ScannedOldImportQrCode from "../../screens/Synchronize/ScannedOldImportQrCode";
import { useInitMemberCredentials } from "../../hooks/useInitMemberCredentials";
import { useSyncWithQrCode } from "../../hooks/useSyncWithQrCode";
import { SpecificError } from "../Error/SpecificError";
Expand Down Expand Up @@ -102,6 +104,12 @@ const ActivationFlow = ({
case Steps.SyncError:
return <SyncError tryAgain={navigateToQrCodeMethod} />;

case Steps.ScannedInvalidQrCode:
return <ScannedInvalidQrCode tryAgain={navigateToQrCodeMethod} />;

case Steps.ScannedOldImportQrCode:
return <ScannedOldImportQrCode tryAgain={navigateToQrCodeMethod} />;

case Steps.UnbackedError:
if (!hasCompletedOnboarding) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export enum AnalyticsPage {
SyncWithQrCode = "Sync with QR code",
PinCode = "Pin code",
PinCodesDoNotMatch = "Pin codes don't match",
ScannedInvalidQrCode = "Scanned invalid QR code",
ScannedIncompatibleApps = "Scans incompatible apps",
Loading = "Loading",
SettingsGeneral = "Settings General",
LedgerSyncSettings = "Ledger Sync Settings",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useCallback, useState } from "react";
import { MemberCredentials, TrustchainMember } from "@ledgerhq/trustchain/types";
import { createQRCodeCandidateInstance } from "@ledgerhq/trustchain/qrcode/index";
import {
ScannedOldImportQrCode,
ScannedInvalidQrCode,
InvalidDigitsError,
NoTrustchainInitialized,
TrustchainAlreadyInitialized,
Expand Down Expand Up @@ -72,7 +74,11 @@ export const useSyncWithQrCode = () => {
onSyncFinished();
return true;
} catch (e) {
if (e instanceof InvalidDigitsError) {
if (e instanceof ScannedOldImportQrCode) {
setCurrentStep(Steps.ScannedOldImportQrCode);
} else if (e instanceof ScannedInvalidQrCode) {
setCurrentStep(Steps.ScannedInvalidQrCode);
} else if (e instanceof InvalidDigitsError) {
setCurrentStep(Steps.SyncError);
return;
} else if (e instanceof NoTrustchainInitialized) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ErrorComponent } from "../../components/Error/Simple";
import { AnalyticsButton, AnalyticsPage } from "../../hooks/useLedgerSyncAnalytics";
import { track } from "~/analytics";

interface Props {
tryAgain: () => void;
}

export default function ScannedInvalidQrCode({ tryAgain }: Props) {
const { t } = useTranslation();

const onTryAgain = () => {
tryAgain();
track("button_clicked", {
button: AnalyticsButton.TryAgain,
page: AnalyticsPage.ScannedInvalidQrCode,
});
};

return (
<ErrorComponent
title={t("walletSync.synchronize.qrCode.scannedInvalidQrCode.title")}
desc={t("walletSync.synchronize.qrCode.scannedInvalidQrCode.desc")}
mainButton={{
label: t("walletSync.synchronize.qrCode.scannedInvalidQrCode.tryAgain"),
onPress: onTryAgain,
outline: false,
}}
analyticsPage={AnalyticsPage.ScannedInvalidQrCode}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ErrorComponent } from "../../components/Error/Simple";
import { AnalyticsButton, AnalyticsPage } from "../../hooks/useLedgerSyncAnalytics";
import { track } from "~/analytics";

interface Props {
tryAgain: () => void;
}

export default function ScannedOldImportQrCode({ tryAgain }: Props) {
const { t } = useTranslation();

const onTryAgain = () => {
tryAgain();
track("button_clicked", {
button: AnalyticsButton.TryAgain,
page: AnalyticsPage.ScannedIncompatibleApps,
});
};

return (
<ErrorComponent
title={t("walletSync.synchronize.qrCode.scannedOldQrCode.title")}
desc={t("walletSync.synchronize.qrCode.scannedOldQrCode.desc")}
mainButton={{
label: t("walletSync.synchronize.qrCode.scannedOldQrCode.tryAgain"),
onPress: onTryAgain,
outline: false,
}}
analyticsPage={AnalyticsPage.ScannedIncompatibleApps}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export enum Steps {
PinDisplay = "PinDisplay",
PinInput = "PinInput",
SyncError = "SyncError",
ScannedInvalidQrCode = "ScannedInvalidQrCode",
ScannedOldImportQrCode = "ScannedOldImportQrCode",
UnbackedError = "UnbackedError",
AlreadyBacked = "AlreadyBacked",
BackedWithDifferentSeeds = "BackedWithDifferentSeeds",
Expand Down
11 changes: 10 additions & 1 deletion apps/ledger-live-mobile/src/screens/ImportAccounts/Scan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { PureComponent } from "react";
import { StyleSheet, View } from "react-native";
import { parseFramesReducer, framesToData, areFramesComplete, progressOfFrames } from "qrloop";
import { Result as ImportAccountsResult, decode } from "@ledgerhq/live-wallet/liveqr/cross";
import { TrackScreen } from "~/analytics";
import { screen, TrackScreen } from "~/analytics";
import { ScreenName } from "~/const";
import Scanner from "~/components/Scanner";
import GenericErrorBottomModal from "~/components/GenericErrorBottomModal";
Expand All @@ -11,6 +11,8 @@ import { withTheme } from "../../colors";
import type { Theme } from "../../colors";
import type { ImportAccountsNavigatorParamList } from "~/components/RootNavigator/types/ImportAccountsNavigator";
import type { StackNavigatorProps } from "~/components/RootNavigator/types/helpers";
import { ScannedNewImportQrCode } from "@ledgerhq/trustchain/errors";
import { AnalyticsPage } from "~/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics";

type NavigationProps = StackNavigatorProps<
ImportAccountsNavigatorParamList,
Expand Down Expand Up @@ -75,6 +77,13 @@ class Scan extends PureComponent<
}
}
} catch (e) {
if (data.match(/host=([0-9A-Fa-f]+)/)) {
this.setState({
error: new ScannedNewImportQrCode(),
progress: 0,
});
screen("", AnalyticsPage.ScannedIncompatibleApps, { source: "Account Import Sync" });
}
Comment on lines +80 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to do it in the catch block? Couldn't we perform the check at the beginning instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the analytics event for the error that is triggered in the catch (the setState({ error: new ScannedNewImportQrCode() }) ).
As it's handled by the generic error component I had to trigger the analytics here

console.warn(e);
}
}
Expand Down
3 changes: 3 additions & 0 deletions libs/trustchain/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createCustomErrorClass } from "@ledgerhq/errors";

export const ScannedOldImportQrCode = createCustomErrorClass("ScannedOldImportQrCode");
export const ScannedNewImportQrCode = createCustomErrorClass("ScannedNewImportQrCode");
export const ScannedInvalidQrCode = createCustomErrorClass("ScannedInvalidQrCode");
export const InvalidDigitsError = createCustomErrorClass("InvalidDigitsError");
export const InvalidEncryptionKeyError = createCustomErrorClass("InvalidEncryptionKeyError");
export const TrustchainEjected = createCustomErrorClass("TrustchainEjected");
Expand Down
52 changes: 52 additions & 0 deletions libs/trustchain/src/qrcode/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createQRCodeHostInstance, createQRCodeCandidateInstance } from ".";
import WebSocket from "ws";
import { convertKeyPairToLiveCredentials } from "../sdk";
import { crypto } from "@ledgerhq/hw-trustchain";
import { ScannedInvalidQrCode, ScannedOldImportQrCode } from "../errors";

describe("Trustchain QR Code", () => {
let server;
Expand Down Expand Up @@ -83,4 +84,55 @@ describe("Trustchain QR Code", () => {
);
expect(res).toEqual(trustchain);
});
test("invalid qr code scanned", async () => {
const trustchain = {
rootId: "test-root-id",
walletSyncEncryptionKey: "test-wallet-sync-encryption-key",
applicationPath: "m/0'/16'/0'",
};
const addMember = jest.fn(() => Promise.resolve(trustchain));
const memberCredentials = convertKeyPairToLiveCredentials(await crypto.randomKeypair());
const memberName = "foo";

const onRequestQRCodeInput = jest.fn();

const scannedUrl = "https://example.com";

const candidateP = createQRCodeCandidateInstance({
memberCredentials,
memberName,
initialTrustchainId: undefined,
addMember,
scannedUrl,
onRequestQRCodeInput,
});

await expect(candidateP).rejects.toThrow(new ScannedInvalidQrCode());
});
test("old accounts export qr code scanned", async () => {
const trustchain = {
rootId: "test-root-id",
walletSyncEncryptionKey: "test-wallet-sync-encryption-key",
applicationPath: "m/0'/16'/0'",
};
const addMember = jest.fn(() => Promise.resolve(trustchain));
const memberCredentials = convertKeyPairToLiveCredentials(await crypto.randomKeypair());
const memberName = "foo";

const onRequestQRCodeInput = jest.fn();

const scannedUrl =
"ZAADAAIAAAAEd2JXMpuoYdzvkNzFTlmQLPcGf2LSjDOgqaB3nQoZqlimcCX6HNkescWKyT1DCGuwO7IesD7oYg+fdZPkiIfFL3V9swfZRePkaNN09IjXsWLsim9hK/qi/RC1/ofX3hYNKUxUAgYVVG82WKXIk47siWfUlRZsCYSAARQ6ASpUgidPjMHaOMK6w53wTZplwo7Zjv1HrIyKwr3Ci8OmrFye5g==";

const candidateP = createQRCodeCandidateInstance({
memberCredentials,
memberName,
initialTrustchainId: undefined,
addMember,
scannedUrl,
onRequestQRCodeInput,
});

await expect(candidateP).rejects.toThrow(new ScannedOldImportQrCode());
});
});
9 changes: 8 additions & 1 deletion libs/trustchain/src/qrcode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
InvalidDigitsError,
NoTrustchainInitialized,
QRCodeWSClosed,
ScannedInvalidQrCode,
ScannedOldImportQrCode,
TrustchainAlreadyInitialized,
} from "../errors";
import { log } from "@ledgerhq/logs";
Expand Down Expand Up @@ -267,7 +269,8 @@ export async function createQRCodeCandidateInstance({
}): Promise<Trustchain | void> {
const m = scannedUrl.match(/host=([0-9A-Fa-f]+)/);
if (!m) {
throw new Error("invalid scannedUrl");
if (isFromOldAccountsImport(scannedUrl)) throw new ScannedOldImportQrCode();
throw new ScannedInvalidQrCode();
}
const hostPublicKey = crypto.from_hex(m[1]);
const ephemeralKey = await crypto.randomKeypair();
Expand Down Expand Up @@ -386,3 +389,7 @@ function fromErrorMessage(payload: { message: string; type: string }): Error {
error.name = "TrustchainQRCode-" + payload.type;
return error;
}

function isFromOldAccountsImport(scannedUrl: string): boolean {
return !!scannedUrl.match(/^[A-Za-z0-9+/=]*$/);
}
Loading