-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ (signer-btc): Implement getSignMessageCommand
- Loading branch information
1 parent
a877b80
commit 21e9de8
Showing
4 changed files
with
356 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@ledgerhq/device-signer-kit-bitcoin": minor | ||
--- | ||
|
||
Implement GetSignMessageCommand |
214 changes: 214 additions & 0 deletions
214
packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
}), | ||
); | ||
}); | ||
}); | ||
}); |
135 changes: 135 additions & 0 deletions
135
packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}), | ||
}); | ||
} | ||
} |
2 changes: 2 additions & 0 deletions
2
packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
|