Skip to content

Commit

Permalink
Merge pull request #1267 from Peersyst/ledger/feat/sign-message
Browse files Browse the repository at this point in the history
feat: Ledger sign message support
  • Loading branch information
trechriron authored Jan 24, 2025
2 parents 0dd705a + 41030ba commit 9c95789
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 28 deletions.
98 changes: 86 additions & 12 deletions packages/ledger/src/lib/ledger-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransportWebHID>(params.client);
const transport = mock<Transport>(params.transport);
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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]));
});
});
Expand Down
66 changes: 52 additions & 14 deletions packages/ledger/src/lib/ledger-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
);
Expand All @@ -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)
Expand All @@ -154,27 +169,26 @@ 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");
}

// 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))
Expand All @@ -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,
});
};
}
49 changes: 49 additions & 0 deletions packages/ledger/src/lib/ledger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ 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", () => {
Expand Down Expand Up @@ -174,6 +189,40 @@ 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";
Expand Down
Loading

0 comments on commit 9c95789

Please sign in to comment.