diff --git a/.changeset/blue-berries-jump.md b/.changeset/blue-berries-jump.md new file mode 100644 index 000000000..7f4577cb1 --- /dev/null +++ b/.changeset/blue-berries-jump.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": minor +--- + +Add GetPubKey command diff --git a/packages/signer/signer-solana/package.json b/packages/signer/signer-solana/package.json index 9b067a040..b928f6bdb 100644 --- a/packages/signer/signer-solana/package.json +++ b/packages/signer/signer-solana/package.json @@ -30,11 +30,12 @@ "prettier": "prettier . --check", "prettier:fix": "prettier . --write", "typecheck": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "pnpm test -- --watch", "test:coverage": "pnpm test -- --coverage" }, "dependencies": { + "bs58": "^6.0.0", "inversify": "^6.0.2", "inversify-logger-middleware": "^3.1.0", "purify-ts": "^2.1.0", @@ -47,6 +48,7 @@ "@ledgerhq/eslint-config-dsdk": "workspace:*", "@ledgerhq/jest-config-dsdk": "workspace:*", "@ledgerhq/prettier-config-dsdk": "workspace:*", + "@ledgerhq/signer-utils": "workspace:*", "@ledgerhq/tsconfig-dsdk": "workspace:*", "ts-node": "^10.9.2" }, diff --git a/packages/signer/signer-solana/src/api/model/PublicKey.ts b/packages/signer/signer-solana/src/api/model/PublicKey.ts index a29843cfd..adde3ae97 100644 --- a/packages/signer/signer-solana/src/api/model/PublicKey.ts +++ b/packages/signer/signer-solana/src/api/model/PublicKey.ts @@ -1 +1 @@ -export type PublicKey = Uint8Array[32]; +export type PublicKey = string; diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.test.ts new file mode 100644 index 000000000..69c5b8733 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.test.ts @@ -0,0 +1,126 @@ +import { + ApduResponse, + CommandResultFactory, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; + +import { GetPubKeyCommand } from "./GetPubKeyCommand"; + +const GET_PUBKYEY_APDU_DEFAULT_PATH_WITH_CONFIRM = new Uint8Array([ + 0xe0, 0x05, 0x01, 0x00, 0x09, 0x02, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x01, + 0xf5, +]); + +const GET_PUBKYEY_APDU_DEFAULT_PATH_WITHOUT_CONFIRM = new Uint8Array([ + 0xe0, 0x05, 0x00, 0x00, 0x09, 0x02, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x01, + 0xf5, +]); + +const GET_PUBKEY_APDU_DIFFERENT_PATH = new Uint8Array([ + 0xe0, 0x05, 0x01, 0x00, 0x09, 0x02, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x01, + 0xf6, +]); + +// D2PPQSYFe83nDzk96FqGumVU8JA7J8vj2Rhjc2oXzEi5 +const GET_PUBKEY_APDU = new Uint8Array([ + 0xb2, 0xa7, 0x22, 0xdc, 0x18, 0xdd, 0x5c, 0x49, 0xc3, 0xf4, 0x8e, 0x9b, 0x07, + 0x26, 0xf1, 0x1b, 0xe6, 0x67, 0x86, 0xe9, 0x1c, 0xac, 0x57, 0x34, 0x98, 0xd6, + 0xee, 0x88, 0x39, 0x2c, 0xc9, 0x6a, 0x90, 0x00, +]); + +const GET_PUBKEY_APDU_RESPONSE = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: GET_PUBKEY_APDU, +}); + +describe("GetPubKeyCommand", () => { + let command: GetPubKeyCommand; + const defaultArgs = { + derivationPath: "44'/501'", + checkOnDevice: true, + }; + + beforeEach(() => { + command = new GetPubKeyCommand(defaultArgs); + jest.clearAllMocks(); + jest.requireActual("@ledgerhq/device-management-kit"); + }); + + describe("getApdu", () => { + it("should return APDU", () => { + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toEqual( + GET_PUBKYEY_APDU_DEFAULT_PATH_WITH_CONFIRM, + ); + }); + + it("should return APDU without confirm", () => { + command = new GetPubKeyCommand({ + ...defaultArgs, + checkOnDevice: false, + }); + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toEqual( + GET_PUBKYEY_APDU_DEFAULT_PATH_WITHOUT_CONFIRM, + ); + }); + + it("should return APDU with different path", () => { + command = new GetPubKeyCommand({ + ...defaultArgs, + derivationPath: "44'/502'", + }); + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toEqual(GET_PUBKEY_APDU_DIFFERENT_PATH); + }); + }); + + describe("parseResponse", () => { + it("should parse the response", () => { + const parsed = command.parseResponse(GET_PUBKEY_APDU_RESPONSE); + expect(parsed).toStrictEqual( + CommandResultFactory({ + data: "D2PPQSYFe83nDzk96FqGumVU8JA7J8vj2Rhjc2oXzEi5", + }), + ); + }); + + describe("error handling", () => { + it("should return error if response is not success", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x6a, 0x82]), + data: new Uint8Array(0), + }); + const result = command.parseResponse(response); + expect(isSuccessCommandResult(result)).toBe(false); + if (!isSuccessCommandResult(result)) { + expect(result.error).toEqual( + expect.objectContaining({ + message: "Unexpected device exchange error happened.", + }), + ); + } else { + fail("Expected error"); + } + }); + + it("should return error if public key is missing", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array(0), + }); + const result = command.parseResponse(response); + expect(isSuccessCommandResult(result)).toBe(false); + if (!isSuccessCommandResult(result)) { + expect(result.error.originalError).toEqual( + expect.objectContaining({ + message: "Public key is missing", + }), + ); + } else { + fail("Expected error"); + } + }); + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.ts new file mode 100644 index 000000000..f7f0cecaa --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/GetPubKeyCommand.ts @@ -0,0 +1,86 @@ +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandResult, + CommandResultFactory, + CommandUtils, + GlobalCommandErrorHandler, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; +import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import bs58 from "bs58"; + +import { PublicKey } from "@api/model/PublicKey"; + +const PUBKEY_LENGTH = 32; + +type GetPubKeyCommandResponse = PublicKey; +type GetPubKeyCommandArgs = { + derivationPath: string; + checkOnDevice: boolean; +}; + +export class GetPubKeyCommand + implements Command +{ + args: GetPubKeyCommandArgs; + + constructor(args: GetPubKeyCommandArgs) { + this.args = args; + } + + getApdu(): Apdu { + const getPubKeyArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x05, + p1: this.args.checkOnDevice ? 0x01 : 0x00, + p2: 0x00, + }; + const builder = new ApduBuilder(getPubKeyArgs); + const derivationPath = this.args.derivationPath; + + const path = DerivationPathUtils.splitPath(derivationPath); + builder.add8BitUIntToData(path.length); + path.forEach((element) => { + builder.add32BitUIntToData(element); + }); + + return builder.build(); + } + + parseResponse( + response: ApduResponse, + ): CommandResult { + const parser = new ApduParser(response); + + if (!CommandUtils.isSuccessResponse(response)) { + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(response), + }); + } + + if (parser.testMinimalLength(PUBKEY_LENGTH) === false) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Public key is missing"), + }); + } + + const buffer = parser.extractFieldByLength(PUBKEY_LENGTH); + + if (buffer === undefined) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Unable to extract public key"), + }); + } + + const publicKey = bs58.encode(buffer); + + return CommandResultFactory({ + data: publicKey, + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 989ab3d42..30f244868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -461,6 +461,9 @@ importers: packages/signer/signer-solana: dependencies: + bs58: + specifier: ^6.0.0 + version: 6.0.0 inversify: specifier: ^6.0.2 version: 6.0.2 @@ -492,6 +495,9 @@ importers: '@ledgerhq/prettier-config-dsdk': specifier: workspace:* version: link:../../config/prettier + '@ledgerhq/signer-utils': + specifier: workspace:* + version: link:../signer-utils '@ledgerhq/tsconfig-dsdk': specifier: workspace:* version: link:../../config/typescript @@ -3326,6 +3332,9 @@ packages: base-x@4.0.0: resolution: {integrity: sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==} + base-x@5.0.0: + resolution: {integrity: sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3404,6 +3413,9 @@ packages: bs58@5.0.0: resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + bs58check@3.0.1: resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} @@ -11164,6 +11176,8 @@ snapshots: base-x@4.0.0: {} + base-x@5.0.0: {} + base64-js@1.5.1: {} basic-ftp@5.0.4: {} @@ -11251,6 +11265,10 @@ snapshots: dependencies: base-x: 4.0.0 + bs58@6.0.0: + dependencies: + base-x: 5.0.0 + bs58check@3.0.1: dependencies: '@noble/hashes': 1.3.2