Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/keyring-eth-ledger-bridge/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added 7702 methods ([#564](https://github.com/MetaMask/accounts/pull/564))

### Changed

- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.2.0` ([#562](https://github.com/MetaMask/accounts/pull/562))
Expand Down
6 changes: 3 additions & 3 deletions packages/keyring-eth-ledger-bridge/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ module.exports = merge(baseConfig, {
coverageThreshold: {
global: {
branches: 93.53,
functions: 98.3,
lines: 97.94,
statements: 97.96,
functions: 97.03,
lines: 97.73,
statements: 97.75,
},
},
});
15 changes: 15 additions & 0 deletions packages/keyring-eth-ledger-bridge/src/ledger-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export type LedgerSignTypedDataResponse = Awaited<
ReturnType<LedgerHwAppEth['signEIP712HashedMessage']>
>;

export type LedgerSignDelegationAuthorizationParams = {
hdPath: string;
chainId: number;
contractAddress: string;
nonce: number;
};

export type LedgerSignDelegationAuthorizationResponse = Awaited<
ReturnType<LedgerHwAppEth['signTransaction']>
>;

export type GetAppNameAndVersionResponse = {
appName: string;
version: string;
Expand Down Expand Up @@ -77,6 +88,10 @@ export type LedgerBridge<T extends LedgerBridgeOptions> = {
params: LedgerSignTypedDataParams,
): Promise<LedgerSignTypedDataResponse>;

deviceSignDelegationAuthorization(
params: LedgerSignDelegationAuthorizationParams,
): Promise<LedgerSignDelegationAuthorizationResponse>;

/**
* Method to retrieve the name and version of the running application on the Ledger device.
*
Expand Down
10 changes: 10 additions & 0 deletions packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
GetPublicKeyParams,
GetPublicKeyResponse,
LedgerBridge,
LedgerSignDelegationAuthorizationParams,
LedgerSignDelegationAuthorizationResponse,
LedgerSignMessageParams,
LedgerSignMessageResponse,
LedgerSignTransactionParams,
Expand Down Expand Up @@ -238,6 +240,14 @@ export class LedgerIframeBridge implements LedgerBridge<LedgerIframeBridgeOption
);
}

async deviceSignDelegationAuthorization(
_params: LedgerSignDelegationAuthorizationParams,
): Promise<LedgerSignDelegationAuthorizationResponse> {
throw new Error(
'Ledger: signDelegationAuthorization is not supported via iframe bridge',
);
}

async getAppNameAndVersion(): Promise<GetAppNameAndVersionResponse> {
return this.#deviceActionMessage(
IFrameMessageAction.LedgerGetAppNameAndVersion,
Expand Down
162 changes: 162 additions & 0 deletions packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,168 @@ describe('LedgerKeyring', function () {
});
});

describe('signEip7702Authorization', function () {
const chainId = 1;
const contractAddress = '0x1234567890abcdef1234567890abcdef12345678';
const nonce = 5;
const authorization: [number, Hex, number] = [
chainId,
contractAddress as Hex,
nonce,
];

beforeEach(async function () {
jest
.spyOn(keyring, 'unlockAccountByAddress')
.mockResolvedValue(`m/44'/60'/15'`);
await basicSetupToUnlockOneAccount(15);
});

it('calls deviceSignDelegationAuthorization with correct params', async function () {
const signDelegationSpy = jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockResolvedValue({
v: '1b',
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
});

jest
.spyOn(sigUtil, 'recoverEIP7702Authorization')
.mockReturnValue(fakeAccounts[15]);

await keyring.signEip7702Authorization(fakeAccounts[15], authorization);

expect(signDelegationSpy).toHaveBeenCalledWith({
hdPath: "m/44'/60'/15'",
chainId,
contractAddress: remove0x(contractAddress),
nonce,
});
});

it('returns signature with yParity from v=0', async function () {
jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockResolvedValue({
v: '0',
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
});

jest
.spyOn(sigUtil, 'recoverEIP7702Authorization')
.mockReturnValue(fakeAccounts[15]);

const result = await keyring.signEip7702Authorization(
fakeAccounts[15],
authorization,
);

expect(result).toBe(
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e3200',
);
});

