Skip to content

Commit

Permalink
✨ (keyring-btc) [DSDK-464]: Implement GetExtendedPublicKeyCommand (#282)
Browse files Browse the repository at this point in the history
  • Loading branch information
aussedatlo authored Sep 19, 2024
2 parents a73ad3e + c03eb92 commit f03c328
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-impalas-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/keyring-btc": patch
---

Implement GetExtendedPublicKeyCommand
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
ApduResponse,
CommandResultFactory,
InvalidStatusWordError,
isSuccessCommandResult,
UnknownDeviceExchangeError,
} from "@ledgerhq/device-sdk-core";

import {
GetExtendedPublicKeyCommand,
type GetExtendedPublicKeyCommandArgs,
} from "./GetExtendedPublicKeyCommand";

const GET_EXTENDED_PUBLIC_KEY_APDU_WITH_DISPLAY = new Uint8Array([
0xe1, 0x00, 0x00, 0x00, 0x0e, 0x01, 0x03, 0x80, 0x00, 0x00, 0x54, 0x80, 0x00,
0x00, 0x00, 0x80, 0x00, 0x00, 0x00,
]);

const GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_DISPLAY = new Uint8Array([
0xe1, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x03, 0x80, 0x00, 0x00, 0x54, 0x80, 0x00,
0x00, 0x00, 0x80, 0x00, 0x00, 0x00,
]);

const GET_EXTENDED_PUBLIC_KEY_APDU_WITH_OTHER_DERIVATION_PATH = new Uint8Array([
0xe1, 0x00, 0x00, 0x00, 0x12, 0x01, 0x04, 0x80, 0x00, 0x00, 0x31, 0x80, 0x00,
0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);

const GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE = new Uint8Array([
0x78, 0x70, 0x75, 0x62, 0x36, 0x44, 0x39, 0x50, 0x70, 0x34, 0x72, 0x46, 0x76,
0x77, 0x54, 0x47, 0x78, 0x38, 0x38, 0x59, 0x44, 0x34, 0x43, 0x44, 0x61, 0x31,
0x6e, 0x42, 0x45, 0x70, 0x63, 0x4b, 0x45, 0x5a, 0x54, 0x43, 0x4e, 0x46, 0x7a,
0x43, 0x46, 0x37, 0x67, 0x56, 0x50, 0x7a, 0x36, 0x54, 0x68, 0x39, 0x42, 0x61,
0x56, 0x68, 0x68, 0x50, 0x4a, 0x44, 0x75, 0x67, 0x39, 0x59, 0x59, 0x46, 0x50,
0x59, 0x6d, 0x6b, 0x53, 0x48, 0x4c, 0x66, 0x52, 0x31, 0x56, 0x51, 0x59, 0x6a,
0x35, 0x6a, 0x61, 0x79, 0x71, 0x77, 0x53, 0x59, 0x41, 0x52, 0x6e, 0x75, 0x42,
0x4a, 0x69, 0x50, 0x53, 0x44, 0x61, 0x62, 0x79, 0x79, 0x54, 0x69, 0x43, 0x44,
0x37, 0x42, 0x33, 0x63, 0x6a, 0x50, 0x71, 0x90, 0x00,
]);

describe("GetExtendedPublicKeyCommand", () => {
let command: GetExtendedPublicKeyCommand;
const defaultArgs: GetExtendedPublicKeyCommandArgs = {
displayOnDevice: true,
derivationPath: "84'/0'/0'",
};

beforeEach(() => {});

describe("getApdu", () => {
it("should return the correct APDU", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand(defaultArgs);

// WHEN
const apdu = command.getApdu();

//THEN
expect(apdu.getRawApdu()).toEqual(
GET_EXTENDED_PUBLIC_KEY_APDU_WITH_DISPLAY,
);
});

it("should return the correct APDU without display", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand({
...defaultArgs,
displayOnDevice: false,
});

// WHEN
const apdu = command.getApdu();

//THEN
expect(apdu.getRawApdu()).toEqual(
GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_DISPLAY,
);
});

it("should return the correct APDU with different derivation path", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand({
...defaultArgs,
derivationPath: "49'/0'/0'/0",
});

// WHEN
const apdu = command.getApdu();

//THEN
expect(apdu.getRawApdu()).toEqual(
GET_EXTENDED_PUBLIC_KEY_APDU_WITH_OTHER_DERIVATION_PATH,
);
});
});

describe("parseResponse", () => {
it("should return the extended public key", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand(defaultArgs);
const response = new ApduResponse({
data: GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE,
statusCode: new Uint8Array([0x90, 0x00]),
});

// WHEN
const result = command.parseResponse(response);

// THEN
expect(result).toEqual(
CommandResultFactory({
data: {
extendedPublicKey:
"xpub6D9Pp4rFvwTGx88YD4CDa1nBEpcKEZTCNFzCF7gVPz6Th9BaVhhPJDug9YYFPYmkSHLfR1VQYj5jayqwSYARnuBJiPSDabyyTiCD7B3cjPq",
},
}),
);
});

it("should return an error if the response is not successful", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand(defaultArgs);
const response = new ApduResponse({
statusCode: Uint8Array.from([0x6d, 0x00]),
data: new Uint8Array(0),
});

// WHEN
const result = command.parseResponse(response);

// THEN
if (!isSuccessCommandResult(result)) {
expect(result.error).toBeInstanceOf(UnknownDeviceExchangeError);
} else {
fail("Expected an error, but the result was successful");
}
});

