Skip to content

Commit

Permalink
✨ (core) [DSDK-288]: Implement GetAppAndVersion command (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
jiyuzhuang authored May 2, 2024
2 parents eb313db + 0fc032a commit efa7d38
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/shaggy-experts-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-sdk-core": minor
---

Implement GetAppAndVersion command.
74 changes: 38 additions & 36 deletions packages/core/src/api/apdu/utils/ApduParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -108,15 +109,15 @@ export class ApduParser {
return;
}
return { tag, value } as TaggedField;
};
}

/**
* Encode a value to a hexadecimal string
* @param value {Uint8Array} - The value to encode
* @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;

Expand All @@ -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;

Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion packages/core/src/api/command/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { ApduResponse } from "@internal/device-session/model/ApduResponse";

export interface Command<Params, T> {
getApdu(params?: Params): Apdu;
parseResponse(responseApdu: ApduResponse, deviceModelId: DeviceModelId): T;
parseResponse(apduResponse: ApduResponse, deviceModelId?: DeviceModelId): T;
}
88 changes: 88 additions & 0 deletions packages/core/src/api/command/os/GetAppAndVersionCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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<void, GetAppAndVersionResponse>;

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",
);
});
});
});
53 changes: 53 additions & 0 deletions packages/core/src/api/command/os/GetAppAndVersionCommand.ts
Original file line number Diff line number Diff line change
@@ -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<void, GetAppAndVersionResponse>
{
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;
}
}

0 comments on commit efa7d38

Please sign in to comment.