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/apdu/utils/ApduParser.ts b/packages/core/src/api/apdu/utils/ApduParser.ts index 525fd4502..88e24032d 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; + 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 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..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..826d182ca --- /dev/null +++ b/packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts @@ -0,0 +1,88 @@ +import { Command } from "@api/command/Command"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +import { + GetAppAndVersionCommand, + GetAppAndVersionResponse, +} from "./GetAppAndVersionCommand"; + +const GET_APP_AND_VERSION_APDU = Uint8Array.from([ + 0xb0, 0x01, 0x00, 0x00, 0x00, +]); + +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; + + 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 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(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 new file mode 100644 index 000000000..39c949819 --- /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 { CommandUtils } from "@api/command/utils/CommandUtils"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +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); + // [SHOULD] Implement new error treatment logic + 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; + } +}