From 6d80d51acee5bac70e71ce524c73e4115efb3c8a Mon Sep 17 00:00:00 2001 From: Jordi Parra Crespo Date: Tue, 10 Dec 2024 11:40:07 +0100 Subject: [PATCH 1/5] feat(ledger): add sign message to ledger --- packages/ledger/src/lib/ledger-client.ts | 66 +++++++++++---- packages/ledger/src/lib/ledger.ts | 84 ++++++++++++++++++- .../ledger/src/lib/nep413/ledger-payload.ts | 45 ++++++++++ 3 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 packages/ledger/src/lib/nep413/ledger-payload.ts diff --git a/packages/ledger/src/lib/ledger-client.ts b/packages/ledger/src/lib/ledger-client.ts index fc99690fc..94136e335 100644 --- a/packages/ledger/src/lib/ledger-client.ts +++ b/packages/ledger/src/lib/ledger-client.ts @@ -7,13 +7,21 @@ import * as nearAPI from "near-api-js"; // - https://github.com/LedgerHQ/app-near/blob/master/workdir/app-near/src/constants.h export const CLA = 0x80; // Always the same for Ledger. -export const INS_SIGN = 0x02; // Sign -export const INS_GET_PUBLIC_KEY = 0x04; // Get Public Key -export const INS_GET_APP_VERSION = 0x06; // Get App Version + +export enum NEAR_INS { + GET_VERSION = 0x06, + GET_PUBLIC_KEY = 0x04, + GET_WALLET_ID = 0x05, + SIGN_TRANSACTION = 0x02, + NEP413_SIGN_MESSAGE = 0x07, + NEP366_SIGN_DELEGATE_ACTION = 0x08, +} + export const P1_LAST = 0x80; // End of Bytes to Sign (finalize) export const P1_MORE = 0x00; // More bytes coming export const P1_IGNORE = 0x00; export const P2_IGNORE = 0x00; +export const CHUNK_SIZE = 250; // Converts BIP32-compliant derivation path to a Buffer. // More info here: https://github.com/LedgerHQ/ledger-live-common/blob/master/docs/derivation.md @@ -46,10 +54,17 @@ interface GetPublicKeyParams { } interface SignParams { - data: Uint8Array; + data: Buffer; derivationPath: string; } +interface InternalSignParams extends SignParams { + ins: + | NEAR_INS.NEP366_SIGN_DELEGATE_ACTION + | NEAR_INS.NEP413_SIGN_MESSAGE + | NEAR_INS.SIGN_TRANSACTION; +} + interface EventMap { disconnect: Error; } @@ -128,7 +143,7 @@ export class LedgerClient { const res = await this.transport.send( CLA, - INS_GET_APP_VERSION, + NEAR_INS.GET_VERSION, P1_IGNORE, P2_IGNORE ); @@ -145,7 +160,7 @@ export class LedgerClient { const res = await this.transport.send( CLA, - INS_GET_PUBLIC_KEY, + NEAR_INS.GET_PUBLIC_KEY, P2_IGNORE, networkId, parseDerivationPath(derivationPath) @@ -154,7 +169,11 @@ export class LedgerClient { return nearAPI.utils.serialize.base_encode(res.subarray(0, -2)); }; - sign = async ({ data, derivationPath }: SignParams) => { + private internalSign = async ({ + data, + derivationPath, + ins, + }: InternalSignParams) => { if (!this.transport) { throw new Error("Device not connected"); } @@ -162,19 +181,14 @@ export class LedgerClient { // NOTE: getVersion call resets state to avoid starting from partially filled buffer await this.getVersion(); - // 128 - 5 service bytes - const CHUNK_SIZE = 123; - const allData = Buffer.concat([ - parseDerivationPath(derivationPath), - Buffer.from(data), - ]); + const allData = Buffer.concat([parseDerivationPath(derivationPath), data]); for (let offset = 0; offset < allData.length; offset += CHUNK_SIZE) { const isLastChunk = offset + CHUNK_SIZE >= allData.length; const response = await this.transport.send( CLA, - INS_SIGN, + ins, isLastChunk ? P1_LAST : P1_MORE, P2_IGNORE, Buffer.from(allData.subarray(offset, offset + CHUNK_SIZE)) @@ -187,4 +201,28 @@ export class LedgerClient { throw new Error("Invalid data or derivation path"); }; + + sign = async ({ data, derivationPath }: SignParams) => { + return this.internalSign({ + data, + derivationPath, + ins: NEAR_INS.SIGN_TRANSACTION, + }); + }; + + signMessage = async ({ data, derivationPath }: SignParams) => { + return this.internalSign({ + data, + derivationPath, + ins: NEAR_INS.NEP413_SIGN_MESSAGE, + }); + }; + + signDelegateAction = async ({ data, derivationPath }: SignParams) => { + return this.internalSign({ + data, + derivationPath, + ins: NEAR_INS.NEP366_SIGN_DELEGATE_ACTION, + }); + }; } diff --git a/packages/ledger/src/lib/ledger.ts b/packages/ledger/src/lib/ledger.ts index 69fb7b86a..9a923216e 100644 --- a/packages/ledger/src/lib/ledger.ts +++ b/packages/ledger/src/lib/ledger.ts @@ -8,8 +8,14 @@ import type { HardwareWallet, Transaction, Optional, + SignMessageParams, + SignedMessage, +} from "@near-wallet-selector/core"; +import { + getActiveAccount, + verifyFullKeyBelongsToUser, + verifySignature, } from "@near-wallet-selector/core"; -import { getActiveAccount } from "@near-wallet-selector/core"; import { isLedgerSupported, LedgerClient } from "./ledger-client"; import type { Subscription } from "./ledger-client"; @@ -17,6 +23,7 @@ import type { Signer } from "near-api-js"; import * as nearAPI from "near-api-js"; import type { FinalExecutionOutcome } from "near-api-js/lib/providers"; import icon from "./icon"; +import { serializeLedgerNEP413Payload } from "./nep413/ledger-payload"; interface LedgerAccount extends Account { derivationPath: string; @@ -86,7 +93,7 @@ const Ledger: WalletBehaviourFactory = async ({ } const signature = await _state.client.sign({ - data: message, + data: Buffer.from(message), derivationPath: account.derivationPath, }); @@ -276,6 +283,79 @@ const Ledger: WalletBehaviourFactory = async ({ return await _state.client.getPublicKey({ derivationPath }); }, + + async signMessage({ + message, + nonce, + recipient, + callbackUrl, + }: SignMessageParams): Promise { + logger.log("signMessage", { message, nonce, recipient, callbackUrl }); + + if (!_state.accounts.length) { + throw new Error("Wallet not signed in"); + } + + const account = getActiveAccount(store.getState()); + + if (!account) { + throw new Error("No active account"); + } + + const ledgerAccount = _state.accounts.find( + (a) => a.accountId === account.accountId + ); + + if (!ledgerAccount) { + throw new Error("Failed to find account for signing"); + } + + const publicKeyExistsInAccount = await verifyFullKeyBelongsToUser({ + publicKey: ledgerAccount.publicKey, + accountId: account.accountId, + network: options.network, + }); + + if (!publicKeyExistsInAccount) { + throw new Error("Failed to find account for signing"); + } + + // Note: Connection must be triggered by user interaction. + await connectLedgerDevice(); + + const serializedPayload = serializeLedgerNEP413Payload({ + message, + nonce, + recipient, + callbackUrl, + }); + + const signature = await _state.client.signMessage({ + data: serializedPayload, + derivationPath: ledgerAccount.derivationPath, + }); + + const encodedSignature = Buffer.from(signature).toString("base64"); + + const isSignatureValid = verifySignature({ + publicKey: ledgerAccount.publicKey, + signature: encodedSignature, + message, + nonce, + recipient, + callbackUrl, + }); + + if (!isSignatureValid) { + throw new Error("Failed to verify signature"); + } + + return { + accountId: ledgerAccount.accountId, + publicKey: "ed25519:" + ledgerAccount.publicKey, + signature: encodedSignature, + }; + }, }; }; diff --git a/packages/ledger/src/lib/nep413/ledger-payload.ts b/packages/ledger/src/lib/nep413/ledger-payload.ts new file mode 100644 index 000000000..237f91020 --- /dev/null +++ b/packages/ledger/src/lib/nep413/ledger-payload.ts @@ -0,0 +1,45 @@ +import { serialize } from "borsh"; + +export class LedgerPayload { + message: string; + nonce: Buffer; + recipient: string; + callbackUrl?: string; + + constructor(data: LedgerPayload) { + this.message = data.message; + this.nonce = data.nonce; + this.recipient = data.recipient; + if (data.callbackUrl) { + this.callbackUrl = data.callbackUrl; + } + } +} + +export const ledgerPayloadSchema = new Map([ + [ + LedgerPayload, + { + kind: "struct", + fields: [ + ["message", "string"], + ["nonce", [32]], + ["recipient", "string"], + [ + "callbackUrl", + { + kind: "option", + type: "string", + }, + ], + ], + }, + ], +]); + +export const serializeLedgerNEP413Payload = ( + ledgerPayload: LedgerPayload +): Buffer => { + const payload = new LedgerPayload({ ...ledgerPayload }); + return Buffer.from(serialize(ledgerPayloadSchema, payload)); +}; From 170480b57d3844d001996e2fc2fdc7f1ca1efe51 Mon Sep 17 00:00:00 2001 From: Jordi Parra Crespo Date: Tue, 10 Dec 2024 15:12:16 +0100 Subject: [PATCH 2/5] test(ledger): update test for ledger and ledger-client with sign message --- packages/ledger/src/lib/ledger-client.spec.ts | 98 ++++++++++++++++--- packages/ledger/src/lib/ledger.spec.ts | 47 +++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/packages/ledger/src/lib/ledger-client.spec.ts b/packages/ledger/src/lib/ledger-client.spec.ts index 5d942ee8a..22bf560ab 100644 --- a/packages/ledger/src/lib/ledger-client.spec.ts +++ b/packages/ledger/src/lib/ledger-client.spec.ts @@ -47,6 +47,19 @@ const createTransactionMock = () => { ); }; +const createSignMessageMock = () => { + /** + * This is a hex encoded payload that is sent to the Ledger device. + * message: "Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts./Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts./Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts.", + * nonce: new Array(32).fill(42), + * recipient: "alice.near", + * callbackUrl: "myapp.com/callback", + */ + const hexEncodedPayload = + "180200004d616b657320697420706f737369626c6520746f2061757468656e74696361746520757365727320776974686f757420686176696e6720746f20616464206e657720616363657373206b6579732e20546869732077696c6c20696d70726f76652055582c2073617665206d6f6e657920616e642077696c6c206e6f7420696e63726561736520746865206f6e2d636861696e2073746f72616765206f662074686520757365727327206163636f756e74732e2f4d616b657320697420706f737369626c6520746f2061757468656e74696361746520757365727320776974686f757420686176696e6720746f20616464206e657720616363657373206b6579732e20546869732077696c6c20696d70726f76652055582c2073617665206d6f6e657920616e642077696c6c206e6f7420696e63726561736520746865206f6e2d636861696e2073746f72616765206f662074686520757365727327206163636f756e74732e2f4d616b657320697420706f737369626c6520746f2061757468656e74696361746520757365727320776974686f757420686176696e6720746f20616464206e657720616363657373206b6579732e20546869732077696c6c20696d70726f76652055582c2073617665206d6f6e657920616e642077696c6c206e6f7420696e63726561736520746865206f6e2d636861696e2073746f72616765206f662074686520757365727327206163636f756e74732e2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a0a000000616c6963652e6e65617201120000006d796170702e636f6d2f63616c6c6261636b"; + return Buffer.from(hexEncodedPayload, "hex"); +}; + const createLedgerClient = (params: CreateLedgerClientParams = {}) => { const client = mock(params.client); const transport = mock(params.transport); @@ -63,9 +76,7 @@ const createLedgerClient = (params: CreateLedgerClientParams = {}) => { const { LedgerClient, CLA, - INS_SIGN, - INS_GET_APP_VERSION, - INS_GET_PUBLIC_KEY, + NEAR_INS, P1_LAST, P1_IGNORE, P2_IGNORE, @@ -80,9 +91,11 @@ const createLedgerClient = (params: CreateLedgerClientParams = {}) => { parseDerivationPath, constants: { CLA, - INS_SIGN, - INS_GET_APP_VERSION, - INS_GET_PUBLIC_KEY, + INS_SIGN_TRANSACTION: NEAR_INS.SIGN_TRANSACTION, + INS_GET_APP_VERSION: NEAR_INS.GET_VERSION, + INS_GET_PUBLIC_KEY: NEAR_INS.GET_PUBLIC_KEY, + INS_NEP413_SIGN_MESSAGE: NEAR_INS.NEP413_SIGN_MESSAGE, + INS_NEP366_SIGN_DELEGATE_ACTION: NEAR_INS.NEP366_SIGN_DELEGATE_ACTION, P1_LAST, P1_IGNORE, P2_IGNORE, @@ -154,32 +167,93 @@ describe("sign", () => { const data = nearAPI.transactions.encodeTransaction(transaction); await client.connect(); + const result = await client.sign({ + data: Buffer.from(data), + derivationPath: "44'/397'/0'/0'/1'", + }); + + //Get version call + expect(transport.send).toHaveBeenNthCalledWith( + 1, + constants.CLA, + constants.INS_GET_APP_VERSION, + constants.P1_IGNORE, + constants.P2_IGNORE + ); + + //Sign call + expect(transport.send).toHaveBeenNthCalledWith( + 2, + constants.CLA, + constants.INS_SIGN_TRANSACTION, + constants.P1_LAST, + constants.P2_IGNORE, + expect.any(Buffer) + ); + + expect(transport.send).toHaveBeenCalledTimes(2); + expect(result).toEqual(Buffer.from([1])); + }); +}); + +describe("signMessage", () => { + it("returns the signature", async () => { + const { client, transport, constants } = createLedgerClient({ + transport: { + send: jest.fn().mockResolvedValue(Buffer.from([1, 2, 3])), + }, + }); + + const data = createSignMessageMock(); + + await client.connect(); + + const result = await client.signMessage({ data, derivationPath: "44'/397'/0'/0'/1'", }); - expect(transport.send).toHaveBeenCalledWith( + //Get version call + expect(transport.send).toHaveBeenNthCalledWith( + 1, constants.CLA, constants.INS_GET_APP_VERSION, constants.P1_IGNORE, constants.P2_IGNORE ); - expect(transport.send).toHaveBeenCalledWith( + + //Sign call 1 + expect(transport.send).toHaveBeenNthCalledWith( + 2, constants.CLA, - constants.INS_SIGN, + constants.INS_NEP413_SIGN_MESSAGE, constants.P1_IGNORE, constants.P2_IGNORE, expect.any(Buffer) ); - expect(transport.send).toHaveBeenCalledWith( + + //Sign call 2 + expect(transport.send).toHaveBeenNthCalledWith( + 3, constants.CLA, - constants.INS_SIGN, + constants.INS_NEP413_SIGN_MESSAGE, + constants.P1_IGNORE, + constants.P2_IGNORE, + expect.any(Buffer) + ); + + //Sign call 3 + expect(transport.send).toHaveBeenNthCalledWith( + 4, + constants.CLA, + constants.INS_NEP413_SIGN_MESSAGE, constants.P1_LAST, constants.P2_IGNORE, expect.any(Buffer) ); - expect(transport.send).toHaveBeenCalledTimes(3); + + expect(transport.send).toHaveBeenCalledTimes(4); expect(result).toEqual(Buffer.from([1])); }); }); diff --git a/packages/ledger/src/lib/ledger.spec.ts b/packages/ledger/src/lib/ledger.spec.ts index 5004e2bf4..b50df7e7e 100644 --- a/packages/ledger/src/lib/ledger.spec.ts +++ b/packages/ledger/src/lib/ledger.spec.ts @@ -23,8 +23,20 @@ const createLedgerWallet = async () => { 218, 45, 220, 10, 4, ]) ), + signMessage: jest + .fn() + .mockResolvedValue( + Buffer.from("fn39aKtzVFDMJOYZiYTWBiE6HQh1QsmGbESQRMRS9dTidGcrDogXIarCvsMUfKsx79iDLicwjGCN7XO8fnYWDA==", "base64") + ), }); + jest.mock("@near-wallet-selector/core", () => { + return { + ...jest.requireActual("@near-wallet-selector/core"), + verifySignature: jest.fn().mockReturnValue(true), + } + }) + jest.mock("./ledger-client", () => { const module = jest.requireActual("./ledger-client"); @@ -174,6 +186,41 @@ describe("signAndSendTransactions", () => { }); }); +describe("signMessage", () => { + it("returns signature", async () => { + const accountId = "amirsaran.testnet"; + const derivationPath = "44'/397'/0'/0'/1'"; + const { wallet, ledgerClient, publicKey } = await createLedgerWallet(); + + await wallet.signIn({ + accounts: [{ derivationPath, publicKey, accountId }], + contractId: "guest-book.testnet", + }); + + const message = + "Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts./Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts./Makes it possible to authenticate users without having to add new access keys. This will improve UX, save money and will not increase the on-chain storage of the users' accounts."; + const nonce = Buffer.from(new Array(32).fill(42)); + const recipient = "alice.near"; + const callbackUrl = "myapp.com/callback"; + + const result = await wallet.signMessage!({ + message, + nonce, + recipient, + callbackUrl, + }); + + expect(ledgerClient.signMessage).toHaveBeenCalled(); + + expect(result!.signature).toBeDefined(); + + expect(result!.accountId).toEqual(accountId); + + expect(result!.publicKey).toEqual("ed25519:" + publicKey); + + }); +}); + describe("getPublicKey", () => { it("returns public key", async () => { const accountId = "amirsaran.testnet"; From 9e6491074e03026f5883c77397e34ba30c43617b Mon Sep 17 00:00:00 2001 From: Jordi Parra Crespo Date: Tue, 10 Dec 2024 15:22:57 +0100 Subject: [PATCH 3/5] fix(ledger): improve error message --- packages/ledger/src/lib/ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ledger/src/lib/ledger.ts b/packages/ledger/src/lib/ledger.ts index 9a923216e..a98ba360c 100644 --- a/packages/ledger/src/lib/ledger.ts +++ b/packages/ledger/src/lib/ledger.ts @@ -317,7 +317,7 @@ const Ledger: WalletBehaviourFactory = async ({ }); if (!publicKeyExistsInAccount) { - throw new Error("Failed to find account for signing"); + throw new Error("Public key not found for the active account."); } // Note: Connection must be triggered by user interaction. From 8c48c135a79630123e9d83bb311f39526c5cc8f2 Mon Sep 17 00:00:00 2001 From: Jordi Parra Crespo Date: Tue, 10 Dec 2024 15:32:58 +0100 Subject: [PATCH 4/5] style(ledger): lint the code --- packages/ledger/src/lib/ledger.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/ledger/src/lib/ledger.spec.ts b/packages/ledger/src/lib/ledger.spec.ts index b50df7e7e..446714e65 100644 --- a/packages/ledger/src/lib/ledger.spec.ts +++ b/packages/ledger/src/lib/ledger.spec.ts @@ -26,7 +26,10 @@ const createLedgerWallet = async () => { signMessage: jest .fn() .mockResolvedValue( - Buffer.from("fn39aKtzVFDMJOYZiYTWBiE6HQh1QsmGbESQRMRS9dTidGcrDogXIarCvsMUfKsx79iDLicwjGCN7XO8fnYWDA==", "base64") + Buffer.from( + "fn39aKtzVFDMJOYZiYTWBiE6HQh1QsmGbESQRMRS9dTidGcrDogXIarCvsMUfKsx79iDLicwjGCN7XO8fnYWDA==", + "base64" + ) ), }); @@ -34,8 +37,8 @@ const createLedgerWallet = async () => { return { ...jest.requireActual("@near-wallet-selector/core"), verifySignature: jest.fn().mockReturnValue(true), - } - }) + }; + }); jest.mock("./ledger-client", () => { const module = jest.requireActual("./ledger-client"); @@ -217,7 +220,6 @@ describe("signMessage", () => { expect(result!.accountId).toEqual(accountId); expect(result!.publicKey).toEqual("ed25519:" + publicKey); - }); }); From 8836b9032104df8c6e59004693853121c16587e2 Mon Sep 17 00:00:00 2001 From: Jordi Parra Crespo Date: Thu, 19 Dec 2024 15:15:13 +0100 Subject: [PATCH 5/5] fix(ledger): update to new borsh version --- .../ledger/src/lib/nep413/ledger-payload.ts | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/ledger/src/lib/nep413/ledger-payload.ts b/packages/ledger/src/lib/nep413/ledger-payload.ts index 237f91020..bcc42a946 100644 --- a/packages/ledger/src/lib/nep413/ledger-payload.ts +++ b/packages/ledger/src/lib/nep413/ledger-payload.ts @@ -1,4 +1,5 @@ import { serialize } from "borsh"; +import type { Schema } from "borsh"; export class LedgerPayload { message: string; @@ -16,26 +17,14 @@ export class LedgerPayload { } } -export const ledgerPayloadSchema = new Map([ - [ - LedgerPayload, - { - kind: "struct", - fields: [ - ["message", "string"], - ["nonce", [32]], - ["recipient", "string"], - [ - "callbackUrl", - { - kind: "option", - type: "string", - }, - ], - ], - }, - ], -]); +export const ledgerPayloadSchema: Schema = { + struct: { + message: "string", + nonce: { array: { type: "u8", len: 32 } }, + recipient: "string", + callbackUrl: { option: "string" }, + }, +}; export const serializeLedgerNEP413Payload = ( ledgerPayload: LedgerPayload