it('returns signature with yParity from v=27', async function () {
jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockResolvedValue({
v: '27',
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
});

jest
.spyOn(sigUtil, 'recoverEIP7702Authorization')
.mockReturnValue(fakeAccounts[15]);

const result = await keyring.signEip7702Authorization(
fakeAccounts[15],
authorization,
);

expect(result).toBe(
'0x72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b946759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e3200',
);
});

it('returns signature with yParity 0 when v is hex 1b (27)', async function () {
const signingAccount =
'0x9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f' as Hex;
const signedAuthorization: [number, Hex, number] = [
chainId,
contractAddress as Hex,
0,
];

jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockResolvedValue({
v: '1b',
r: '0cab9d92511ba1089c6dda5b59b5e10ba73ec91d6576ee514f5e678468ebdd34',
s: '4ef284890f772272a9c00a84dfe949ad2513b42cd1053d1536330ddc01b5587f',
});

const result = await keyring.signEip7702Authorization(
signingAccount,
signedAuthorization,
);

expect(result).toBe(
'0x0cab9d92511ba1089c6dda5b59b5e10ba73ec91d6576ee514f5e678468ebdd344ef284890f772272a9c00a84dfe949ad2513b42cd1053d1536330ddc01b5587f00',
);
});

it('throws when recovered address does not match', async function () {
jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockResolvedValue({
v: '1',
r: '72d4e38a0e582e09a620fd38e236fe687a1ec782206b56d576f579c026a7e5b9',
s: '46759735981cd0c3efb02d36df28bb2feedfec3d90e408efc93f45b894946e32',
});

jest
.spyOn(sigUtil, 'recoverEIP7702Authorization')
.mockReturnValue(fakeAccounts[0]);

await expect(
keyring.signEip7702Authorization(fakeAccounts[15], authorization),
).rejects.toThrow(
'Ledger: The EIP-7702 authorization signature does not match the right address',
);
});

it('throws when hdPath is not found', async function () {
jest
.spyOn(keyring, 'unlockAccountByAddress')
.mockResolvedValue(undefined);

await expect(
keyring.signEip7702Authorization(fakeAccounts[0], authorization),
).rejects.toThrow(
'Ledger: Unknown error while signing EIP-7702 authorization',
);
});

it('handles transport errors', async function () {
const transportError = {
statusCode: 27013,
message: 'Ledger device: (denied by the user?) (0x6985)',
name: 'TransportStatusError',
};
Object.setPrototypeOf(transportError, TransportStatusError.prototype);
jest
.spyOn(keyring.bridge, 'deviceSignDelegationAuthorization')
.mockRejectedValue(transportError);

await expect(
keyring.signEip7702Authorization(fakeAccounts[15], authorization),
).rejects.toThrow('Ledger: User rejected action on device');
});
});

