Skip to content

Commit

Permalink
✨ (signer-btc) [DSDK-467]: Implement getSignMessageCommand (#525)
Browse files Browse the repository at this point in the history
  • Loading branch information
fAnselmi-Ledger authored Nov 29, 2024
2 parents d97c060 + 21e9de8 commit 56164a3
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-mayflies-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-signer-kit-bitcoin": minor
---

Implement GetSignMessageCommand
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
ApduResponse,
CommandResultFactory,
InvalidStatusWordError,
isSuccessCommandResult,
} from "@ledgerhq/device-management-kit";
import { Just } from "purify-ts";

import { type Signature } from "@api/model/Signature";

import { SignMessageCommand } from "./SignMessageCommand";

const PATH = "44'/0'/0'/0/0";
const MESSAGE = new TextEncoder().encode("Hello Bitcoin!");
const MESSAGE_LENGTH = MESSAGE.length;
const MSG_MERKLE_ROOT = new Uint8Array(32).fill(0xfa);

const getResponse = ({
omitR = false,
omitS = false,
}: {
omitR?: boolean;
omitS?: boolean;
} = {}) =>
new Uint8Array([
...(omitR ? [] : [0x1b]), // v
...(omitR
? []
: [
0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5,
0xa2, 0x3e, 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3,
0x42, 0x2d, 0x57, 0x55, 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e,
]), // r (32 bytes)
...(omitS
? []
: [
0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, 0xc1, 0x02, 0xc1, 0x64, 0xa2,
0x25, 0x53, 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, 0xef, 0xc4, 0x63,
0xf6, 0x7f, 0x60, 0xce, 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce,
]), // s (32 bytes)
]);

const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]);

const EXPECTED_APDU = new Uint8Array([
0xe1, // CLA
0x10, // INS
0x00, // P1
0x01, // P2
0x36, // Lc
// Data:
0x05, // Number of derivation steps (5)
// BIP32 path:
// 44' (0x8000002C)
0x80,
0x00,
0x00,
0x2c,
// 0' (0x80000000)
0x80,
0x00,
0x00,
0x00,
// 0' (0x80000000)
0x80,
0x00,
0x00,
0x00,
// 0 (0x00000000)
0x00,
0x00,
0x00,
0x00,
// 0 (0x00000000)
0x00,
0x00,
0x00,
0x00,
// Message length (varint)
0x0e, // 14 bytes ("Hello Bitcoin!")
// messageMerkleRoot
...MSG_MERKLE_ROOT,
]);

describe("SignMessageCommand", (): void => {
const defaultArgs = {
derivationPath: PATH,
messageLength: MESSAGE_LENGTH,
messageMerkleRoot: MSG_MERKLE_ROOT,
};

describe("getApdu", () => {
it("should return correct APDU for given arguments", () => {
// given
const command = new SignMessageCommand(defaultArgs);
// when
const apdu = command.getApdu();
// then
expect(apdu.getRawApdu()).toStrictEqual(EXPECTED_APDU);
});
});

describe("parseResponse", () => {
it("should return correct response after successful signing", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: new Uint8Array([0x90, 0x00]),
data: getResponse(),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(response).toStrictEqual(
CommandResultFactory({
data: Just<Signature>({
v: 27,
r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e",
s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce",
}),
}),
);
});

it("should return an error if user denied the operation", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: USER_DENIED_STATUS,
data: new Uint8Array([]),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(isSuccessCommandResult(response)).toBe(false);
if (!isSuccessCommandResult(response)) {
expect(response.error).toBeDefined();
}
});

it("should return an error when the response data is empty", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: new Uint8Array([0x90, 0x00]),
data: new Uint8Array([]),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(isSuccessCommandResult(response)).toBe(false);
expect(response).toStrictEqual(
CommandResultFactory({
error: new InvalidStatusWordError("V is missing"),
}),
);
});

it("should return correct data when the response data is not empty", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: new Uint8Array([0x90, 0x00]),
data: getResponse(),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(isSuccessCommandResult(response)).toBe(true);
if (isSuccessCommandResult(response)) {
expect(response.data.isJust()).toBe(true);
expect(response.data.extract()).toStrictEqual({
v: 27,
r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e",
s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce",
});
}
});

