diff --git a/.changeset/slimy-toes-march.md b/.changeset/slimy-toes-march.md index adf97840e..7ba239e62 100644 --- a/.changeset/slimy-toes-march.md +++ b/.changeset/slimy-toes-march.md @@ -2,4 +2,4 @@ "@ledgerhq/device-signer-kit-bitcoin": minor --- -Implement ClientCommands +Implement SignMessage diff --git a/apps/sample/package.json b/apps/sample/package.json index 92c849a0d..bdd162fd5 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -21,6 +21,7 @@ "@ledgerhq/device-mockserver-client": "workspace:*", "@ledgerhq/device-signer-kit-ethereum": "workspace:*", "@ledgerhq/device-signer-kit-solana": "workspace:*", + "@ledgerhq/device-signer-kit-bitcoin": "workspace:*", "@ledgerhq/device-transport-kit-mockserver": "workspace:*", "@ledgerhq/device-transport-kit-web-ble": "workspace:*", "@ledgerhq/device-transport-kit-web-hid": "workspace:*", diff --git a/apps/sample/src/app/signer/bitcoin/page.tsx b/apps/sample/src/app/signer/bitcoin/page.tsx new file mode 100644 index 000000000..be89491a2 --- /dev/null +++ b/apps/sample/src/app/signer/bitcoin/page.tsx @@ -0,0 +1,11 @@ +"use client"; +import React from "react"; + +import { SessionIdWrapper } from "@/components/SessionIdWrapper"; +import { SignerBitcoinView } from "@/components/SignerBtcView"; + +const Signer: React.FC = () => { + return ; +}; + +export default Signer; diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx new file mode 100644 index 000000000..4b50dc4ec --- /dev/null +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from "react"; +import { + SignerBtcBuilder, + type SignMessageDAError, + type SignMessageDAIntermediateValue, + type SignMessageDAOutput, +} from "@ledgerhq/device-signer-kit-bitcoin"; + +import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList"; +import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester"; +import { useDmk } from "@/providers/DeviceManagementKitProvider"; + +const DEFAULT_DERIVATION_PATH = "44'/501'/0'/0'"; + +export const SignerBitcoinView: React.FC<{ sessionId: string }> = ({ + sessionId, +}) => { + const dmk = useDmk(); + const signer = new SignerBtcBuilder({ dmk, sessionId }).build(); + + const deviceModelId = dmk.getConnectedDevice({ + sessionId, + }).modelId; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deviceActions: DeviceActionProps[] = useMemo( + () => [ + { + title: "Sign message", + description: + "Perform all the actions necessary to sign a message with the device", + executeDeviceAction: ({ derivationPath, message }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + return signer.signMessage(derivationPath, message); + }, + initialValues: { + derivationPath: DEFAULT_DERIVATION_PATH, + message: "Hello World", + }, + deviceModelId, + } satisfies DeviceActionProps< + SignMessageDAOutput, + { + derivationPath: string; + message: string; + }, + SignMessageDAError, + SignMessageDAIntermediateValue + >, + ], + [deviceModelId, signer], + ); + + return ( + + ); +}; diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts index 64e25d512..1bc3ee428 100644 --- a/packages/signer/signer-btc/src/api/SignerBtc.ts +++ b/packages/signer/signer-btc/src/api/SignerBtc.ts @@ -3,15 +3,21 @@ // import { type Signature } from "@api/model/Signature"; // import { type Wallet } from "@api/model/Wallet"; import { type AddressOptions } from "@api/model/AddressOptions"; -import { type GetExtendedPublicKeyReturnType } from "@root/src"; +import { + type GetExtendedPublicKeyReturnType, + type SignMessageDAReturnType, +} from "@root/src"; export interface SignerBtc { getExtendedPublicKey: ( derivationPath: string, options: AddressOptions, ) => GetExtendedPublicKeyReturnType; + signMessage: ( + derivationPath: string, + message: string, + ) => SignMessageDAReturnType; // getAddress: (wallet: Wallet, options?: AddressOptions) => Promise; - // signMessage: (wallet: Wallet, message: string) => Promise; // signPsbt: (wallet: Wallet, psbt: Psbt) => Promise; // signTransaction: (wallet: Wallet, psbt: Psbt) => Promise; } diff --git a/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts b/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts new file mode 100644 index 000000000..412334ac5 --- /dev/null +++ b/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts @@ -0,0 +1,42 @@ +import { + type DeviceManagementKit, + type DeviceSessionId, +} from "@ledgerhq/device-management-kit"; + +import { DefaultSignerBtc } from "@internal/DefaultSignerBtc"; + +type SignerBtcBuilderConstructorArgs = { + dmk: DeviceManagementKit; + sessionId: DeviceSessionId; +}; + +/** + * Builder for the `SignerBtc` class. + * + * @example + * ``` + * const signer = new SignerBtcBuilder({ dmk, sessionId }) + * .build(); + * ``` + */ +export class SignerBtcBuilder { + private _dmk: DeviceManagementKit; + private _sessionId: DeviceSessionId; + + constructor({ dmk, sessionId }: SignerBtcBuilderConstructorArgs) { + this._dmk = dmk; + this._sessionId = sessionId; + } + + /** + * Build the btc signer + * + * @returns the bitcoin signer + */ + public build() { + return new DefaultSignerBtc({ + dmk: this._dmk, + sessionId: this._sessionId, + }); + } +} diff --git a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts new file mode 100644 index 000000000..289334468 --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts @@ -0,0 +1,44 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type OpenAppDAError, + type OpenAppDARequiredInteraction, + type UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; + +import { type Signature } from "@api/model/Signature"; + +export type SignMessageDAOutput = Signature; + +export type SignMessageDAInput = { + readonly derivationPath: string; + readonly message: string; +}; + +export type SignMessageDAError = OpenAppDAError | CommandErrorResult["error"]; + +type SignMessageDARequiredInteraction = + | OpenAppDARequiredInteraction + | UserInteractionRequired.SignPersonalMessage; + +export type SignMessageDAIntermediateValue = { + requiredUserInteraction: SignMessageDARequiredInteraction; +}; + +export type SignMessageDAState = DeviceActionState< + SignMessageDAOutput, + SignMessageDAError, + SignMessageDAIntermediateValue +>; + +export type SignMessageDAInternalState = { + readonly error: SignMessageDAError | null; + readonly signature: Signature | null; +}; + +export type SignMessageDAReturnType = ExecuteDeviceActionReturnType< + SignMessageDAOutput, + SignMessageDAError, + SignMessageDAIntermediateValue +>; diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts index a7ef9bf77..36e101c10 100644 --- a/packages/signer/signer-btc/src/api/index.ts +++ b/packages/signer/signer-btc/src/api/index.ts @@ -1,2 +1,12 @@ export { type SignerBtc } from "./SignerBtc"; export * from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +export type { + SignMessageDAError, + SignMessageDAInput, + SignMessageDAIntermediateValue, + SignMessageDAOutput, + SignMessageDAState, +} from "@api/app-binder/SignMessageDeviceActionType"; +export * from "@api/app-binder/SignMessageDeviceActionType"; +export * from "@api/SignerBtc"; +export * from "@api/SignerBtcBuilder"; diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts index d2b79c99f..003bcb245 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts @@ -3,7 +3,9 @@ import { type DeviceManagementKit } from "@ledgerhq/device-management-kit"; import { DefaultSignerBtc } from "@internal/DefaultSignerBtc"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; -describe("DefaultSignerSolana", () => { +import { SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; + +describe("DefaultSignerBtc", () => { it("should be defined", () => { const signer = new DefaultSignerBtc({ dmk: {} as DeviceManagementKit, @@ -24,4 +26,17 @@ describe("DefaultSignerSolana", () => { }); expect(GetExtendedPublicKeyUseCase.prototype.execute).toHaveBeenCalled(); }); + + it("should call signMessageUseCase", () => { + jest.spyOn(SignMessageUseCase.prototype, "execute"); + const sessionId = "session-id"; + const dmk = { + executeDeviceAction: jest.fn(), + } as unknown as DeviceManagementKit; + const derivationPath = "44'/0'/0'/0/0"; + const message = "message"; + const signer = new DefaultSignerBtc({ dmk, sessionId }); + signer.signMessage(derivationPath, message); + expect(SignMessageUseCase.prototype.execute).toHaveBeenCalled(); + }); }); diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts index 89b8e785f..203ce128b 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts @@ -4,11 +4,13 @@ import { } from "@ledgerhq/device-management-kit"; import { type Container } from "inversify"; +import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; import { type AddressOptions } from "@api/model/AddressOptions"; import { type SignerBtc } from "@api/SignerBtc"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { type GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; +import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; import { makeContainer } from "./di"; type DefaultSignerBtcConstructorArgs = { @@ -33,4 +35,13 @@ export class DefaultSignerBtc implements SignerBtc { ) .execute(derivationPath, { checkOnDevice }); } + + signMessage( + _derivationPath: string, + _message: string, + ): SignMessageDAReturnType { + return this._container + .get(useCasesTypes.SignMessageUseCase) + .execute(_derivationPath, _message); + } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts index cf200be32..64a57a77a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts @@ -13,6 +13,12 @@ import { type GetExtendedPublicKeyDAError, type GetExtendedPublicKeyDAOutput, } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +import { + type SignMessageDAError, + type SignMessageDAIntermediateValue, + type SignMessageDAOutput, +} from "@api/index"; +import { type Signature } from "@api/model/Signature"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; @@ -152,4 +158,65 @@ describe("BtcAppBinder", () => { }); }); }); + + describe("signMessage", () => { + it("should return the signature", (done) => { + // GIVEN + const signature: Signature = { + r: `0xDEF1`, + s: `0xAFAF`, + v: 0, + }; + const message = "Hello, World!"; + + jest.spyOn(mockedDmk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signature, + } as DeviceActionState< + SignMessageDAOutput, + SignMessageDAError, + SignMessageDAIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new BtcAppBinder(mockedDmk, "sessionId"); + const { observable } = appBinder.signMessage({ + derivationPath: "44'/60'/3'/2/1", + message, + }); + + // THEN + const states: DeviceActionState< + SignMessageDAOutput, + SignMessageDAError, + SignMessageDAIntermediateValue + >[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signature, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts index 83570fbb4..795a1da93 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -10,15 +10,19 @@ import { GetExtendedPublicKeyDAInput, GetExtendedPublicKeyReturnType, } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +import { SignMessageDAReturnType } from "@api/index"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; import { externalTypes } from "@internal/externalTypes"; +import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; + @injectable() export class BtcAppBinder { constructor( @inject(externalTypes.Dmk) private dmk: DeviceManagementKit, @inject(externalTypes.SessionId) private sessionId: DeviceSessionId, ) {} + getExtendedPublicKey( args: GetExtendedPublicKeyDAInput, ): GetExtendedPublicKeyReturnType { @@ -35,4 +39,19 @@ export class BtcAppBinder { }), }); } + + signMessage(args: { + derivationPath: string; + message: string; + }): SignMessageDAReturnType { + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignMessageDeviceAction({ + input: { + derivationPath: args.derivationPath, + message: args.message, + }, + }), + }); + } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts index 960b10b0a..19ddb619c 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts @@ -1,11 +1,11 @@ import { ApduResponse, CommandResultFactory, - InvalidStatusWordError, - isSuccessCommandResult, + GlobalCommandErrorHandler, } from "@ledgerhq/device-management-kit"; import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; +import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; import { ContinueCommand } from "./ContinueCommand"; @@ -14,34 +14,6 @@ describe("ContinueCommand", (): void => { payload: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), }; - const getSignatureResponse = ({ - omitR = false, - omitS = false, - }: { - omitV?: boolean; - 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([ 0xf8, // CLA 0x01, // INS @@ -54,10 +26,21 @@ describe("ContinueCommand", (): void => { 0xef, // Payload data ]); + const parser = (response: ApduResponse) => { + if (BtcCommandUtils.isContinueResponse(response)) { + return CommandResultFactory({ + data: response, + }); + } + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(response), + }); + }; + describe("getApdu", () => { it("should return correct APDU for given payload", () => { // given - const command = new ContinueCommand(defaultArgs); + const command = new ContinueCommand(defaultArgs, parser); // when const apdu = command.getApdu(); // then @@ -68,7 +51,7 @@ describe("ContinueCommand", (): void => { describe("parseResponse", () => { it("should return the APDU response if it's a continue response", () => { // given - const command = new ContinueCommand(defaultArgs); + const command = new ContinueCommand(defaultArgs, parser); const continueResponseData = new Uint8Array([0x01, 0x02, 0x03, 0x04]); const apduResponse = new ApduResponse({ statusCode: SW_INTERRUPTED_EXECUTION, @@ -85,146 +68,5 @@ describe("ContinueCommand", (): void => { }), ); }); - - it("should return correct signature after successful signing", () => { - // given - const command = new ContinueCommand(defaultArgs); - const signatureData = getSignatureResponse(); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: signatureData, - }); - - // when - const response = command.parseResponse(apduResponse); - - // then - expect(response).toStrictEqual( - CommandResultFactory({ - data: { - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }, - }), - ); - }); - - it("should return an error if user denied the operation", () => { - // given - const command = new ContinueCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: USER_DENIED_STATUS, - data: new Uint8Array([]), - }); - 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 but status is success", () => { - // given - const command = new ContinueCommand(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 a valid signature", () => { - // given - const command = new ContinueCommand(defaultArgs); - const signatureData = getSignatureResponse(); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: signatureData, - }); - - // when - const response = command.parseResponse(apduResponse); - - // then - expect(isSuccessCommandResult(response)).toBe(true); - if (isSuccessCommandResult(response)) { - expect(response.data).toStrictEqual({ - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }); - } - }); - - it("should return an error if 'r' is missing in the signature response", () => { - // given - const command = new ContinueCommand(defaultArgs); - const signatureData = getSignatureResponse({ omitR: true }); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: signatureData, - }); - - // 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 in the signature response", () => { - // given - const command = new ContinueCommand(defaultArgs); - const signatureData = getSignatureResponse({ omitS: true }); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: signatureData, - }); - - // when - const response = command.parseResponse(apduResponse); - - // then - expect(response).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }), - ); - }); - - it("should return a global error for unknown status codes", () => { - // given - const command = new ContinueCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x6a, 0x80]), - data: new Uint8Array([]), - }); - - // when - const response = command.parseResponse(apduResponse); - - // then - expect(isSuccessCommandResult(response)).toBe(false); - if (!isSuccessCommandResult(response)) { - expect(response.error).toBeDefined(); - } - }); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts index 0d919f864..5379a9cf4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts @@ -1,36 +1,25 @@ import { type Apdu, ApduBuilder, - ApduParser, type ApduResponse, type Command, type CommandResult, - CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, - InvalidStatusWordError, - isCommandErrorCode, } from "@ledgerhq/device-management-kit"; -import { type Signature } from "@api/model/Signature"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; - -import { - BitcoinAppCommandError, - bitcoinAppErrors, -} from "./utils/bitcoinAppErrors"; - export type ContinueCommandArgs = { payload: Uint8Array; }; -const R_LENGTH = 32; -const S_LENGTH = 32; - -export class ContinueCommand - implements Command +export class ContinueCommand + implements Command { - constructor(private readonly args: ContinueCommandArgs) {} + constructor( + private readonly args: ContinueCommandArgs, + private readonly parseFn: ( + response: ApduResponse, + ) => CommandResult, + ) {} + getApdu(): Apdu { return new ApduBuilder({ cla: 0xf8, @@ -42,71 +31,7 @@ export class ContinueCommand .build(); } - parseResponse( - response: ApduResponse, - ): CommandResult { - if (BtcCommandUtils.isContinueResponse(response)) { - return CommandResultFactory({ - data: response, - }); - } - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - - // !!! POC to test with the real device - // !!! final implementation will have this part injected - const parser = new ApduParser(response); - const errorCode = parser.encodeToHexaString(response.statusCode); - if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { - return CommandResultFactory({ - error: new BitcoinAppCommandError({ - ...bitcoinAppErrors[errorCode], - errorCode, - }), - }); - } - - // 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: { - v, - r, - s, - }, - }); - // !!! POC to test with the real device - // !!! final implementation will have this part injected + parseResponse(response: ApduResponse): CommandResult { + return this.parseFn(response); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts index bb8cddebd..536a8bf30 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts @@ -1,8 +1,8 @@ import { Just, Nothing } from "purify-ts"; import { - BUFFER_SIZE, ClientCommandCodes, + SHA256_SIZE, } from "@internal/app-binder/command/utils/constants"; import { type DataStore } from "@internal/data-store/model/DataStore"; import { encodeVarint } from "@internal/utils/Varint"; @@ -39,8 +39,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => { it("should return the index when the Merkle leaf is found", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff); const handlerRequest = createRequest( COMMAND_CODE, merkleRootHash, @@ -75,8 +75,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => { it("should return a failure response when the Merkle leaf is not found", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff); const handlerRequest = createRequest( COMMAND_CODE, merkleRootHash, @@ -107,8 +107,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => { it("should correctly handle a leaf index of zero", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff); const handlerRequest = createRequest( COMMAND_CODE, merkleRootHash, @@ -141,16 +141,16 @@ describe("GetMerkleLeafIndexCommandHandler", () => { expect(commandHandlerContext.queue).toHaveLength(0); }); - it("should correctly handle the maximum BUFFER_SIZE-bit unsigned integer as a leaf index", () => { + it("should correctly handle the maximum SHA256_SIZE-bit unsigned integer as a leaf index", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff); const handlerRequest = createRequest( COMMAND_CODE, merkleRootHash, leafNodeHash, ); - const maximumLeafIndex = 0xffffffff; // maximum value of a BUFFER_SIZE-bit unsigned integer + const maximumLeafIndex = 0xffffffff; // maximum value of a SHA256_SIZE-bit unsigned integer const encodedLeafIndexVarint = encodeVarint(maximumLeafIndex).unsafeCoerce(); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts index f979cbaaf..937f9318e 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts @@ -1,7 +1,7 @@ import { type DmkError } from "@ledgerhq/device-management-kit"; import { type Either, Right } from "purify-ts"; -import { BUFFER_SIZE } from "@internal/app-binder/command/utils/constants"; +import { SHA256_SIZE } from "@internal/app-binder/command/utils/constants"; import { encodeVarint } from "@internal/utils/Varint"; import { @@ -13,8 +13,8 @@ export const GetMerkleLeafIndexCommandHandler: CommandHandler = ( request: Uint8Array, commandHandlerContext: CommandHandlerContext, ): Either => { - const merkleRootHash = request.slice(1, BUFFER_SIZE + 1); // extract the Merkle root hash - const leafNodeHash = request.slice(BUFFER_SIZE + 1, BUFFER_SIZE * 2 + 1); // extract the leaf hash + const merkleRootHash = request.slice(1, SHA256_SIZE + 1); // extract the Merkle root hash + const leafNodeHash = request.slice(SHA256_SIZE + 1, SHA256_SIZE * 2 + 1); // extract the leaf hash const maybeMerkleIndex = commandHandlerContext.dataStore.getMerkleLeafIndex( merkleRootHash, diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts index db3215dd5..cbc2c1b5e 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts @@ -2,8 +2,8 @@ import { APDU_MAX_PAYLOAD } from "@ledgerhq/device-management-kit"; import { Just, Nothing } from "purify-ts"; import { - BUFFER_SIZE, ClientCommandCodes, + SHA256_SIZE, } from "@internal/app-binder/command/utils/constants"; import { type DataStore } from "@internal/data-store/model/DataStore"; import { encodeVarint } from "@internal/utils/Varint"; @@ -47,7 +47,7 @@ describe("GetMerkleLeafProofCommandHandler", () => { it("should return the Merkle leaf and proof when found and proof length is less than or equal to the maximum allowed", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x01); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x01); const totalElements = 1; const proofElementIndex = 2; const request = createRequest( @@ -56,10 +56,10 @@ describe("GetMerkleLeafProofCommandHandler", () => { totalElements, proofElementIndex, ); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xaa); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xaa); const proofElements = [ - new Uint8Array(BUFFER_SIZE).fill(0xbb), - new Uint8Array(BUFFER_SIZE).fill(0xcc), + new Uint8Array(SHA256_SIZE).fill(0xbb), + new Uint8Array(SHA256_SIZE).fill(0xcc), ]; // when @@ -72,19 +72,19 @@ describe("GetMerkleLeafProofCommandHandler", () => { commandHandlerContext, ); - const maximumPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1; - const maximumProofElements = Math.floor(maximumPayloadSize / BUFFER_SIZE); + const maximumPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1; + const maximumProofElements = Math.floor(maximumPayloadSize / SHA256_SIZE); const proofElementsToInclude = Math.min( proofElements.length, maximumProofElements, ); const expectedResponse = new Uint8Array( - BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsToInclude, + SHA256_SIZE + 1 + 1 + SHA256_SIZE * proofElementsToInclude, ); let responseBufferOffset = 0; expectedResponse.set(leafNodeHash, responseBufferOffset); // leafHash - responseBufferOffset += BUFFER_SIZE; + responseBufferOffset += SHA256_SIZE; expectedResponse[responseBufferOffset++] = proofElements.length; // total proof length expectedResponse[responseBufferOffset++] = proofElementsToInclude; for ( @@ -96,7 +96,7 @@ describe("GetMerkleLeafProofCommandHandler", () => { proofElements[proofElementIndex] as Uint8Array, responseBufferOffset, ); - responseBufferOffset += BUFFER_SIZE; + responseBufferOffset += SHA256_SIZE; } // then @@ -111,7 +111,7 @@ describe("GetMerkleLeafProofCommandHandler", () => { it("should handle proof longer than the maximum allowed by queuing the remaining proof elements", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x02); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x02); const totalElements = 1; const proofElementIndex = 3; const request = createRequest( @@ -120,11 +120,11 @@ describe("GetMerkleLeafProofCommandHandler", () => { totalElements, proofElementIndex, ); - const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xdd); + const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xdd); const totalProofLength = 10; const proofElements = Array.from( { length: totalProofLength }, - (_, proofIndex) => new Uint8Array(BUFFER_SIZE).fill(0xee + proofIndex), + (_, proofIndex) => new Uint8Array(SHA256_SIZE).fill(0xee + proofIndex), ); // when @@ -137,19 +137,19 @@ describe("GetMerkleLeafProofCommandHandler", () => { commandHandlerContext, ); - const maximumPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1; - const maximumProofElements = Math.floor(maximumPayloadSize / BUFFER_SIZE); + const maximumPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1; + const maximumProofElements = Math.floor(maximumPayloadSize / SHA256_SIZE); const proofElementsToInclude = Math.min( proofElements.length, maximumProofElements, ); const expectedResponse = new Uint8Array( - BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsToInclude, + SHA256_SIZE + 1 + 1 + SHA256_SIZE * proofElementsToInclude, ); let responseBufferOffset = 0; expectedResponse.set(leafNodeHash, responseBufferOffset); // leafHash - responseBufferOffset += BUFFER_SIZE; + responseBufferOffset += SHA256_SIZE; expectedResponse[responseBufferOffset++] = proofElements.length; // total proof length expectedResponse[responseBufferOffset++] = proofElementsToInclude; for ( @@ -161,7 +161,7 @@ describe("GetMerkleLeafProofCommandHandler", () => { proofElements[proofElementIndex] as Uint8Array, responseBufferOffset, ); - responseBufferOffset += BUFFER_SIZE; + responseBufferOffset += SHA256_SIZE; } // then @@ -187,7 +187,7 @@ describe("GetMerkleLeafProofCommandHandler", () => { it("should return an error when the Merkle proof is not found in the data store", () => { // given - const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x04); + const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x04); const totalElements = 1; const proofElementIndex = 5; const request = createRequest( diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts index 10adcf31f..31c54a7c4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts @@ -1,12 +1,13 @@ import { APDU_MAX_PAYLOAD, + ByteArrayBuilder, ByteArrayParser, type DmkError, } from "@ledgerhq/device-management-kit"; -import { type Either, Left, type Maybe, Right } from "purify-ts"; +import { type Either, Left, Right } from "purify-ts"; -import { BUFFER_SIZE } from "@internal/app-binder/command/utils/constants"; -import { extractVarint, type Varint } from "@internal/utils/Varint"; +import { SHA256_SIZE } from "@internal/app-binder/command/utils/constants"; +import { extractVarint } from "@internal/utils/Varint"; import { type CommandHandler, @@ -18,72 +19,67 @@ export const GetMerkleLeafProofCommandHandler: CommandHandler = ( request: Uint8Array, context: CommandHandlerContext, ): Either => { - const rootHash = request.slice(1, BUFFER_SIZE + 1); - const parser = new ByteArrayParser(request.slice(33)); + const rootHash = request.slice(1, SHA256_SIZE + 1); + const parser = new ByteArrayParser(request.slice(SHA256_SIZE + 1)); - const numberOfElementsMaybe: Maybe = extractVarint(parser); - if (numberOfElementsMaybe.isNothing()) { - return Left(new ClientCommandHandlerError("Failed to extract 'n' varint")); - } - const numberOfElementsVarint = numberOfElementsMaybe.extract(); - if (!numberOfElementsVarint) { - return Left(new ClientCommandHandlerError("Failed to extract 'n' varint")); - } + return extractVarint(parser).mapOrDefault( + () => + extractVarint(parser).mapOrDefault( + (proofIndexVarint) => + context.dataStore + .getMerkleProof(rootHash, proofIndexVarint.value) + .mapOrDefault( + ({ proof, leafHash }) => { + const maxPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1; + const maxProofElements = Math.floor( + maxPayloadSize / SHA256_SIZE, + ); + const proofElementsCount = Math.min( + proof.length, + maxProofElements, + ); - const proofIndexMaybe: Maybe = extractVarint(parser); - if (proofIndexMaybe.isNothing()) { - return Left(new ClientCommandHandlerError("Failed to extract 'i' varint")); - } - const proofIndexVarint = proofIndexMaybe.extract(); - if (!proofIndexVarint) { - return Left(new ClientCommandHandlerError("Failed to extract 'n' varint")); - } - const proofIndex = proofIndexVarint.value; + const builder = new ByteArrayBuilder() + .addBufferToData(leafHash) + .add8BitUIntToData(proof.length) + .add8BitUIntToData(proofElementsCount); - const maybeProof = context.dataStore.getMerkleProof(rootHash, proofIndex); + for (let i = 0; i < proofElementsCount; i++) { + const proofElement = proof[i]; + if (proofElement) { + builder.addBufferToData(proofElement); + } + } - return maybeProof.mapOrDefault( - ({ proof, leafHash }) => { - const maxPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1; // 255 total - 32 (leafHash) - 1 (proof.length) - 1 (p) + const response = builder.build(); - const maxProofElements = Math.floor(maxPayloadSize / BUFFER_SIZE); - const proofElementsCount = Math.min(proof.length, maxProofElements); + if (proofElementsCount < proof.length) { + for (let i = proofElementsCount; i < proof.length; i++) { + const proofElement = proof[i]; + if (proofElement instanceof Uint8Array) { + context.queue.push(proofElement); + } + } + } - const responseSize = - BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsCount; - const response = new Uint8Array(responseSize); - let responseOffset = 0; - - response.set(leafHash, responseOffset); - responseOffset += BUFFER_SIZE; - - response[responseOffset++] = proof.length; - response[responseOffset++] = proofElementsCount; - - for ( - let proofElementIndex = 0; - proofElementIndex < proofElementsCount; - proofElementIndex++ - ) { - const proofElement = proof[proofElementIndex]; - if (proofElement) { - response.set(proofElement, responseOffset); - } - responseOffset += BUFFER_SIZE; - } - - if (proofElementsCount < proof.length) { - for ( - let remainingProofElementIndex = proofElementsCount; - remainingProofElementIndex < proof.length; - remainingProofElementIndex++ - ) { - context.queue.push(proof[remainingProofElementIndex] as Uint8Array); - } - } - - return Right(response); - }, - Left(new ClientCommandHandlerError("Merkle proof not found in dataStore")), + return Right(response); + }, + Left( + new ClientCommandHandlerError( + "Merkle proof not found in dataStore", + ), + ), + ), + Left( + new ClientCommandHandlerError( + "Failed to extract the proofIndex varint", + ), + ), + ), + Left( + new ClientCommandHandlerError( + "Failed to extract the numberOfElements varint", + ), + ), ); }; diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts index de5200fea..53843d7ca 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts @@ -1,9 +1,9 @@ import { type DmkError } from "@ledgerhq/device-management-kit"; -import { Just, Nothing } from "purify-ts"; +import { Either, Just, Nothing } from "purify-ts"; import { - BUFFER_SIZE, ClientCommandCodes, + SHA256_SIZE, } from "@internal/app-binder/command/utils/constants"; import { type DataStore } from "@internal/data-store/model/DataStore"; import { encodeVarint } from "@internal/utils/Varint"; @@ -38,7 +38,7 @@ describe("GetPreimageCommandHandler", () => { it("should return the preimage when it is found and its length is within the maximum payload size", () => { // given - const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x01); + const requestHash = new Uint8Array(SHA256_SIZE).fill(0x01); const preimage = new Uint8Array([0xaa, 0xbb, 0xcc]); const preimageLength = preimage.length; @@ -74,7 +74,7 @@ describe("GetPreimageCommandHandler", () => { it("should handle a preimage longer than the maximum payload size by queuing the remaining bytes", () => { // given - const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x02); + const requestHash = new Uint8Array(SHA256_SIZE).fill(0x02); const preimage = new Uint8Array(300).fill(0xff); const preimageLength = preimage.length; @@ -129,7 +129,7 @@ describe("GetPreimageCommandHandler", () => { it("should return an error when the preimage is not found", () => { // given - const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x03); + const requestHash = new Uint8Array(SHA256_SIZE).fill(0x03); // when mockDataStore.getPreimage.mockReturnValue(Nothing); @@ -157,7 +157,7 @@ describe("GetPreimageCommandHandler", () => { it("should handle a preimage length where the maximum payload size exactly matches the preimage length", () => { // given - const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x05); + const requestHash = new Uint8Array(SHA256_SIZE).fill(0x05); const preimage = new Uint8Array(252); preimage.fill(0x77); @@ -188,14 +188,7 @@ describe("GetPreimageCommandHandler", () => { expectedResponse.set(preimage, varintEncodedLength.length + 1); // then - handlerResult.caseOf({ - Left: (_) => { - throw new Error("Expected Right, got Left"); - }, - Right: (response) => { - expect(response).toEqual(expectedResponse); - expect(commandHandlerContext.queue).toHaveLength(0); - }, - }); + expect(handlerResult).toStrictEqual(Either.of(expectedResponse)); + expect(commandHandlerContext.queue).toHaveLength(0); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts index 4db5841e9..2e53f8cec 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts @@ -1,5 +1,6 @@ import { APDU_MAX_PAYLOAD, + ByteArrayBuilder, type DmkError, } from "@ledgerhq/device-management-kit"; import { type Either, Left, Right } from "purify-ts"; @@ -30,14 +31,11 @@ export const GetPreimageCommandHandler: CommandHandler = ( preimage.length, ); - const response = new Uint8Array( - preimageLengthVarint.length + 1 + bytesToIncludeInResponse, - ); - let responseOffset = 0; - response.set(preimageLengthVarint, responseOffset); // preimage length varint - responseOffset += preimageLengthVarint.length; - response[responseOffset++] = bytesToIncludeInResponse; // number of bytes in the response - response.set(preimage.slice(0, bytesToIncludeInResponse), responseOffset); + const response = new ByteArrayBuilder() + .addBufferToData(preimageLengthVarint) // varint + .add8BitUIntToData(bytesToIncludeInResponse) // byte count + .addBufferToData(preimage.slice(0, bytesToIncludeInResponse)) // requested part of the preimage + .build(); if (bytesToIncludeInResponse < preimage.length) { for ( diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts index 94793981e..5118812a4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts @@ -1,6 +1,6 @@ export const PROTOCOL_VERSION = 1; -export const BUFFER_SIZE = 32; +export const SHA256_SIZE = 32; export const CHUNK_SIZE = 64; diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts new file mode 100644 index 000000000..d2b50da47 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts @@ -0,0 +1,373 @@ +import { + CommandResultFactory, + DeviceActionStatus, + UnknownDeviceExchangeError, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +import { UnknownDAError } from "@ledgerhq/device-management-kit"; +import { InvalidStatusWordError } from "@ledgerhq/device-management-kit"; + +import { type SignMessageDAState } from "@api/index"; +import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; +import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; + +import { SignMessageDeviceAction } from "./SignMessageDeviceAction"; + +jest.mock( + "@ledgerhq/device-management-kit", + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + () => ({ + ...jest.requireActual("@ledgerhq/device-management-kit"), + OpenAppDeviceAction: jest.fn(() => ({ + makeStateMachine: jest.fn(), + })), + }), +); + +describe("SignMessageDeviceAction", () => { + const signPersonalMessageMock = jest.fn(); + + function extractDependenciesMock() { + return { + signMessage: signPersonalMessageMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + }), + ); + + // Expected intermediate values for the following state sequence: + // Initial -> OpenApp -> BuildContext -> ProvideContext -> SignTypedData + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + status: DeviceActionStatus.Pending, + }, + { + output: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // Verify mocks calls parameters + observable.subscribe({ + complete: () => { + expect(signPersonalMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }), + ); + }, + }); + }); + }); + + describe("error cases", () => { + it("Error if the open app fails", (done) => { + setupOpenAppDAMock(new UnknownDeviceExchangeError("Mocked error")); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if the signPersonalMessage fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if the signPersonalMessage throws an exception", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalMessageMock.mockRejectedValueOnce( + new InvalidStatusWordError("Mocked error"), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if signPersonalMessage return an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Return a Left if the final state has no signature", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + data: undefined, + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("No error in final state"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts new file mode 100644 index 000000000..9a5060101 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts @@ -0,0 +1,224 @@ +import { + type CommandResult, + type DeviceActionStateMachine, + type InternalApi, + isSuccessCommandResult, + OpenAppDeviceAction, + type StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + type SignMessageDAError, + type SignMessageDAInput, + type SignMessageDAIntermediateValue, + type SignMessageDAInternalState, + type SignMessageDAOutput, +} from "@api/app-binder/SignMessageDeviceActionType"; +import { type Signature } from "@api/model/Signature"; +import { + SendSignMessageTask, + type SendSignMessageTaskArgs, +} from "@internal/app-binder/task/SignMessageTask"; + +export type MachineDependencies = { + readonly signMessage: (arg0: { + input: SendSignMessageTaskArgs; + }) => Promise>; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class SignMessageDeviceAction extends XStateDeviceAction< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState +> { + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState + > { + type types = StateMachineTypes< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState + >; + + const { signMessage } = this.extractDependencies(internalApi); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + actors: { + openAppStateMachine: new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }).makeStateMachine(internalApi), + signMessage: fromPromise(signMessage), + }, + guards: { + noInternalError: ({ context }) => context._internalState.error === null, + }, + actions: { + assignErrorFromEvent: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: _.event["error"], // NOTE: it should never happen, the error is not typed anymore here + }), + }), + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGUCWUB2AFMAnWA9hgIYA2AsnLMTACJgBuqAxmAILMAuqRAdAPIAHMBjaDB9Jqw7ciAYghEwvVBgYEA1soLDR45J2Kcw5YswAWqsAG0ADAF1EoQQVipZGJyAAeiACwAzADsvACcABwAjKGRtuG2AGyRSX4ANCAAnoiRAEwArLy2uX6RQUGRpeHhfgC+NelomDj4RGSUsNR0jCzsXDwYvADC5mDMGkIiYhLd0n1EAEpwAK6knHJ2jkggLm4eXr4IoRG8eTlFOaWhQX45QaHpWQiRN4UBfu8JeaE3obY5OXUGuhsHhCCQKFQaGBJD0ZP0hiMxhM9NMpL0PItYCs1tZIptnK53P19ogjuETmdcpdrrd7pl-M8wnkgvkgvFbH48n4EkFASBGiCWuD2p1oTN0fCBc0wW1ITAFEoVGpNMo3E1Qa0IR0oRsvDsiUQSU88tVeOEktEEuE8m8cjcHqScicgokTXk8rZygFQgD6vzgdLNSKoTDZh5eFKNcK5WA5HhcARcLxBKQjAAzRMAW14asFMq1ot1W31ey2B0iJr8ZotoStNpu9vpCDtoTNHr8PoizyKvL9kaFsu1XTRcL4-fzwZgmOxw1GGnWDj1hNLoAOFwCBWC5u5RySEQdT1sra+OSt1wCAQuzICfPHQZjoYlY4DUcHounq1nY3WeKXu2JZaIOum5sgkO61tE4QHhWBS2HkCT-G8R5wc8N58hgBAQHAXh3tGQ5iiOcyeMWy4AauiAALQJAeVGFLY9EMYxDG9kC6oDgWIbiqOAzIlMj7cX+BrEeRCCNo8sS2CcRz-B6zIsnBaGsXm974fxREInOvHiGpGLLKsgkrj4iBnrwFwVgkCHfEeCQBNB3K8IEpxBO6ySep8in+mxE4Plx6m4W+UIGWRRlPJEVRmncOTmhyLI2QeUS8GFQRvPBXZRLWt4vuxk4EbCflZd5+EfpwX4aEFhqAU84RRYUpyXmBATJBeQTQZEARhKEG5Ne69GUplXkqaKOmSkszCsB05XCSFOSXgUyVshUdoJLYF7QYkvAXkUER3H45q1qE-XKXhQ2+eGACiuAJrgk1GjN+S8PNUTFMtq1Nrckl-O6wTct6KF5HUdRAA */ + id: "SignMessageDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + signature: null, + }, + }; + }, + states: { + OpenAppDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "openAppStateMachine", + input: { appName: "Bitcoin" }, + src: "openAppStateMachine", + onSnapshot: { + actions: assign({ + intermediateValue: (_) => + _.event.snapshot.context.intermediateValue, + }), + }, + onDone: { + actions: assign({ + _internalState: (_) => { + return _.event.output.caseOf({ + Right: () => _.context._internalState, + Left: (error) => ({ + ..._.context._internalState, + error, + }), + }); + }, + }), + target: "CheckOpenAppDeviceActionResult", + }, + }, + }, + CheckOpenAppDeviceActionResult: { + always: [ + { + target: "SignMessage", + guard: "noInternalError", + }, + "Error", + ], + }, + SignMessage: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }), + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "signMessage", + src: "signMessage", + input: ({ context }) => ({ + derivationPath: context.input.derivationPath, + message: context.input.message, + }), + onDone: { + target: "SignMessageResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signature: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + SignMessageResultCheck: { + always: [ + { guard: "noInternalError", target: "Success" }, + { target: "Error" }, + ], + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: ({ context }) => + context._internalState.signature + ? Right(context._internalState.signature) + : Left( + context._internalState.error || + new UnknownDAError("No error in final state"), + ), + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + const signMessage = async (arg0: { + input: { + derivationPath: string; + message: string; + }; + }) => new SendSignMessageTask(internalApi, arg0.input).run(); + + return { + signMessage, + }; + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts new file mode 100644 index 000000000..08104df33 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts @@ -0,0 +1,17 @@ +import { type InternalApi } from "@ledgerhq/device-management-kit"; + +const sendCommandMock = jest.fn(); +const apiGetDeviceSessionStateMock = jest.fn(); +const apiGetDeviceSessionStateObservableMock = jest.fn(); +const setDeviceSessionStateMock = jest.fn(); +const getMetadataForAppHashesMock = jest.fn(); + +export function makeDeviceActionInternalApiMock(): jest.Mocked { + return { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, + setDeviceSessionState: setDeviceSessionStateMock, + getMetadataForAppHashes: getMetadataForAppHashesMock, + }; +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts new file mode 100644 index 000000000..3d27aabf5 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts @@ -0,0 +1,32 @@ +import { + OpenAppDeviceAction, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, createMachine } from "xstate"; + +export const setupOpenAppDAMock = (error?: unknown) => { + (OpenAppDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + initial: "pending", + states: { + pending: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }), + after: { + 0: "done", + }, + }, + done: { + type: "final", + }, + }, + output: () => (error ? Left(error) : Right(undefined)), + }), + ), + })); +}; diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts new file mode 100644 index 000000000..bc8f1ce01 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts @@ -0,0 +1,48 @@ +import { + type DeviceAction, + type DeviceActionIntermediateValue, + type DeviceActionState, + type DmkError, + type InternalApi, +} from "@ledgerhq/device-management-kit"; + +/** + * Test that the states emitted by a device action match the expected states. + * @param deviceAction The device action to test. + * @param expectedStates The expected states. + * @param done The Jest done callback. + */ +export function testDeviceActionStates< + Output, + Input, + Error extends DmkError, + IntermediateValue extends DeviceActionIntermediateValue, +>( + deviceAction: DeviceAction, + expectedStates: Array>, + internalApi: InternalApi, + done?: jest.DoneCallback, +) { + const observedStates: Array< + DeviceActionState + > = []; + + const { observable, cancel } = deviceAction._execute(internalApi); + observable.subscribe({ + next: (state) => { + observedStates.push(state); + }, + error: (error) => { + if (done) done(error); + }, + complete: () => { + try { + expect(observedStates).toEqual(expectedStates); + if (done) done(); + } catch (e) { + if (done) done(e); + } + }, + }); + return { observable, cancel }; +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SendSignMessageTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts similarity index 58% rename from packages/signer/signer-btc/src/internal/app-binder/task/SendSignMessageTask.test.ts rename to packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts index 2e5aeef5c..c298233f4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SendSignMessageTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts @@ -1,19 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { + type ApduResponse, CommandResultFactory, CommandResultStatus, + type InternalApi, InvalidStatusWordError, + isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { type ApduResponse } from "@ledgerhq/device-management-kit"; -import { makeDeviceActionInternalApiMock } from "@ledgerhq/device-management-kit/src/api/device-action/__test-utils__/makeInternalApi.js"; import { Left, Right } from "purify-ts"; import { type Signature } from "@api/model/Signature"; import { ClientCommandHandlerError } from "@internal/app-binder/command/client-command-handlers/Errors"; import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; import { - BUFFER_SIZE, CHUNK_SIZE, ClientCommandCodes, + SHA256_SIZE, SW_INTERRUPTED_EXECUTION, } from "@internal/app-binder/command/utils/constants"; import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; @@ -24,7 +29,7 @@ const EXACT_ONE_CHUNK_MESSAGE = "a".repeat(CHUNK_SIZE); const EXACT_TWO_CHUNKS_MESSAGE = "a".repeat(CHUNK_SIZE * 2); const DERIVATION_PATH = "44'/0'/0'/0/0"; const PREIMAGE = new Uint8Array([1, 2, 3, 4]); -const MERKLE_ROOT = new Uint8Array(BUFFER_SIZE).fill(0x01); +const MERKLE_ROOT = new Uint8Array(SHA256_SIZE).fill(0x01); const SIGNATURE: Signature = { v: 27, @@ -37,13 +42,50 @@ const APDU_RESPONSE_YELD: ApduResponse = { data: new Uint8Array([ClientCommandCodes.YIELD]), }; -describe("SendSignMessageTask", () => { - const signatureResult = CommandResultFactory({ +// Helper function to create a mock signature response +const getSignatureResponse = ({ + omitV = false, + omitR = false, + omitS = false, +}: { + omitV?: boolean; + omitR?: boolean; + omitS?: boolean; +} = {}) => + omitV + ? new Uint8Array([]) + : new Uint8Array([ + // v + ...(omitR ? [] : [0x1b]), + // r (32 bytes) unless omitted + ...(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, + ]), + // s (32 bytes) unless omitted + ...(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, + ]), + ]); + +const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]); + +describe("SignMessageTask", () => { + const signatureResult = CommandResultFactory({ data: SIGNATURE, }); - const apiMock = makeDeviceActionInternalApiMock(); + const apiMock = { + sendCommand: jest.fn(), + } as unknown as InternalApi; - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); @@ -62,17 +104,14 @@ describe("SendSignMessageTask", () => { return MERKLE_ROOT; }); - apiMock.sendCommand.mockResolvedValueOnce(signatureResult); + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(signatureResult); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); // THEN expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect(result.status).toBe(CommandResultStatus.Success); - if (result.status === CommandResultStatus.Success) { - expect(result.data).toStrictEqual(SIGNATURE); - } + expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); }); it("should correctly chunk a message that fits in 2 chunks", async () => { @@ -89,17 +128,14 @@ describe("SendSignMessageTask", () => { return MERKLE_ROOT; }); - apiMock.sendCommand.mockResolvedValueOnce(signatureResult); + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(signatureResult); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); // THEN expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect(result.status).toBe(CommandResultStatus.Success); - if (result.status === CommandResultStatus.Success) { - expect(result.data).toStrictEqual(SIGNATURE); - } + expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); }); it("should handle interrupted execution with interactive commands", async () => { @@ -116,7 +152,7 @@ describe("SendSignMessageTask", () => { return MERKLE_ROOT; }); - apiMock.sendCommand + (apiMock.sendCommand as jest.Mock) .mockResolvedValueOnce( CommandResultFactory({ data: APDU_RESPONSE_YELD, @@ -134,7 +170,7 @@ describe("SendSignMessageTask", () => { const getClientCommandPayloadMock = jest .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((request: Uint8Array, context: any) => { const commandCode = request[0]; if (commandCode === ClientCommandCodes.YIELD) { @@ -191,10 +227,7 @@ describe("SendSignMessageTask", () => { ); // check the final result - expect(result.status).toBe(CommandResultStatus.Success); - if (result.status === CommandResultStatus.Success) { - expect(result.data).toStrictEqual(SIGNATURE); - } + expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); // check that getClientCommandPayload was called correctly expect(getClientCommandPayloadMock).toHaveBeenCalledTimes(2); @@ -217,7 +250,7 @@ describe("SendSignMessageTask", () => { message: EXACT_ONE_CHUNK_MESSAGE, }; - const resultError = CommandResultFactory({ + const resultError = CommandResultFactory({ error: new InvalidStatusWordError("error"), }); @@ -228,7 +261,7 @@ describe("SendSignMessageTask", () => { return MERKLE_ROOT; }); - apiMock.sendCommand.mockResolvedValueOnce(resultError); + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(resultError); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); @@ -255,11 +288,11 @@ describe("SendSignMessageTask", () => { return MERKLE_ROOT; }); - const resultError = CommandResultFactory({ + const resultError = CommandResultFactory({ error: new InvalidStatusWordError("error"), }); - apiMock.sendCommand + (apiMock.sendCommand as jest.Mock) .mockResolvedValueOnce( CommandResultFactory({ data: APDU_RESPONSE_YELD, @@ -269,7 +302,7 @@ describe("SendSignMessageTask", () => { const getClientCommandPayloadMock = jest .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((request: Uint8Array, context: any) => { const commandCode = request[0]; if (commandCode === ClientCommandCodes.YIELD) { @@ -327,4 +360,143 @@ describe("SendSignMessageTask", () => { ); }); }); + + describe("parseBitcoinSignatureResponse", () => { + let instance: SendSignMessageTask; + + beforeEach(() => { + instance = new SendSignMessageTask(apiMock, { + derivationPath: DERIVATION_PATH, + message: "test", + }); + }); + + it("should return a continuation response if it's a continue response", () => { + const apduResponse: ApduResponse = { + statusCode: SW_INTERRUPTED_EXECUTION, + data: new Uint8Array([ClientCommandCodes.YIELD]), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + expect(result.status).toBe(CommandResultStatus.Success); + if (isSuccessCommandResult(result)) { + expect(result.data).toEqual(apduResponse); + } + }); + + it("should return a global error if not success and not a known continuation", () => { + const apduResponse: ApduResponse = { + statusCode: new Uint8Array([0x6a, 0x80]), + data: new Uint8Array([]), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + expect(result.status).toBe(CommandResultStatus.Error); + if (!isSuccessCommandResult(result)) { + expect(result.error).toBeDefined(); + } + }); + + it("should return a bitcoin app command error if the error code matches a known bitcoin app error", () => { + const apduResponse: ApduResponse = { + statusCode: USER_DENIED_STATUS, + data: new Uint8Array([]), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + expect(result.status).toBe(CommandResultStatus.Error); + if (!isSuccessCommandResult(result)) { + expect(result.error).toBeDefined(); + } + }); + + it("should return an error if 'v' is missing", () => { + const apduResponse: ApduResponse = { + statusCode: new Uint8Array([0x90, 0x00]), + data: getSignatureResponse({ omitV: true }), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + + expect(result.status).toBe(CommandResultStatus.Error); + if (!isSuccessCommandResult(result)) { + expect(result.error).toBeInstanceOf(InvalidStatusWordError); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }), + ); + } + }); + + it("should return an error if 'r' is missing", () => { + const apduResponse: ApduResponse = { + statusCode: new Uint8Array([0x90, 0x00]), + data: getSignatureResponse({ omitR: true }), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + + expect(result.status).toBe(CommandResultStatus.Error); + if (!isSuccessCommandResult(result)) { + expect(result.error).toBeInstanceOf(InvalidStatusWordError); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("R is missing"), + }), + ); + } + }); + + it("should return an error if 's' is missing", () => { + const apduResponse: ApduResponse = { + statusCode: new Uint8Array([0x90, 0x00]), + data: getSignatureResponse({ omitS: true }), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + + expect(result.status).toBe(CommandResultStatus.Error); + if (!isSuccessCommandResult(result)) { + expect(result.error).toBeInstanceOf(InvalidStatusWordError); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("S is missing"), + }), + ); + } + }); + + it("should return a signature if v, r, and s are present", () => { + const apduResponse: ApduResponse = { + statusCode: new Uint8Array([0x90, 0x00]), + data: getSignatureResponse(), + }; + + const result = (instance as any).parseBitcoinSignatureResponse( + apduResponse, + ); + + expect(result.status).toBe(CommandResultStatus.Success); + if (isSuccessCommandResult(result)) { + expect(result.data).toEqual({ + v: 27, + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", + }); + } + }); + }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts index 468b13bbe..0e49c74f9 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts @@ -1,9 +1,12 @@ import { + ApduParser, type ApduResponse, type CommandResult, CommandResultFactory, + GlobalCommandErrorHandler, type InternalApi, InvalidStatusWordError, + isCommandErrorCode, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; @@ -15,6 +18,10 @@ import { SignMessageCommand, type SignMessageCommandResponse, } from "@internal/app-binder/command/SignMessageCommand"; +import { + BitcoinAppCommandError, + bitcoinAppErrors, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { CHUNK_SIZE } from "@internal/app-binder/command/utils/constants"; import { DataStore } from "@internal/data-store/model/DataStore"; import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; @@ -23,9 +30,13 @@ import { MerkleMapBuilder } from "@internal/merkle-tree/service/MerkleMapBuilder import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; import { CommandUtils } from "@internal/utils/CommandUtils"; +import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; -type SendSignMessageTaskArgs = { +const R_LENGTH = 32; +const S_LENGTH = 32; + +export type SendSignMessageTaskArgs = { derivationPath: string; message: string; }; @@ -51,7 +62,7 @@ export class SendSignMessageTask { ); } - async run(): Promise> { + async run(): Promise> { const { derivationPath, message } = this.args; const dataStore = new DataStore(); @@ -79,57 +90,63 @@ export class SendSignMessageTask { messageMerkleRoot: merkleRoot, }), ); + if (!isSuccessCommandResult(signMessageFirstCommandResponse)) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Invalid signMessageFirstCommandResponse response", + ), + }); + } - if ( - isSuccessCommandResult(signMessageFirstCommandResponse) && - this.isSignature(signMessageFirstCommandResponse.data) - ) { + if (this.isSignature(signMessageFirstCommandResponse.data)) { return CommandResultFactory({ data: signMessageFirstCommandResponse.data, }); } - if (isSuccessCommandResult(signMessageFirstCommandResponse)) { - let currentResponse = signMessageFirstCommandResponse; - while ( - this.isApduResponse(currentResponse.data) && - CommandUtils.isContinueResponse(currentResponse.data) - ) { - const maybeCommandPayload = interpreter.getClientCommandPayload( - currentResponse.data.data, - commandHandlersContext, + let currentResponse = signMessageFirstCommandResponse; + while ( + this.isApduResponse(currentResponse.data) && + CommandUtils.isContinueResponse(currentResponse.data) + ) { + const maybeCommandPayload = interpreter.getClientCommandPayload( + currentResponse.data.data, + commandHandlersContext, + ); + if (maybeCommandPayload.isLeft()) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + maybeCommandPayload.extract().message, + ), + }); + } + + const payload = maybeCommandPayload.extract(); + if (payload instanceof Uint8Array) { + const nextResponse = await this.api.sendCommand( + new ContinueCommand( + { + payload, + }, + this.parseBitcoinSignatureResponse, + ), ); - if (maybeCommandPayload.isLeft()) { + if (!isSuccessCommandResult(nextResponse)) { return CommandResultFactory({ - error: new InvalidStatusWordError( - maybeCommandPayload.extract().message, - ), + error: new InvalidStatusWordError("Invalid response type"), }); } - - const payload = maybeCommandPayload.extract(); - if (payload instanceof Uint8Array) { - const nextResponse = await this.api.sendCommand( - new ContinueCommand({ - payload, - }), - ); - if (!isSuccessCommandResult(nextResponse)) { - return CommandResultFactory({ - error: new InvalidStatusWordError("Invalid response type"), - }); - } - if (this.isSignature(nextResponse.data)) { - return CommandResultFactory({ - data: nextResponse.data, - }); - } - - currentResponse = nextResponse; + if (this.isSignature(nextResponse.data)) { + return CommandResultFactory({ + data: nextResponse.data, + }); } + + currentResponse = nextResponse; } } - return CommandResultFactory({ + + return CommandResultFactory({ error: new InvalidStatusWordError("Failed to send sign message command."), }); } @@ -156,4 +173,66 @@ export class SendSignMessageTask { "data" in response ); }; + + private parseBitcoinSignatureResponse( + response: ApduResponse, + ): CommandResult { + if (BtcCommandUtils.isContinueResponse(response)) { + return CommandResultFactory({ + data: response, + }); + } + + if (!CommandUtils.isSuccessResponse(response)) { + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(response), + }); + } + + const parser = new ApduParser(response); + const errorCode = parser.encodeToHexaString(response.statusCode); + if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { + return CommandResultFactory({ + error: new BitcoinAppCommandError({ + ...bitcoinAppErrors[errorCode], + errorCode, + }), + }); + } + + const v = parser.extract8BitUInt(); + if (v === undefined) { + return CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }); + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + return CommandResultFactory({ + error: new InvalidStatusWordError("R is missing"), + }); + } + + const s = parser.encodeToHexaString( + parser.extractFieldByLength(S_LENGTH), + true, + ); + if (!s) { + return CommandResultFactory({ + error: new InvalidStatusWordError("S is missing"), + }); + } + + return CommandResultFactory({ + data: { + v, + r, + s, + }, + }); + } } diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts index d1681c9d1..fa1619860 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts @@ -22,5 +22,9 @@ describe("useCasesModuleFactory", () => { container.isBound(useCasesTypes.GetExtendedPublicKeyUseCase), ).toBeTruthy(); }); + + it("should bind SignMessageUseCase", () => { + expect(container.isBound(useCasesTypes.SignMessageUseCase)).toBeTruthy(); + }); }); }); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts index aea2ed6e5..666c669b0 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts @@ -2,6 +2,7 @@ import { ContainerModule } from "inversify"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; +import { SignMessageUseCase } from "@internal/use-cases/sign-message/SignMessageUseCase"; export const useCasesModuleFactory = () => new ContainerModule( @@ -17,5 +18,6 @@ export const useCasesModuleFactory = () => bind(useCasesTypes.GetExtendedPublicKeyUseCase).to( GetExtendedPublicKeyUseCase, ); + bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); }, ); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts index cc4b8fb89..2b00e636e 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts @@ -1,3 +1,4 @@ export const useCasesTypes = { GetExtendedPublicKeyUseCase: Symbol.for("GetExtendedPublicKeyUseCase"), + SignMessageUseCase: Symbol.for("SignMessageUseCase"), }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.test.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.test.ts new file mode 100644 index 000000000..3d50774c7 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.test.ts @@ -0,0 +1,26 @@ +import { type BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; + +import { SignMessageUseCase } from "./SignMessageUseCase"; + +describe("SignMessageUseCase", () => { + it("should call signPersonalMessage on appBinder with the correct arguments", () => { + // Given + const derivationPath = "44'/501'/0'/0'"; + const message = "Hello world"; + const appBinder = { + signMessage: jest.fn(), + }; + const signMessageUseCase = new SignMessageUseCase( + appBinder as unknown as BtcAppBinder, + ); + + // When + signMessageUseCase.execute(derivationPath, message); + + // Then + expect(appBinder.signMessage).toHaveBeenCalledWith({ + derivationPath, + message, + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts new file mode 100644 index 000000000..44caf4bbf --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts @@ -0,0 +1,25 @@ +import { inject, injectable } from "inversify"; + +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; +import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; + +@injectable() +export class SignMessageUseCase { + private _appBinder: BtcAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: BtcAppBinder, + ) { + this._appBinder = appBinding; + } + + execute(derivationPath: string, message: string): SignMessageDAReturnType { + // 1- Sign msg using the app binding + return this._appBinder.signMessage({ + derivationPath, + message, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts index 3c0474c68..7083d7cb5 100644 --- a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts +++ b/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts @@ -8,8 +8,8 @@ import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/con export class CommandUtils { static isContinueResponse(response: ApduResponse) { return ( - response.statusCode?.[0] === SW_INTERRUPTED_EXECUTION[0] && - response.statusCode?.[1] === SW_INTERRUPTED_EXECUTION[1] + response.statusCode[0] === SW_INTERRUPTED_EXECUTION[0] && + response.statusCode[1] === SW_INTERRUPTED_EXECUTION[1] ); } static isSuccessResponse(response: ApduResponse) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d079c509..a603c5960 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: '@ledgerhq/device-mockserver-client': specifier: workspace:* version: link:../../packages/mockserver-client + '@ledgerhq/device-signer-kit-bitcoin': + specifier: workspace:* + version: link:../../packages/signer/signer-btc '@ledgerhq/device-signer-kit-ethereum': specifier: workspace:* version: link:../../packages/signer/signer-eth