From 7029023b0dd32ea3e8d0d756a9227cf918defff9 Mon Sep 17 00:00:00 2001 From: "Valentin D. Pinkman" Date: Fri, 6 Sep 2024 17:58:58 +0200 Subject: [PATCH] :sparkles: (device-core): add new reinstallConfiguration consent use case --- .changeset/strong-steaks-rule.md | 7 ++ .changeset/twelve-owls-accept.md | 5 ++ .changeset/wet-clocks-nail.md | 5 ++ apps/cli/package.json | 2 + apps/cli/src/commands-index.ts | 2 + .../commands/device/reinstallConfiguration.ts | 61 +++++++++++++++ .../static/i18n/en/app.json | 3 + .../src/locales/en/common.json | 3 + .../entities/ReinstallConfigEntity.ts | 15 ++++ .../consent/reinstallConfiguration.test.ts | 56 ++++++++++++++ .../consent/reinstallConfiguration.ts | 75 +++++++++++++++++++ libs/device-core/src/index.ts | 2 + libs/ledgerjs/packages/errors/src/index.ts | 1 + pnpm-lock.yaml | 6 ++ 14 files changed, 243 insertions(+) create mode 100644 .changeset/strong-steaks-rule.md create mode 100644 .changeset/twelve-owls-accept.md create mode 100644 .changeset/wet-clocks-nail.md create mode 100644 apps/cli/src/commands/device/reinstallConfiguration.ts create mode 100644 libs/device-core/src/commands/entities/ReinstallConfigEntity.ts create mode 100644 libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.test.ts create mode 100644 libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.ts diff --git a/.changeset/strong-steaks-rule.md b/.changeset/strong-steaks-rule.md new file mode 100644 index 000000000000..a34f7f26842d --- /dev/null +++ b/.changeset/strong-steaks-rule.md @@ -0,0 +1,7 @@ +--- +"@ledgerhq/errors": patch +"ledger-live-desktop": patch +"live-mobile": patch +--- + +Add new PINNotSet error diff --git a/.changeset/twelve-owls-accept.md b/.changeset/twelve-owls-accept.md new file mode 100644 index 000000000000..55eb529c21f8 --- /dev/null +++ b/.changeset/twelve-owls-accept.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-core": minor +--- + +Add new reinstallConfiguration consent use case diff --git a/.changeset/wet-clocks-nail.md b/.changeset/wet-clocks-nail.md new file mode 100644 index 000000000000..54e2614a8313 --- /dev/null +++ b/.changeset/wet-clocks-nail.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-cli": minor +--- + +Implement reinstallConfiguration command diff --git a/apps/cli/package.json b/apps/cli/package.json index ac6f3a964c5c..85ffe792b07a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,8 @@ "@ledgerhq/coin-bitcoin": "workspace:^", "@ledgerhq/coin-framework": "workspace:^", "@ledgerhq/cryptoassets": "workspace:^", + "@ledgerhq/device-core": "workspace:^", + "@ledgerhq/devices": "workspace:^", "@ledgerhq/errors": "workspace:^", "@ledgerhq/hw-app-btc": "workspace:^", "@ledgerhq/hw-transport": "workspace:^", diff --git a/apps/cli/src/commands-index.ts b/apps/cli/src/commands-index.ts index 4a4242d7d592..48dbca8ad58b 100644 --- a/apps/cli/src/commands-index.ts +++ b/apps/cli/src/commands-index.ts @@ -47,6 +47,7 @@ import i18n from "./commands/device/i18n"; import listApps from "./commands/device/listApps"; import managerListApps from "./commands/device/managerListApps"; import proxy from "./commands/device/proxy"; +import reinstallConfiguration from "./commands/device/reinstallConfiguration"; import repl from "./commands/device/repl"; import speculosList from "./commands/device/speculosList"; import balanceHistory from "./commands/live/balanceHistory"; @@ -110,6 +111,7 @@ export default { listApps, managerListApps, proxy, + reinstallConfiguration, repl, speculosList, balanceHistory, diff --git a/apps/cli/src/commands/device/reinstallConfiguration.ts b/apps/cli/src/commands/device/reinstallConfiguration.ts new file mode 100644 index 000000000000..2aa5aed4b573 --- /dev/null +++ b/apps/cli/src/commands/device/reinstallConfiguration.ts @@ -0,0 +1,61 @@ +import { withDevice } from "@ledgerhq/live-common/hw/deviceAccess"; +import getDeviceInfo from "@ledgerhq/live-common/hw/getDeviceInfo"; +import customLockScreenFetchHash from "@ledgerhq/live-common/hw/customLockScreenFetchHash"; +import listApps from "@ledgerhq/live-common/hw/listApps"; +import { getAppStorageInfo, isCustomLockScreenSupported } from "@ledgerhq/device-core"; +import { reinstallConfiguration } from "@ledgerhq/device-core"; +import { ReinstallConfigArgs } from "@ledgerhq/device-core/commands/entities/ReinstallConfigEntity"; +import { identifyTargetId } from "@ledgerhq/devices"; +import { deviceOpt } from "../../scan"; +import { from, map, switchMap } from "rxjs"; + +export default { + description: + "Consent to allow restoring state of device after a firmware update (apps, language pack, custom lock screen and app data)", + args: [ + deviceOpt, + { + name: "format", + alias: "f", + type: String, + typeDesc: "raw | json | default", + }, + ], + job: ({ device }: { device: string }) => { + return withDevice(device || "")(t => + from(listApps(t)).pipe( + map(apps => apps.filter(app => !!app.name)), + switchMap(async apps => { + const reinstallAppsLength = apps.length; + let storageLength = 0; + for (const app of apps) { + const appStorageInfo = await getAppStorageInfo(t, app.name); + if (appStorageInfo) { + storageLength++; + } + } + const deviceInfo = await getDeviceInfo(t); + if (!deviceInfo.seTargetId) throw new Error("Cannot get device info"); + const deviceModel = identifyTargetId(deviceInfo.seTargetId); + if (!deviceModel) throw new Error("Cannot get device model"); + + const cls = isCustomLockScreenSupported(deviceModel.id) + ? await customLockScreenFetchHash(t) + : false; + + const langId = deviceInfo?.languageId ?? 0; + + const args: ReinstallConfigArgs = [ + langId > 0 ? 0x01 : 0x00, + cls ? 0x01 : 0x00, + reinstallAppsLength, + storageLength, + ]; + + return args; + }), + switchMap(args => reinstallConfiguration(t, args)), + ), + ); + }, +}; diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index f55c7b84e8d5..139391af17fe 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -6244,6 +6244,9 @@ }, "DeleteAppDataError": { "title": "Error deleting app data" + }, + "PinNotSet": { + "title": "PIN not set" } }, "cryptoOrg": { diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index c5812037e772..78196b972dd8 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -893,6 +893,9 @@ "NearStakingThresholdNotMet": { "title": "Amount needs to be at least {{threshold}}" }, + "PinNotSet": { + "title": "PIN not set" + }, "PriorityFeeTooLow": { "title": "Priority fee is lower than the recommended value" }, diff --git a/libs/device-core/src/commands/entities/ReinstallConfigEntity.ts b/libs/device-core/src/commands/entities/ReinstallConfigEntity.ts new file mode 100644 index 000000000000..1859cb2de20b --- /dev/null +++ b/libs/device-core/src/commands/entities/ReinstallConfigEntity.ts @@ -0,0 +1,15 @@ +export type ReinstallConfigArgs = [ + // 0x00 = false, 0x01 = true + ReinstallLanguagePack: 0x00 | 0x01, // 1 byte + ReinstallCustomLockScreen: 0x00 | 0x01, // 1 byte + ReinstallAppsNum: number, // 1 byte UINT8 + ReinstallAppDataNum: number, // 1 byte UINT8 +]; + +// TODO: model used when getting the config from the device before a software update +// export type ReinstallConfig = { +// languageId?: LanguageId, +// CustomLockScreen?: CustomLockScreen, +// reinstallApps: AppName[], +// reinstallStorage: AppName[], +// }; diff --git a/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.test.ts b/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.test.ts new file mode 100644 index 000000000000..73ad7fae2da6 --- /dev/null +++ b/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.test.ts @@ -0,0 +1,56 @@ +import Transport, { StatusCodes, TransportStatusError } from "@ledgerhq/hw-transport"; +import { reinstallConfiguration } from "./reinstallConfiguration"; +import { PinNotSet, UserRefusedOnDevice } from "@ledgerhq/errors"; + +describe("reinstallConfiguration", () => { + let transport: Transport; + + beforeEach(() => { + transport = { + send: jest.fn().mockResolvedValue(Buffer.from([])), + getTraceContext: jest.fn().mockResolvedValue(undefined), + } as unknown as Transport; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("success cases", () => { + it("should call the send function with correct parameters", async () => { + transport.send = jest.fn().mockResolvedValue(Buffer.from([0x90, 0x00])); + await reinstallConfiguration(transport, [0x00, 0x00, 0x00, 0x00]); + expect(transport.send).toHaveBeenCalledWith( + 0xe0, + 0x6f, + 0x00, + 0x00, + Buffer.from([0x00, 0x00, 0x00, 0x00]), + [StatusCodes.OK, StatusCodes.USER_REFUSED_ON_DEVICE, StatusCodes.PIN_NOT_SET], + ); + }); + }); + + describe("error cases", () => { + it("should throw UserRefusedOnDevice if the user refused on device", async () => { + transport.send = jest.fn().mockResolvedValue(Buffer.from([0x55, 0x01])); + await expect(reinstallConfiguration(transport, [0x00, 0x00, 0x00, 0x00])).rejects.toThrow( + new UserRefusedOnDevice("User refused on device"), + ); + }); + + it("should throw PINNotSet if the PIN is not set", async () => { + transport.send = jest.fn().mockResolvedValue(Buffer.from([0x55, 0x02])); + await expect(reinstallConfiguration(transport, [0x00, 0x00, 0x00, 0x00])).rejects.toThrow( + new PinNotSet("PIN not set"), + ); + }); + + it("should throw TransportStatusError if the response status is invalid", async () => { + transport.send = jest.fn().mockResolvedValue(Buffer.from([0x6f, 0x00])); + await expect(reinstallConfiguration(transport, [0x00, 0x00, 0x00, 0x00])).rejects.toThrow( + new TransportStatusError(0x6f00), + ); + }); + }); +}); diff --git a/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.ts b/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.ts new file mode 100644 index 000000000000..3b3623a0fc37 --- /dev/null +++ b/libs/device-core/src/commands/use-cases/consent/reinstallConfiguration.ts @@ -0,0 +1,75 @@ +import Transport, { StatusCodes, TransportStatusError } from "@ledgerhq/hw-transport"; +import { LocalTracer } from "@ledgerhq/logs"; +import { UserRefusedOnDevice, PinNotSet } from "@ledgerhq/errors"; +import type { APDU } from "../../entities/APDU"; +import type { ReinstallConfigArgs } from "../../entities/ReinstallConfigEntity"; + +/** + * Name in documentation: REINSTALL_CONFIG + * cla: 0xe0 + * ins: 0x6f + * p1: 0x00 + * p2: 0x00 + * data: CHUNK_LEN + CHUNK to configure at runtime + */ +const REINSTALL_CONFIG = [0xe0, 0x6f, 0x00, 0x00] as const; + +/** + * 0x9000: Success. + * 0xYYYY: already in REINSTALL mode + * 0xZZZZ: if other error (TBD) + */ +const RESPONSE_STATUS_SET: number[] = [ + StatusCodes.OK, + StatusCodes.USER_REFUSED_ON_DEVICE, + StatusCodes.PIN_NOT_SET, +]; + +/** + * Requests consent from the user to allow reinstalling all the previous + * settings after an OS update. + * + * @param transport - The transport object used to communicate with the device. + * @returns A promise that resolves when the consent is granted. + */ +export async function reinstallConfiguration( + transport: Transport, + args: ReinstallConfigArgs, +): Promise { + const tracer = new LocalTracer("hw", { + transport: transport.getTraceContext(), + function: "reinstallConfiguration", + }); + tracer.trace("Start"); + + const apdu: Readonly = [...REINSTALL_CONFIG, Buffer.from(args)]; + + const response = await transport.send(...apdu, RESPONSE_STATUS_SET); + + return parseResponse(response); +} + +/** + * Parses the response data buffer, check the status code and return the data. + * + * @param data - The response data buffer w/ status code. + * @returns The response data as a buffer w/o status code. + */ +export function parseResponse(data: Buffer): void { + const tracer = new LocalTracer("hw", { + function: "parseResponse@reinstallConfiguration", + }); + const status = data.readUInt16BE(data.length - 2); + tracer.trace("Result status from 0xe06f0000", { status }); + + switch (status) { + case StatusCodes.OK: + return; + case StatusCodes.USER_REFUSED_ON_DEVICE: + throw new UserRefusedOnDevice("User refused on device"); + case StatusCodes.PIN_NOT_SET: + throw new PinNotSet("PIN not set"); + default: + throw new TransportStatusError(status); + } +} diff --git a/libs/device-core/src/index.ts b/libs/device-core/src/index.ts index 71938e3267ba..ff39ef58fb37 100644 --- a/libs/device-core/src/index.ts +++ b/libs/device-core/src/index.ts @@ -42,3 +42,5 @@ export * from "./customLockScreen/screenSpecs"; export { shouldForceFirmwareUpdate } from "./firmwareUpdate/shouldForceFirmwareUpdate"; // errors export * from "./errors"; +// src/commands/consent/ +export { reinstallConfiguration } from "./commands/use-cases/consent/reinstallConfiguration"; diff --git a/libs/ledgerjs/packages/errors/src/index.ts b/libs/ledgerjs/packages/errors/src/index.ts index 362ac7f596d2..992aed2c31ce 100644 --- a/libs/ledgerjs/packages/errors/src/index.ts +++ b/libs/ledgerjs/packages/errors/src/index.ts @@ -125,6 +125,7 @@ export const UserRefusedAddress = createCustomErrorClass("UserRefusedAddress"); export const UserRefusedFirmwareUpdate = createCustomErrorClass("UserRefusedFirmwareUpdate"); export const UserRefusedAllowManager = createCustomErrorClass("UserRefusedAllowManager"); export const UserRefusedOnDevice = createCustomErrorClass("UserRefusedOnDevice"); // TODO rename because it's just for transaction refusal +export const PinNotSet = createCustomErrorClass("PinNotSet"); export const ExpertModeRequired = createCustomErrorClass("ExpertModeRequired"); export const TransportOpenUserCancelled = createCustomErrorClass("TransportOpenUserCancelled"); export const TransportInterfaceNotAvailable = createCustomErrorClass( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f3979fe02ac..0b32a5661a31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,12 @@ importers: '@ledgerhq/cryptoassets': specifier: workspace:^ version: link:../../libs/ledgerjs/packages/cryptoassets + '@ledgerhq/device-core': + specifier: workspace:^ + version: link:../../libs/device-core + '@ledgerhq/devices': + specifier: workspace:* + version: link:../../libs/ledgerjs/packages/devices '@ledgerhq/errors': specifier: workspace:^ version: link:../../libs/ledgerjs/packages/errors