From b50f80b9453eb9fe8f7fb1b7b213135c21ce7659 Mon Sep 17 00:00:00 2001 From: jz_ Date: Thu, 25 Apr 2024 16:34:47 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20(core):=20Improve=20te?= =?UTF-8?q?st=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/api/apdu/utils/ApduParser.ts | 72 ++++++++++--------- packages/core/src/api/command/Command.ts | 2 +- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/core/src/api/apdu/utils/ApduParser.ts b/packages/core/src/api/apdu/utils/ApduParser.ts index 525fd4502..5c3666cc9 100644 --- a/packages/core/src/api/apdu/utils/ApduParser.ts +++ b/packages/core/src/api/apdu/utils/ApduParser.ts @@ -23,80 +23,81 @@ export class ApduParser { * @param length: number * @returns {boolean} - Returns false if the length is greater than the response length */ - testMinimalLength = (length: number) => !(length > this._response.length); + testMinimalLength(length: number): boolean { + return length <= this._response.length; + } /** * Extract a single byte from the response * @returns {number | undefined} - Returns the byte extracted from the response */ - extract8BitUint = () => { - if (!this.testLength(1)) return; + extract8BitUint(): number | undefined { + if (this._outOfRange(1)) return; return this._response[this._index++]; - }; + } /** * Extract a 16-bit unsigned integer (Big Endian coding) from the response * @returns {number | undefined} - Returns the 16-bit unsigned integer extracted from the response */ - extract16BitUInt = () => { - if (!this.testLength(2)) return; + extract16BitUInt(): number | undefined { + if (this._outOfRange(2)) return; let msb = this.extract8BitUint(); if (!msb) return; const lsb = this.extract8BitUint(); if (!lsb) return; msb *= 0x100; return msb + lsb; - }; + } /** * Extract a 32-bit unsigned integer (Big Endian coding) from the response * @returns {number | undefined} - Returns the 32-bit unsigned integer extracted from the response */ - extract32BitUInt = () => { - if (!this.testLength(4)) return; + extract32BitUInt(): number | undefined { + if (this._outOfRange(4)) return; let msw = this.extract16BitUInt(); if (!msw) return; const lsw = this.extract16BitUInt(); if (!lsw) return; msw *= 0x10000; return msw + lsw; - }; + } /** * Extract a field of a specified length from the response * @param length: number - The length of the field to extract * @returns {Uint8Array | undefined} - Returns the field extracted from the response */ - extractFieldByLength = (length: number) => { - if (!this.testLength(length)) return; + extractFieldByLength(length: number): Uint8Array | undefined { + if (this._outOfRange(length)) return; if (length == 0) return new Uint8Array(); const field = this._response.slice(this._index, this._index + length); this._index += length; return field; - }; + } /** * Extract a field from the response that is length-value encoded * @returns {Uint8Array | undefined} - Returns the field extracted from the response */ - extractFieldLVEncoded = () => { + extractFieldLVEncoded(): Uint8Array | undefined { // extract Length field const length = this.extract8BitUint(); - if (length == 0) return new Uint8Array(); - if (!length) return; + if (length === undefined) return; + if (length === 0) return new Uint8Array(); const field = this.extractFieldByLength(length); - // if the field is inconsistent then roll back to the initial point if (!field) this._index--; return field; - }; + } /** * Extract a field from the response that is tag-length-value encoded * @returns {TaggedField | undefined} - Returns the field extracted from the response */ - extractFieldTLVEncoded = () => { - if (!this.testLength(2)) return; + extractFieldTLVEncoded(): TaggedField | undefined { + if (this._outOfRange(2)) return; // extract the tag field const tag = this.extract8BitUint(); @@ -108,7 +109,7 @@ export class ApduParser { return; } return { tag, value } as TaggedField; - }; + } /** * Encode a value to a hexadecimal string @@ -116,7 +117,7 @@ export class ApduParser { * @param prefix {boolean} - Whether to add a prefix to the encoded value * @returns {string} - The encoded value as a hexadecimal string */ - encodeToHexaString = (value?: Uint8Array, prefix?: boolean) => { + encodeToHexaString(value?: Uint8Array, prefix?: boolean): string { let result = ""; let index = 0; @@ -130,14 +131,14 @@ export class ApduParser { index++; } return result; - }; + } /** * Encode a value to an ASCII string * @param value {Uint8Array} - The value to encode * @returns {string} - The encoded value as an ASCII string */ - encodeToString = (value?: Uint8Array) => { + encodeToString(value?: Uint8Array): string { let result = ""; let index = 0; @@ -150,40 +151,41 @@ export class ApduParser { } return result; - }; + } /** * Get the current index of the parser * @returns {number} - The current index of the parser */ - getCurrentIndex = () => { + getCurrentIndex(): number { return this._index; - }; + } /** * Reset the index of the parser to 0 */ - resetIndex = () => { + resetIndex() { this._index = 0; - }; + } /** * Get the remaining length of the response * @returns {number} - The remaining length of the response */ - getUnparsedRemainingLength = () => { + getUnparsedRemainingLength(): number { return this._response.length - this._index; - }; + } // =========== // Private API // =========== /** - * Test if the length is greater than the response length + * Check whether the expected length is out of range * @param length: number - * @returns {boolean} - Returns false if the length is greater than the response length + * @returns {boolean} - Returns true if the expected length is out of range */ - private testLength = (length: number) => - !(this._index + length > this._response.length); + private _outOfRange(length: number): boolean { + return this._index + length > this._response.length; + } } diff --git a/packages/core/src/api/command/Command.ts b/packages/core/src/api/command/Command.ts index 7b4f35586..5cfd1390d 100644 --- a/packages/core/src/api/command/Command.ts +++ b/packages/core/src/api/command/Command.ts @@ -4,5 +4,5 @@ import { ApduResponse } from "@internal/device-session/model/ApduResponse"; export interface Command { getApdu(params?: Params): Apdu; - parseResponse(responseApdu: ApduResponse, deviceModelId: DeviceModelId): T; + parseResponse(responseApdu: ApduResponse, deviceModelId?: DeviceModelId): T; } From 3fa6950730af314c1496f1838883befcf8d3802b Mon Sep 17 00:00:00 2001 From: jz_ Date: Thu, 25 Apr 2024 16:35:40 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20GetAppAndVersio?= =?UTF-8?q?n=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/api/apdu/utils/ApduParser.ts | 4 +- packages/core/src/api/command/Command.ts | 2 +- .../os/GetAppAndVersionCommand.test.ts | 45 ++++++++++++++++ .../api/command/os/GetAppAndVersionCommand.ts | 53 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts create mode 100644 packages/core/src/api/command/os/GetAppAndVersionCommand.ts diff --git a/packages/core/src/api/apdu/utils/ApduParser.ts b/packages/core/src/api/apdu/utils/ApduParser.ts index 5c3666cc9..88e24032d 100644 --- a/packages/core/src/api/apdu/utils/ApduParser.ts +++ b/packages/core/src/api/apdu/utils/ApduParser.ts @@ -83,8 +83,8 @@ export class ApduParser { */ extractFieldLVEncoded(): Uint8Array | undefined { // extract Length field - const length = this.extract8BitUint(); - if (length === undefined) return; + const length = this.extract8BitUint() ?? -1; + if (length === -1) return; if (length === 0) return new Uint8Array(); const field = this.extractFieldByLength(length); // if the field is inconsistent then roll back to the initial point diff --git a/packages/core/src/api/command/Command.ts b/packages/core/src/api/command/Command.ts index 5cfd1390d..7d4f40f02 100644 --- a/packages/core/src/api/command/Command.ts +++ b/packages/core/src/api/command/Command.ts @@ -4,5 +4,5 @@ import { ApduResponse } from "@internal/device-session/model/ApduResponse"; export interface Command { getApdu(params?: Params): Apdu; - parseResponse(responseApdu: ApduResponse, deviceModelId?: DeviceModelId): T; + parseResponse(apduResponse: ApduResponse, deviceModelId?: DeviceModelId): T; } diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts new file mode 100644 index 000000000..ef15bb3b0 --- /dev/null +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts @@ -0,0 +1,45 @@ +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; +import { Command } from "../Command"; +import { + GetAppAndVersionResponse, + GetAppAndVersionCommand, +} from "./GetAppAndVersionCommand"; + +const GET_APP_AND_VERSION_APDU = Uint8Array.from([ + 0xb0, 0x01, 0x00, 0x00, 0x00, +]); + +const STAX_RESPONSE_HEX = Uint8Array.from([ + 0x01, 0x05, 0x42, 0x4f, 0x4c, 0x4f, 0x53, 0x09, 0x31, 0x2e, 0x34, 0x2e, 0x30, + 0x2d, 0x72, 0x63, 0x32, 0x90, 0x00, +]); + +describe("GetAppAndVersionCommand", () => { + let command: Command; + + beforeEach(() => { + command = new GetAppAndVersionCommand(); + }); + + describe("getApdu", () => { + it("should return the GetAppAndVersion APDU", () => { + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toStrictEqual(GET_APP_AND_VERSION_APDU); + }); + }); + + describe("parseResponse", () => { + it("should parse the GetAppAndVersion response", () => { + const STAX_APDU_RESPONSE = new ApduResponse({ + statusCode: STAX_RESPONSE_HEX.slice(-2), + data: STAX_RESPONSE_HEX.slice(0, -2), + }); + const parsed = command.parseResponse(STAX_APDU_RESPONSE); + const expected = { + name: "BOLOS", + version: "1.4.0-rc2", + }; + expect(parsed).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts new file mode 100644 index 000000000..63fcd6a50 --- /dev/null +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts @@ -0,0 +1,53 @@ +import { Apdu } from "@api/apdu/model/Apdu"; +import { ApduBuilder, ApduBuilderArgs } from "@api/apdu/utils/ApduBuilder"; +import { ApduParser } from "@api/apdu/utils/ApduParser"; +import { Command } from "@api/command/Command"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; +import { CommandUtils } from "@api/command/utils/CommandUtils"; + +export type GetAppAndVersionResponse = { + name: string; + version: string; + flags?: number | Uint8Array; +}; + +export class GetAppAndVersionCommand + implements Command +{ + getApdu(): Apdu { + const getAppAndVersionApduArgs: ApduBuilderArgs = { + cla: 0xb0, + ins: 0x01, + p1: 0x00, + p2: 0x00, + } as const; + return new ApduBuilder(getAppAndVersionApduArgs).build(); + } + + parseResponse(apduResponse: ApduResponse): GetAppAndVersionResponse { + const parser = new ApduParser(apduResponse); + // TODO: Check and handle unsuccessful response + if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new Error( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + + if (parser.extract8BitUint() !== 1) { + // TODO: Make dedicated error object + throw new Error("getAppAndVersion: format not supported"); + } + + const name = parser.encodeToString(parser.extractFieldLVEncoded()); + const version = parser.encodeToString(parser.extractFieldLVEncoded()); + + if (parser.getUnparsedRemainingLength() === 0) { + return { name, version } as GetAppAndVersionResponse; + } + + const flags = parser.extractFieldLVEncoded(); + return { name, version, flags } as GetAppAndVersionResponse; + } +} From d40f3e28b0893d81a916550417a4b7d0bcaa6e12 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 26 Apr 2024 15:39:07 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../os/GetAppAndVersionCommand.test.ts | 54 ++++++++++++++++--- .../api/command/os/GetAppAndVersionCommand.ts | 2 +- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts index ef15bb3b0..5226ff555 100644 --- a/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts @@ -9,11 +9,20 @@ const GET_APP_AND_VERSION_APDU = Uint8Array.from([ 0xb0, 0x01, 0x00, 0x00, 0x00, ]); -const STAX_RESPONSE_HEX = Uint8Array.from([ +const OS_RESPONSE_HEX = Uint8Array.from([ 0x01, 0x05, 0x42, 0x4f, 0x4c, 0x4f, 0x53, 0x09, 0x31, 0x2e, 0x34, 0x2e, 0x30, 0x2d, 0x72, 0x63, 0x32, 0x90, 0x00, ]); +const APP_RESPONSE_HEX = Uint8Array.from([ + 0x01, 0x07, 0x42, 0x69, 0x74, 0x63, 0x6f, 0x69, 0x6e, 0x0b, 0x32, 0x2e, 0x31, + 0x2e, 0x35, 0x2d, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01, 0x02, 0x90, 0x00, +]); + +const FAILED_RESPONSE_HEX = Uint8Array.from([0x67, 0x00]); + +const ERROR_RESPONSE_HEX = Uint8Array.from([0x04, 0x90, 0x00]); + describe("GetAppAndVersionCommand", () => { let command: Command; @@ -29,17 +38,50 @@ describe("GetAppAndVersionCommand", () => { }); describe("parseResponse", () => { - it("should parse the GetAppAndVersion response", () => { - const STAX_APDU_RESPONSE = new ApduResponse({ - statusCode: STAX_RESPONSE_HEX.slice(-2), - data: STAX_RESPONSE_HEX.slice(0, -2), + it("should parse the response when launching OS (dashboard)", () => { + const OS_RESPONSE = new ApduResponse({ + statusCode: OS_RESPONSE_HEX.slice(-2), + data: OS_RESPONSE_HEX.slice(0, -2), }); - const parsed = command.parseResponse(STAX_APDU_RESPONSE); + const parsed = command.parseResponse(OS_RESPONSE); const expected = { name: "BOLOS", version: "1.4.0-rc2", }; expect(parsed).toStrictEqual(expected); }); + it("should parse the response when launching App", () => { + const APP_RESPONSE = new ApduResponse({ + statusCode: APP_RESPONSE_HEX.slice(-2), + data: APP_RESPONSE_HEX.slice(0, -2), + }); + const parsed = command.parseResponse(APP_RESPONSE); + const expected = { + name: "Bitcoin", + version: "2.1.5-alpha", + flags: Uint8Array.from([2]), + }; + expect(parsed).toStrictEqual(expected); + }); + it("should throw an error if the command failed", () => { + const FAILED_RESPONSE = new ApduResponse({ + statusCode: FAILED_RESPONSE_HEX.slice(-2), + data: FAILED_RESPONSE_HEX.slice(0, -2), + }); + + expect(() => command.parseResponse(FAILED_RESPONSE)).toThrow( + "Unexpected status word: 6700", + ); + }); + it("should throw an error if the response returned unsupported format", () => { + const ERROR_RESPONSE = new ApduResponse({ + statusCode: ERROR_RESPONSE_HEX.slice(-2), + data: ERROR_RESPONSE_HEX.slice(0, -2), + }); + + expect(() => command.parseResponse(ERROR_RESPONSE)).toThrow( + "getAppAndVersion: format not supported", + ); + }); }); }); diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts index 63fcd6a50..6236b0303 100644 --- a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts @@ -26,7 +26,7 @@ export class GetAppAndVersionCommand parseResponse(apduResponse: ApduResponse): GetAppAndVersionResponse { const parser = new ApduParser(apduResponse); - // TODO: Check and handle unsuccessful response + // [SHOULD] Implement new error treatment logic if (!CommandUtils.isSuccessResponse(apduResponse)) { throw new Error( `Unexpected status word: ${parser.encodeToHexaString( From 0fc032a9332a81ca25e34404be979dbcfc4086b3 Mon Sep 17 00:00:00 2001 From: jz_ Date: Fri, 26 Apr 2024 15:47:45 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=96=20(core):=20Add=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shaggy-experts-flash.md | 5 +++++ .../core/src/api/command/os/GetAppAndVersionCommand.test.ts | 5 +++-- packages/core/src/api/command/os/GetAppAndVersionCommand.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .changeset/shaggy-experts-flash.md diff --git a/.changeset/shaggy-experts-flash.md b/.changeset/shaggy-experts-flash.md new file mode 100644 index 000000000..27127b07a --- /dev/null +++ b/.changeset/shaggy-experts-flash.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Implement GetAppAndVersion command. diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts index 5226ff555..826d182ca 100644 --- a/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts @@ -1,8 +1,9 @@ +import { Command } from "@api/command/Command"; import { ApduResponse } from "@internal/device-session/model/ApduResponse"; -import { Command } from "../Command"; + import { - GetAppAndVersionResponse, GetAppAndVersionCommand, + GetAppAndVersionResponse, } from "./GetAppAndVersionCommand"; const GET_APP_AND_VERSION_APDU = Uint8Array.from([ diff --git a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts index 6236b0303..39c949819 100644 --- a/packages/core/src/api/command/os/GetAppAndVersionCommand.ts +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.ts @@ -2,8 +2,8 @@ import { Apdu } from "@api/apdu/model/Apdu"; import { ApduBuilder, ApduBuilderArgs } from "@api/apdu/utils/ApduBuilder"; import { ApduParser } from "@api/apdu/utils/ApduParser"; import { Command } from "@api/command/Command"; -import { ApduResponse } from "@internal/device-session/model/ApduResponse"; import { CommandUtils } from "@api/command/utils/CommandUtils"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; export type GetAppAndVersionResponse = { name: string;