Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Ledger sign message support #1267

Merged
merged 7 commits into from
Jan 24, 2025
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
Loading