Skip to content

Commit

Permalink
Merge pull request #7771 from LedgerHQ/feat/new-super-consent
Browse files Browse the repository at this point in the history
✨ (device-core): add new reinstallConfiguration consent use case
  • Loading branch information
valpinkman authored Sep 10, 2024
2 parents 7b6d676 + 0a71c43 commit 7f53095
Show file tree
Hide file tree
Showing 14 changed files with 247 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .changeset/strong-steaks-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ledgerhq/errors": patch
"ledger-live-desktop": patch
"live-mobile": patch
---

Add new PINNotSet error
5 changes: 5 additions & 0 deletions .changeset/twelve-owls-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-core": minor
---

Add new reinstallConfiguration consent use case
5 changes: 5 additions & 0 deletions .changeset/wet-clocks-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/live-cli": minor
---

Implement reinstallConfiguration command
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 reinstallConfigurationConsent from "./commands/device/reinstallConfigurationConsent";
import repl from "./commands/device/repl";
import speculosList from "./commands/device/speculosList";
import balanceHistory from "./commands/live/balanceHistory";
Expand Down Expand Up @@ -110,6 +111,7 @@ export default {
listApps,
managerListApps,
proxy,
reinstallConfigurationConsent,
repl,
speculosList,
balanceHistory,
Expand Down
64 changes: 64 additions & 0 deletions apps/cli/src/commands/device/reinstallConfigurationConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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,
reinstallConfigurationConsent,
ReinstallConfigArgs,
} from "@ledgerhq/device-core";
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 => reinstallConfigurationConsent(t, args)),
),
);
},
};
3 changes: 3 additions & 0 deletions apps/ledger-live-desktop/static/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -6247,6 +6247,9 @@
},
"DeleteAppDataError": {
"title": "Error deleting app data"
},
"PinNotSet": {
"title": "PIN not set"
}
},
"cryptoOrg": {
Expand Down
3 changes: 3 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
15 changes: 15 additions & 0 deletions libs/device-core/src/commands/entities/ReinstallConfigEntity.ts
Original file line number Diff line number Diff line change
@@ -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[],
// };
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Transport, { StatusCodes, TransportStatusError } from "@ledgerhq/hw-transport";
import { reinstallConfigurationConsent } from "./reinstallConfigurationConsent";
import { PinNotSet, UserRefusedOnDevice } from "@ledgerhq/errors";

describe("reinstallConfigurationConsent", () => {
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 reinstallConfigurationConsent(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(
reinstallConfigurationConsent(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(
reinstallConfigurationConsent(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(
reinstallConfigurationConsent(transport, [0x00, 0x00, 0x00, 0x00]),
).rejects.toThrow(new TransportStatusError(0x6f00));
});
});
});
Original file line number Diff line number Diff line change
@@ -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 reinstallConfigurationConsent(
transport: Transport,
args: ReinstallConfigArgs,
): Promise<void> {
const tracer = new LocalTracer("hw", {
transport: transport.getTraceContext(),
function: "reinstallConfigurationConsent",
});
tracer.trace("Start");

const apdu: Readonly<APDU> = [...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@reinstallConfigurationConsent",
});
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);
}
}
3 changes: 3 additions & 0 deletions libs/device-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
OsuFirmware,
FirmwareUpdateContextEntity,
} from "./managerApi/entities/FirmwareUpdateContextEntity";
export type { ReinstallConfigArgs } from "./commands/entities/ReinstallConfigEntity";
export type { ManagerApiRepository } from "./managerApi/repositories/ManagerApiRepository";
export { HttpManagerApiRepository } from "./managerApi/repositories/HttpManagerApiRepository";
export { StubManagerApiRepository } from "./managerApi/repositories/StubManagerApiRepository";
Expand Down Expand Up @@ -42,3 +43,5 @@ export * from "./customLockScreen/screenSpecs";
export { shouldForceFirmwareUpdate } from "./firmwareUpdate/shouldForceFirmwareUpdate";
// errors
export * from "./errors";
// src/commands/consent/
export { reinstallConfigurationConsent } from "./commands/use-cases/consent/reinstallConfigurationConsent";
1 change: 1 addition & 0 deletions libs/ledgerjs/packages/errors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7f53095

Please sign in to comment.