describe('getAppNameAndVersion', function () {
it('returns app name and version from bridge', async function () {
const mockResponse = {
Expand Down
76 changes: 74 additions & 2 deletions packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { TypedTransaction } from '@ethereumjs/tx';
import { publicToAddress } from '@ethereumjs/util';
import type { MessageTypes, TypedMessage } from '@metamask/eth-sig-util';
import {
recoverEIP7702Authorization,
recoverPersonalSignature,
recoverTypedSignature,
SignTypedDataVersion,
Expand Down Expand Up @@ -483,7 +484,7 @@ export class LedgerKeyring implements Keyring {
}

const modifiedV = this.#normalizeRecoveryParam(
parseInt(String(payload.v), 10),
this.#parseLedgerRecoveryParam(payload.v),
);

const signature = `0x${payload.r}${payload.s}${modifiedV}`;
Expand Down Expand Up @@ -575,7 +576,7 @@ export class LedgerKeyring implements Keyring {
}

const recoveryId = this.#normalizeRecoveryParam(
parseInt(String(payload.v), 10),
this.#parseLedgerRecoveryParam(payload.v),
);
const signature = `0x${payload.r}${payload.s}${recoveryId}`;
const addressSignedWith = recoverTypedSignature({
Expand All @@ -593,6 +594,57 @@ export class LedgerKeyring implements Keyring {
return signature;
}

async signEip7702Authorization(
withAccount: Hex,
authorization: [chainId: number, contractAddress: Hex, nonce: number],
): Promise<string> {
const [chainId, contractAddress, nonce] = authorization;
const hdPath = await this.unlockAccountByAddress(withAccount);

if (!hdPath) {
throw new Error(
'Ledger: Unknown error while signing EIP-7702 authorization',
);
}

let payload;
try {
payload = await this.bridge.deviceSignDelegationAuthorization({
hdPath,
chainId,
contractAddress: remove0x(contractAddress),
nonce,
});
} catch (error: unknown) {
handleLedgerTransportError(
error,
'Ledger: Unknown error while signing EIP-7702 authorization',
);
}

const recoveryValue = this.#parseLedgerRecoveryParam(payload.v);
const yParity =
recoveryValue === 0 || recoveryValue === 1
? recoveryValue
: recoveryValue - 27;
const signature = `0x${payload.r}${payload.s}${yParity.toString(16).padStart(2, '0')}`;

const addressSignedWith = recoverEIP7702Authorization({
signature,
authorization,
});

if (
this.#getChecksumHexAddress(addressSignedWith) !==
this.#getChecksumHexAddress(withAccount)
) {
throw new Error(
'Ledger: The EIP-7702 authorization signature does not match the right address',
);
}
return signature;
}

forgetDevice(): void {
this.deviceId = '';
this.accounts = [];
Expand Down Expand Up @@ -720,6 +772,26 @@ export class LedgerKeyring implements Keyring {
return getChecksumAddress(add0x(address));
}

/**
* Parses the recovery parameter (`v`) returned by a Ledger device.
* Ledger may return `v` as a number or as a decimal/hex string (e.g. `'1b'` for 27).
*
* @param recoveryParam - The recovery parameter from Ledger.
* @returns The recovery parameter as a number.
*/
#parseLedgerRecoveryParam(recoveryParam: string | number): number {
if (typeof recoveryParam === 'number') {
return recoveryParam;
}

const value = String(recoveryParam).trim();
const withoutPrefix =
value.startsWith('0x') || value.startsWith('0X') ? value.slice(2) : value;
const radix = /[a-f]/iu.test(withoutPrefix) ? 16 : 10;

return parseInt(withoutPrefix, radix);
}

/**
* Normalizes the signature recovery parameter (v) to legacy format.
* Ledger devices may return v as 0 or 1 (modern format), but signature
Expand Down
13 changes: 13 additions & 0 deletions packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
GetPublicKeyParams,
GetPublicKeyResponse,
LedgerBridge,
LedgerSignDelegationAuthorizationParams,
LedgerSignDelegationAuthorizationResponse,
LedgerSignMessageParams,
LedgerSignMessageResponse,
LedgerSignTransactionParams,
Expand Down Expand Up @@ -102,6 +104,17 @@ export class LedgerMobileBridge implements MobileBridge {
return this.#getEthApp().signEIP712Message(hdPath, message);
}

async deviceSignDelegationAuthorization({
hdPath: _hdPath,
chainId: _chainId,
contractAddress: _contractAddress,
nonce: _nonce,
}: LedgerSignDelegationAuthorizationParams): Promise<LedgerSignDelegationAuthorizationResponse> {
throw new Error(
'Ledger: signDelegationAuthorization is not supported via mobile bridge',
);
}

/**
* Method to sign a transaction
* Sending the hexadecimal transaction message to the device and returning the signed transaction.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@metamask/keyring-api';
import type { KeyringAccount } from '@metamask/keyring-api';
import { KeyringType } from '@metamask/keyring-api/v2';
import { EthKeyringMethod } from '@metamask/keyring-sdk/v2';
import HDKey from 'hdkey';

import type { LedgerBridge, LedgerBridgeOptions } from '../ledger-bridge';
Expand Down Expand Up @@ -47,6 +48,7 @@ const EXPECTED_METHODS = [
EthMethod.SignTransaction,
EthMethod.PersonalSign,
EthMethod.SignTypedDataV4,
EthKeyringMethod.SignEip7702Authorization,
];

/**
Expand Down
Loading
Loading