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

✨ (device-core): add new reinstallConfiguration consent use case #7771

Merged
merged 1 commit into from
Sep 10, 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
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 @@ -6244,6 +6244,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.

Loading