it("should return an error if the response is too short", () => {
// GIVEN
command = new GetExtendedPublicKeyCommand(defaultArgs);
const response = new ApduResponse({
data: GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE.slice(0, 2),
statusCode: new Uint8Array([0x90, 0x00]),
});

// WHEN
const result = command.parseResponse(response);

// THEN
if (!isSuccessCommandResult(result)) {
expect(result.error).toEqual(
new InvalidStatusWordError("Invalid response length"),
);
} else {
fail("Expected an error, but the result was successful");
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// https://github.com/LedgerHQ/app-bitcoin-new/blob/master/doc/bitcoin.md#get_extended_pubkey
import {
Apdu,
ApduBuilder,
type ApduBuilderArgs,
ApduParser,
ApduResponse,
type Command,
type CommandResult,
CommandResultFactory,
CommandUtils,
GlobalCommandErrorHandler,
} from "@ledgerhq/device-sdk-core";
import { InvalidStatusWordError } from "@ledgerhq/device-sdk-core";

import { DerivationPathUtils } from "@internal/shared/utils/DerivationPathUtils";

const STATUS_CODE_LENGTH = 2;

export type GetExtendedPublicKeyCommandArgs = {
displayOnDevice: boolean;
derivationPath: string;
};

export type GetExtendedPublicKeyCommandResponse = {
extendedPublicKey: string;
};

export class GetExtendedPublicKeyCommand
implements
Command<
GetExtendedPublicKeyCommandResponse,
GetExtendedPublicKeyCommandArgs
>
{
constructor(private readonly args: GetExtendedPublicKeyCommandArgs) {}

getApdu(): Apdu {
const { displayOnDevice, derivationPath } = this.args;

const getExtendedPublicKeyArgs: ApduBuilderArgs = {
cla: 0xe1,
ins: 0x00,
p1: 0x00,
p2: 0x00,
};
const builder = new ApduBuilder(getExtendedPublicKeyArgs).add8BitUIntToData(
displayOnDevice ? 0x01 : 0x00,
);

const path = DerivationPathUtils.splitPath(derivationPath);
builder.add8BitUIntToData(path.length);
path.forEach((element) => {
builder.add32BitUIntToData(element);
});

return builder.build();
}

parseResponse(
response: ApduResponse,
): CommandResult<GetExtendedPublicKeyCommandResponse> {
const parser = new ApduParser(response);

if (!CommandUtils.isSuccessResponse(response)) {
return CommandResultFactory({
error: GlobalCommandErrorHandler.handle(response),
});
}

const length = parser.getUnparsedRemainingLength() - STATUS_CODE_LENGTH;

if (length <= 0) {
return CommandResultFactory({
error: new InvalidStatusWordError("Invalid response length"),
});
}

const extendedPublicKey = parser.encodeToString(
parser.extractFieldByLength(length),
);

return CommandResultFactory({
data: {
extendedPublicKey,
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DerivationPathUtils } from "./DerivationPathUtils";

const PADDING = 0x80000000;

describe("DerivationPathUtils", () => {
it("should split the derivation path", () => {
// GIVEN
const path = "44'/60/0/0/0";

// WHEN
const result = DerivationPathUtils.splitPath(path);

// THEN
expect(result).toStrictEqual([44 + PADDING, 60, 0, 0, 0]);
});

it("should split the derivation path with hardened path", () => {
// GIVEN
const path = "44'/60'/0'/0'/1";

// WHEN
const result = DerivationPathUtils.splitPath(path);

// THEN
expect(result).toStrictEqual([
44 + PADDING,
60 + PADDING,
0 + PADDING,
0 + PADDING,
1,
]);
});

it("should split the derivation path with custom path", () => {
// GIVEN
const path = "44'/60'/5/4/3";

// WHEN
const result = DerivationPathUtils.splitPath(path);

// THEN
expect(result).toStrictEqual([44 + PADDING, 60 + PADDING, 5, 4, 3]);
});

it("should throw an error if invalid number provided", () => {
// GIVEN
const path = "44'/60'/zzz/4/3";

// WHEN
const result = () => DerivationPathUtils.splitPath(path);

// THEN
expect(result).toThrow(new Error("Invalid number provided"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// TODO: Move to shared package and use in both keyring-btc and keyring-eth

export class DerivationPathUtils {
private static readonly PADDING = 0x80000000;

static splitPath(path: string): number[] {
const result: number[] = [];
const components = path.split("/");
components.forEach((element) => {
let number = parseInt(element, 10);
if (isNaN(number)) {
throw new Error("Invalid number provided");
}
if (element.length > 1 && element[element.length - 1] === "'") {
number += this.PADDING;
}
result.push(number);
});
return result;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// TODO: Move to shared package and use in both keyring-btc and keyring-eth

export class DerivationPathUtils {
static splitPath(path: string): number[] {
const result: number[] = [];
Expand Down

0 comments on commit f03c328

Please sign in to comment.