diff --git a/.changeset/blue-insects-punch.md b/.changeset/blue-insects-punch.md new file mode 100644 index 000000000..6dc178deb --- /dev/null +++ b/.changeset/blue-insects-punch.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Add SendCommand use case + GetOsVersion command diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts index da6381a71..ac318d966 100644 --- a/packages/core/jest.config.ts +++ b/packages/core/jest.config.ts @@ -4,7 +4,7 @@ import type { JestConfigWithTsJest } from "ts-jest"; const config: JestConfigWithTsJest = { preset: "@ledgerhq/jest-config-dsdk", setupFiles: ["/jest.setup.ts"], - testPathIgnorePatterns: ["/lib/"], + testPathIgnorePatterns: ["/lib/esm", "/lib/cjs"], collectCoverageFrom: [ "src/**/*.ts", "!src/**/*.stub.ts", diff --git a/packages/core/src/api/DeviceSdk.test.ts b/packages/core/src/api/DeviceSdk.test.ts index b701aee0e..76dba1a5c 100644 --- a/packages/core/src/api/DeviceSdk.test.ts +++ b/packages/core/src/api/DeviceSdk.test.ts @@ -7,6 +7,7 @@ import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; import pkg from "@root/package.json"; import { StubUseCase } from "@root/src/di.stub"; +import { commandTypes } from "./command/di/commandTypes"; import { ConsoleLogger } from "./logger-subscriber/service/ConsoleLogger"; import { DeviceSdk } from "./DeviceSdk"; @@ -49,6 +50,10 @@ describe("DeviceSdk", () => { it("should have getConnectedDevice method", () => { expect(sdk.getConnectedDevice).toBeDefined(); }); + + it("should have sendCommand method", () => { + expect(sdk.sendCommand).toBeDefined(); + }); }); describe("stubbed", () => { @@ -78,6 +83,7 @@ describe("DeviceSdk", () => { [discoveryTypes.StopDiscoveringUseCase], [discoveryTypes.ConnectUseCase], [sendTypes.SendApduUseCase], + [commandTypes.SendCommandUseCase], [usbDiTypes.GetConnectedDeviceUseCase], ])("should have %p use case", (diSymbol) => { const uc = sdk.container.get(diSymbol); diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index 485d3dae1..a7c3f521a 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -1,6 +1,11 @@ import { Container } from "inversify"; import { Observable } from "rxjs"; +import { commandTypes } from "@api/command/di/commandTypes"; +import { + SendCommandUseCase, + SendCommandUseCaseArgs, +} from "@api/command/use-case/SendCommandUseCase"; import { ConnectedDevice } from "@api/usb/model/ConnectedDevice"; import { configTypes } from "@internal/config/di/configTypes"; import { GetSdkVersionUseCase } from "@internal/config/use-case/GetSdkVersionUseCase"; @@ -66,6 +71,12 @@ export class DeviceSdk { .execute(args); } + sendCommand(args: SendCommandUseCaseArgs): Promise { + return this.container + .get(commandTypes.SendCommandUseCase) + .execute(args); + } + getConnectedDevice(args: GetConnectedDeviceUseCaseArgs): ConnectedDevice { return this.container .get(usbDiTypes.GetConnectedDeviceUseCase) diff --git a/packages/core/src/api/command/Command.ts b/packages/core/src/api/command/Command.ts index 2d1e47f4e..7b4f35586 100644 --- a/packages/core/src/api/command/Command.ts +++ b/packages/core/src/api/command/Command.ts @@ -1,6 +1,8 @@ import { Apdu } from "@api/apdu/model/Apdu"; +import { DeviceModelId } from "@internal/device-model/model/DeviceModel"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; export interface Command { getApdu(params?: Params): Apdu; - parseResponse(responseApdu: Apdu): T; + parseResponse(responseApdu: ApduResponse, deviceModelId: DeviceModelId): T; } diff --git a/packages/core/src/api/command/di/commandModule.test.ts b/packages/core/src/api/command/di/commandModule.test.ts new file mode 100644 index 000000000..6a7d3cb6d --- /dev/null +++ b/packages/core/src/api/command/di/commandModule.test.ts @@ -0,0 +1,51 @@ +import { Container } from "inversify"; + +import { SendCommandUseCase } from "@api/command/use-case/SendCommandUseCase"; +import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; +import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; +import { StubUseCase } from "@root/src/di.stub"; + +import { commandModuleFactory } from "./commandModule"; +import { commandTypes } from "./commandTypes"; + +describe("commandModuleFactory", () => { + describe("Default", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = commandModuleFactory(); + container = new Container(); + container.load(mod, deviceSessionModuleFactory(), loggerModuleFactory()); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return non-stubbed sendCommand usecase", () => { + const sendCommandUseCase = container.get( + commandTypes.SendCommandUseCase, + ); + expect(sendCommandUseCase).toBeInstanceOf(SendCommandUseCase); + }); + }); + + describe("Stubbed", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = commandModuleFactory({ stub: true }); + container = new Container(); + container.load(mod); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return stubbed sendCommand usecase", () => { + const sendCommandUseCase = container.get(commandTypes.SendCommandUseCase); + expect(sendCommandUseCase).toBeInstanceOf(StubUseCase); + }); + }); +}); diff --git a/packages/core/src/api/command/di/commandModule.ts b/packages/core/src/api/command/di/commandModule.ts new file mode 100644 index 000000000..32f0657a6 --- /dev/null +++ b/packages/core/src/api/command/di/commandModule.ts @@ -0,0 +1,30 @@ +import { ContainerModule } from "inversify"; + +import { SendCommandUseCase } from "@api/command/use-case/SendCommandUseCase"; +import { StubUseCase } from "@root/src/di.stub"; + +import { commandTypes } from "./commandTypes"; + +type CommandModuleArgs = Partial<{ + stub: boolean; +}>; + +export const commandModuleFactory = ({ + stub = false, +}: CommandModuleArgs = {}) => + new ContainerModule( + ( + bind, + _unbind, + _isBound, + rebind, + _unbindAsync, + _onActivation, + _onDeactivation, + ) => { + bind(commandTypes.SendCommandUseCase).to(SendCommandUseCase); + if (stub) { + rebind(commandTypes.SendCommandUseCase).to(StubUseCase); + } + }, + ); diff --git a/packages/core/src/api/command/di/commandTypes.ts b/packages/core/src/api/command/di/commandTypes.ts new file mode 100644 index 000000000..2ab6ed617 --- /dev/null +++ b/packages/core/src/api/command/di/commandTypes.ts @@ -0,0 +1,3 @@ +export const commandTypes = { + SendCommandUseCase: Symbol.for("SendCommandUseCase"), +}; diff --git a/packages/core/src/api/command/os/GetOsVersionCommand.test.ts b/packages/core/src/api/command/os/GetOsVersionCommand.test.ts new file mode 100644 index 000000000..8df4e9eea --- /dev/null +++ b/packages/core/src/api/command/os/GetOsVersionCommand.test.ts @@ -0,0 +1,136 @@ +import { Command } from "@api/command/Command"; +import { DeviceModelId } from "@api/types"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +import { + GetOsVersionCommand, + GetOsVersionResponse, +} from "./GetOsVersionCommand"; + +const GET_OS_VERSION_APDU = Uint8Array.from([0xe0, 0x01, 0x00, 0x00, 0x00]); + +const LNX_RESPONSE_DATA_GOOD = Uint8Array.from([ + 0x33, 0x00, 0x00, 0x04, 0x05, 0x32, 0x2e, 0x32, 0x2e, 0x33, 0x04, 0xe6, 0x00, + 0x00, 0x00, 0x04, 0x32, 0x2e, 0x33, 0x30, 0x04, 0x31, 0x2e, 0x31, 0x36, 0x01, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x90, 0x00, +]); +const LNX_RESPONSE_GOOD = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA_GOOD, +}); + +const LNSP_REPONSE_DATA_GOOD = Uint8Array.from([ + 0x33, 0x10, 0x00, 0x04, 0x05, 0x31, 0x2e, 0x31, 0x2e, 0x31, 0x04, 0xe6, 0x00, + 0x00, 0x00, 0x04, 0x34, 0x2e, 0x30, 0x33, 0x04, 0x33, 0x2e, 0x31, 0x32, 0x01, + 0x00, 0x01, 0x00, 0x90, 0x00, +]); +const LNSP_RESPONSE_GOOD = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNSP_REPONSE_DATA_GOOD, +}); + +const STAX_RESPONSE_DATA_GOOD = Uint8Array.from([ + 0x33, 0x20, 0x00, 0x04, 0x05, 0x31, 0x2e, 0x33, 0x2e, 0x30, 0x04, 0xe6, 0x00, + 0x00, 0x00, 0x04, 0x35, 0x2e, 0x32, 0x34, 0x04, 0x30, 0x2e, 0x34, 0x38, 0x01, + 0x00, 0x01, 0x00, 0x90, 0x00, +]); +const STAX_RESPONSE_GOOD = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: STAX_RESPONSE_DATA_GOOD, +}); + +describe("GetOsVersionCommand", () => { + let command: Command; + + beforeEach(() => { + command = new GetOsVersionCommand(); + }); + + describe("getApdu", () => { + it("should return the GetOsVersion apdu", () => { + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toStrictEqual(GET_OS_VERSION_APDU); + }); + }); + + describe("parseResponse", () => { + describe("Nano X", () => { + it("should parse the LNX response", () => { + const parsed = command.parseResponse( + LNX_RESPONSE_GOOD, + DeviceModelId.NANO_X, + ); + + const expected = { + targetId: "33000004", + seVersion: "2.2.3", + seFlags: 3858759680, + mcuSephVersion: "2.30", + mcuBootloaderVersion: "1.16", + hwVersion: "00", + langId: "00", + recoverState: "00", + }; + + expect(parsed).toStrictEqual(expected); + }); + }); + + describe("Nano S Plus", () => { + it("should parse the LNSP response", () => { + const parsed = command.parseResponse( + LNSP_RESPONSE_GOOD, + DeviceModelId.NANO_SP, + ); + + const expected = { + targetId: "33100004", + seVersion: "1.1.1", + seFlags: 3858759680, + mcuSephVersion: "4.03", + mcuBootloaderVersion: "3.12", + hwVersion: "00", + langId: "00", + recoverState: "00", + }; + + expect(parsed).toStrictEqual(expected); + }); + }); + + describe("Stax", () => { + it("should parse the STAX response", () => { + const parsed = command.parseResponse( + STAX_RESPONSE_GOOD, + DeviceModelId.STAX, + ); + + const expected = { + targetId: "33200004", + seVersion: "1.3.0", + seFlags: 3858759680, + mcuSephVersion: "5.24", + mcuBootloaderVersion: "0.48", + hwVersion: "00", + langId: "00", + recoverState: "00", + }; + + expect(parsed).toStrictEqual(expected); + }); + }); + + describe("Error handling", () => { + it("should throw an error if the response is not successful", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x6e, 0x80]), + data: Uint8Array.from([]), + }); + + expect(() => + command.parseResponse(response, DeviceModelId.NANO_X), + ).toThrow("Unexpected status word: 6e80"); + }); + }); + }); +}); diff --git a/packages/core/src/api/command/os/GetOsVersionCommand.ts b/packages/core/src/api/command/os/GetOsVersionCommand.ts new file mode 100644 index 000000000..84c8fca69 --- /dev/null +++ b/packages/core/src/api/command/os/GetOsVersionCommand.ts @@ -0,0 +1,74 @@ +import { Apdu } from "@api/apdu/model/Apdu"; +import { ApduBuilder } from "@api/apdu/utils/ApduBuilder"; +import { ApduParser } from "@api/apdu/utils/ApduParser"; +import { Command } from "@api/command/Command"; +import { CommandUtils } from "@api/command/utils/CommandUtils"; +import { DeviceModelId } from "@api/types"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +export type GetOsVersionResponse = { + targetId: string; + seVersion: string; + seFlags: number; + mcuSephVersion: string; + mcuBootloaderVersion: string; + hwVersion: string; + langId: string; + recoverState: string; +}; + +export class GetOsVersionCommand + implements Command +{ + getApdu = (): Apdu => + new ApduBuilder({ + cla: 0xe0, + ins: 0x01, + p1: 0x00, + p2: 0x00, + }).build(); + + parseResponse(responseApdu: ApduResponse, deviceModelId: DeviceModelId) { + const parser = new ApduParser(responseApdu); + if (!CommandUtils.isSuccessResponse(responseApdu)) { + // [ASK] How de we handle unsuccessful responses? + throw new Error( + `Unexpected status word: ${parser.encodeToHexaString(responseApdu.statusCode)}`, + ); + } + + const targetId = parser.encodeToHexaString(parser.extractFieldByLength(4)); + const seVersion = parser.encodeToString(parser.extractFieldLVEncoded()); + const seFlags = parseInt( + parser.encodeToHexaString(parser.extractFieldLVEncoded()).toString(), + 16, + ); + const mcuSephVersion = parser.encodeToString( + parser.extractFieldLVEncoded(), + ); + const mcuBootloaderVersion = parser.encodeToString( + parser.extractFieldLVEncoded(), + ); + + let hwVersion = "00"; + if (deviceModelId === DeviceModelId.NANO_X) { + hwVersion = parser.encodeToHexaString(parser.extractFieldLVEncoded()); + } + + const langId = parser.encodeToHexaString(parser.extractFieldLVEncoded()); + const recoverState = parser.encodeToHexaString( + parser.extractFieldLVEncoded(), + ); + + return { + targetId, + seVersion, + seFlags, + mcuSephVersion, + mcuBootloaderVersion, + hwVersion, + langId, + recoverState: recoverState ? recoverState : "0", + }; + } +} diff --git a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts new file mode 100644 index 000000000..8ee94dbaf --- /dev/null +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts @@ -0,0 +1,64 @@ +import { Left } from "purify-ts"; + +import { Command } from "@api/command/Command"; +import { sessionStubBuilder } from "@internal/device-session/model/Session.stub"; +import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; +import { SessionService } from "@internal/device-session/service/SessionService"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +import { + SendCommandUseCase, + // SendCommandUseCaseArgs, +} from "./SendCommandUseCase"; + +let logger: LoggerPublisherService; +let sessionService: SessionService; +const fakeSessionId = "fakeSessionId"; +let command: Command; + +describe("SendCommandUseCase", () => { + beforeEach(() => { + logger = new DefaultLoggerPublisherService([], "send-command-use-case"); + sessionService = new DefaultSessionService(() => logger); + command = { + getApdu: jest.fn(), + parseResponse: jest.fn(), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should send a command to a connected device", async () => { + const session = sessionStubBuilder(); + sessionService.addSession(session); + const useCase = new SendCommandUseCase(sessionService, () => logger); + + jest + .spyOn(session, "getCommand") + .mockReturnValue(async () => Promise.resolve({ status: "success" })); + + const response = await useCase.execute({ + sessionId: fakeSessionId, + command, + }); + + expect(response).toStrictEqual({ status: "success" }); + }); + + it("should throw an error if the session is not found", async () => { + const useCase = new SendCommandUseCase(sessionService, () => logger); + jest + .spyOn(sessionService, "getSessionById") + .mockReturnValue(Left({ _tag: "DeviceSessionNotFound" })); + + const res = useCase.execute({ + sessionId: fakeSessionId, + command, + }); + + await expect(res).rejects.toMatchObject({ _tag: "DeviceSessionNotFound" }); + }); +}); diff --git a/packages/core/src/api/command/use-case/SendCommandUseCase.ts b/packages/core/src/api/command/use-case/SendCommandUseCase.ts new file mode 100644 index 000000000..686424350 --- /dev/null +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.ts @@ -0,0 +1,55 @@ +import { inject, injectable } from "inversify"; + +import { Command } from "@api/command/Command"; +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import type { SessionService } from "@internal/device-session/service/SessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export type SendCommandUseCaseArgs = { + sessionId: string; + command: Command; + params?: Params; +}; + +/** + * Sends a command to a device through a device session. + */ +@injectable() +export class SendCommandUseCase { + private readonly _sessionService: SessionService; + private readonly _logger: LoggerPublisherService; + constructor( + @inject(deviceSessionTypes.SessionService) sessionService: SessionService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this._sessionService = sessionService; + this._logger = loggerFactory("SendCommandUseCase"); + } + + async execute({ + sessionId, + command, + params, + }: SendCommandUseCaseArgs): Promise { + const deviceSession = this._sessionService.getSessionById(sessionId); + + return deviceSession.caseOf({ + // Case device session found + Right: async (session) => { + const deviceModelId = session.connectedDevice.deviceModel.id; + const action = session.getCommand(command); + const response = await action(deviceModelId, params); + return response; + }, + // Case device session not found + Left: (error) => { + this._logger.error("Error getting session", { + data: { error }, + }); + throw error; + }, + }); + } +} diff --git a/packages/core/src/api/command/utils/CommandUtils.test.ts b/packages/core/src/api/command/utils/CommandUtils.test.ts new file mode 100644 index 000000000..e640b83bf --- /dev/null +++ b/packages/core/src/api/command/utils/CommandUtils.test.ts @@ -0,0 +1,34 @@ +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +import { CommandUtils } from "./CommandUtils"; + +describe("CommandUtils", () => { + describe("static isSuccessResponse", () => { + it("should return true if the status code is 0x9000", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + + expect(CommandUtils.isSuccessResponse(response)).toBe(true); + }); + + it("should return false if the status code is not 0x9000", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x6e, 0x80]), + data: Uint8Array.from([]), + }); + + expect(CommandUtils.isSuccessResponse(response)).toBe(false); + }); + + it("should return false if the status code is not 2 bytes long", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x90]), + data: Uint8Array.from([]), + }); + + expect(CommandUtils.isSuccessResponse(response)).toBe(false); + }); + }); +}); diff --git a/packages/core/src/api/command/utils/CommandUtils.ts b/packages/core/src/api/command/utils/CommandUtils.ts new file mode 100644 index 000000000..a4a1bd5d2 --- /dev/null +++ b/packages/core/src/api/command/utils/CommandUtils.ts @@ -0,0 +1,11 @@ +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +export class CommandUtils { + static isSuccessResponse({ statusCode }: ApduResponse) { + if (statusCode.length !== 2) { + return false; + } + + return statusCode[0] === 0x90 && statusCode[1] === 0x00; + } +} diff --git a/packages/core/src/di.ts b/packages/core/src/di.ts index 90fb8e264..2d2462389 100644 --- a/packages/core/src/di.ts +++ b/packages/core/src/di.ts @@ -1,5 +1,6 @@ import { Container } from "inversify"; +import { commandModuleFactory } from "@api/command/di/commandModule"; import { LoggerSubscriberService } from "@api/logger-subscriber/service/LoggerSubscriberService"; // Uncomment this line to enable the logger middleware // import { makeLoggerMiddleware } from "inversify-logger-middleware"; @@ -36,6 +37,7 @@ export const makeContainer = ({ loggerModuleFactory({ subscribers: loggers }), deviceSessionModuleFactory(), sendModuleFactory({ stub }), + commandModuleFactory({ stub }), // modules go here ); diff --git a/packages/core/src/internal/device-session/model/Session.ts b/packages/core/src/internal/device-session/model/Session.ts index 20f93ee39..4fb4d109e 100644 --- a/packages/core/src/internal/device-session/model/Session.ts +++ b/packages/core/src/internal/device-session/model/Session.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import { Command } from "@api/command/Command"; +import { DeviceModelId } from "@api/types"; import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; export type SessionId = string; @@ -30,18 +31,21 @@ export class Session { return this._connectedDevice; } - sendApdu(_args: Uint8Array) { - return this._connectedDevice.sendApdu(_args); + sendApdu(rawApdu: Uint8Array) { + return this._connectedDevice.sendApdu(rawApdu); } - executeCommand( - _params: Params, - _command: Command, - ): Promise { - // const apdu = command.getApdu(params); - // do some magic with apdu - // const response = command.parseResponse(); - // return response; - throw new Error("Method not implemented."); + getCommand(command: Command) { + return async (deviceModel: DeviceModelId, params?: Params): Promise => { + const apdu = command.getApdu(params); + const response = await this.sendApdu(apdu.getRawApdu()); + + return response.caseOf({ + Left: (err) => { + throw err; + }, + Right: (r) => command.parseResponse(r, deviceModel), + }); + }; } }