it("should return an error if 'r' is missing", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: new Uint8Array([0x90, 0x00]),
data: getResponse({ omitR: true }),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(response).toStrictEqual(
CommandResultFactory({
error: new InvalidStatusWordError("R is missing"),
}),
);
});

it("should return an error if 's' is missing", () => {
// given
const command = new SignMessageCommand(defaultArgs);
const apduResponse = new ApduResponse({
statusCode: new Uint8Array([0x90, 0x00]),
data: getResponse({ omitS: true }),
});
// when
const response = command.parseResponse(apduResponse);
// then
expect(response).toStrictEqual(
CommandResultFactory({
error: new InvalidStatusWordError("S is missing"),
}),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
type Apdu,
ApduBuilder,
ApduParser,
type ApduResponse,
type Command,
type CommandResult,
CommandResultFactory,
CommandUtils,
GlobalCommandErrorHandler,
InvalidStatusWordError,
isCommandErrorCode,
} from "@ledgerhq/device-management-kit";
import { DerivationPathUtils } from "@ledgerhq/signer-utils";
import { Just, type Maybe } from "purify-ts";

import { type Signature } from "@api/model/Signature";
import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants";
import { encodeVarint } from "@internal/utils/Varint";

import {
BitcoinAppCommandError,
bitcoinAppErrors,
} from "./utils/bitcoinAppErrors";

const R_LENGTH = 32;
const S_LENGTH = 32;

export type SignMessageCommandArgs = {
/**
* The BIP32 path (e.g., "m/44'/0'/0'/0/0")
*/
readonly derivationPath: string;
/**
* The total length of the message to be signed
*/
readonly messageLength: number;
/**
* The Merkle root of the message data
*/
readonly messageMerkleRoot: Uint8Array;
};

export type SignMessageCommandResponse = Maybe<Signature>;

export class SignMessageCommand
implements Command<SignMessageCommandResponse, SignMessageCommandArgs>
{
readonly args: SignMessageCommandArgs;

constructor(args: SignMessageCommandArgs) {
this.args = args;
}

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

const builder = new ApduBuilder({
cla: 0xe1,
ins: 0x10,
p1: 0x00,
p2: PROTOCOL_VERSION,
});

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

return builder
.addBufferToData(encodeVarint(messageLength).unsafeCoerce()) // Message length (varint)
.addBufferToData(messageMerkleRoot)
.build();
}

parseResponse(
apduResponse: ApduResponse,
): CommandResult<SignMessageCommandResponse> {
const parser = new ApduParser(apduResponse);
const errorCode = parser.encodeToHexaString(apduResponse.statusCode);
if (isCommandErrorCode(errorCode, bitcoinAppErrors)) {
return CommandResultFactory({
error: new BitcoinAppCommandError({
...bitcoinAppErrors[errorCode],
errorCode,
}),
});
}

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

// Extract 'v'
const v = parser.extract8BitUInt();
if (v === undefined) {
return CommandResultFactory({
error: new InvalidStatusWordError("V is missing"),
});
}

// Extract 'r'
const r = parser.encodeToHexaString(
parser.extractFieldByLength(R_LENGTH),
true,
);
if (!r) {
return CommandResultFactory({
error: new InvalidStatusWordError("R is missing"),
});
}

// Extract 's'
const s = parser.encodeToHexaString(
parser.extractFieldByLength(S_LENGTH),
true,
);
if (!s) {
return CommandResultFactory({
error: new InvalidStatusWordError("S is missing"),
});
}

return CommandResultFactory({
data: Just({
v,
r,
s,
}),
});
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//temp file, will be changed in a specific PR

import {
type CommandErrors,
DeviceExchangeError,
Expand Down

0 comments on commit 56164a3

Please sign in to comment.