Skip to content

Commit 80b6dea

Browse files
feat: add Ledger DMK bridge (#473)
<!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? Are there any issues or other links reviewers should consult to understand this pull request better? For instance: * Fixes #12345 * See: #67890 --> This PR introduces the DMK bridge and middleware for the ledger keyring. Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1857 <!-- Are there any examples of this change being used in another repository? When considering changes to the MetaMask module template, it's strongly preferred that the change be experimented with in another repository first. This gives reviewers a better sense of how the change works, making it less likely the change will need to be reverted or adjusted later. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > New hardware-wallet transport and signing path touches all Ledger operations (keys, txs, typed data, EIP-7702); incorrect session or error mapping could break device flows or misreport user rejections. > > **Overview** > Adds a **Ledger Device Management Kit (DMK)** path to `@metamask/eth-ledger-bridge-keyring` so hosts can talk to Ledger through an injected transport factory instead of only the legacy iframe/mobile bridges. > > **`LedgerDmkBridge`** implements the shared `LedgerBridge` contract: it builds DMK internally, delegates discovery/connect/session work to **`LedgerDmkTransportMiddleware`** (cached `SignerEth` per session), and routes address derivation plus tx, personal message, EIP-712, and **EIP-7702 delegation** signing through the Ethereum signer kit. DMK/RxJS failures are normalized via **`translateDmkError`** into `TransportStatusError` so existing keyring error handling still applies. A custom **`EthGetAppConfigurationCommand`** reads Ethereum-app config (e.g. blind signing) over DMK because the signer kit does not expose that APDU publicly. The bridge also exposes **`onSessionStateChange`**, **`isDeviceConnected`**, and session monitoring with backoff so unplugged devices do not leave a stale connected state. > > The package **exports** the new bridge, middleware, and error helpers; **`ledger-keyring`** now types `SignTransactionPayload` against generic **`LedgerBridgeOptions`** (not iframe-only options). Dependencies add Ledger DMK/signer/context packages and **rxjs**; changelog and lockfile reflect the new stack. Large Jest coverage accompanies the new `dmk/` module. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c5e413a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com>
1 parent 788df79 commit 80b6dea

20 files changed

Lines changed: 2988 additions & 773 deletions

eslint-suppressions.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,6 @@
6262
"count": 4
6363
}
6464
},
65-
"packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts": {
66-
"@typescript-eslint/no-explicit-any": {
67-
"count": 2
68-
},
69-
"promise/always-return": {
70-
"count": 1
71-
}
72-
},
7365
"packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts": {
7466
"import-x/no-nodejs-modules": {
7567
"count": 1

packages/keyring-eth-ledger-bridge/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add `LedgerDmkBridge` and `LedgerDmkTransportMiddleware` for Ledger Device Management Kit (DMK) support ([#473](https://github.com/MetaMask/accounts/pull/473))
13+
- New `LedgerDmkBridge` class implementing the `LedgerBridge` interface via Ledger's DMK.
14+
- New `LedgerDmkTransportMiddleware` managing sessions, device discovery, and Ethereum signer lifecycle.
15+
- DMK error translation utilities (`translateDmkError`, `isDeviceExchangeError`) mapping to `TransportStatusError`.
16+
- Custom `EthGetAppConfigurationCommand` for retrieving Ethereum app config via DMK.
17+
- Session state monitoring with `onSessionStateChange` observable and `isDeviceConnected` getter.
18+
- Exports `LedgerDmkBridge`, `LedgerDmkBridgeOptions`, `LedgerDmkTransportMiddleware`, and DMK error utilities from the package root.
1219
- Add EIP-7702 authorization signing to the Ledger keyring ([#564](https://github.com/MetaMask/accounts/pull/564))
1320
- Not yet functional: both iframe and mobile bridges throw until DMK support lands.
1421
- Fixes hex `v` parsing (`'1b'`) for personal_sign and typed data.
1522

1623
### Changed
1724

25+
- Widen `SignTransactionPayload` type from `LedgerIframeBridgeOptions` to `LedgerBridgeOptions` for bridge compatibility ([#473](https://github.com/MetaMask/accounts/pull/473))
26+
- Remove unused `LedgerIframeBridgeOptions` import from `ledger-keyring.ts` ([#473](https://github.com/MetaMask/accounts/pull/473))
1827
- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#562](https://github.com/MetaMask/accounts/pull/562), [#569](https://github.com/MetaMask/accounts/pull/569))
1928
- Bump `@metamask/keyring-sdk` from `^2.1.1` to `^2.2.0` ([#562](https://github.com/MetaMask/accounts/pull/562))
2029

packages/keyring-eth-ledger-bridge/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,17 @@
7171
"@ethereumjs/rlp": "^5.0.2",
7272
"@ethereumjs/tx": "^5.4.0",
7373
"@ethereumjs/util": "^9.1.0",
74+
"@ledgerhq/context-module": "2.1.0",
75+
"@ledgerhq/device-management-kit": "1.5.1",
76+
"@ledgerhq/device-signer-kit-ethereum": "1.16.0",
7477
"@ledgerhq/hw-app-eth": "^6.42.0",
7578
"@ledgerhq/hw-transport": "^6.31.3",
7679
"@metamask/eth-sig-util": "^8.2.0",
7780
"@metamask/hw-wallet-sdk": "^0.8.0",
7881
"@metamask/keyring-api": "^23.3.0",
7982
"@metamask/keyring-sdk": "^2.2.0",
80-
"hdkey": "^2.1.0"
83+
"hdkey": "^2.1.0",
84+
"rxjs": "^7.8.2"
8185
},
8286
"devDependencies": {
8387
"@ethereumjs/common": "^4.4.0",
@@ -94,8 +98,8 @@
9498
"@types/ethereumjs-tx": "^1.0.1",
9599
"@types/hdkey": "^2.0.1",
96100
"@types/jest": "^29.5.12",
97-
"@types/node": "^20.12.12",
98101
"@types/web": "^0.0.69",
102+
"@types/ws": "^8.18.1",
99103
"deepmerge": "^4.2.2",
100104
"depcheck": "^1.4.7",
101105
"ethereum-cryptography": "^2.2.1",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { DeviceExchangeError } from '@ledgerhq/device-management-kit';
2+
3+
import { ETH_APP_COMMAND_ERROR_TAG } from '../eth-get-app-configuration-command';
4+
5+
/**
6+
* Construct a mock {@link DeviceExchangeError} for tests.
7+
*
8+
* The DMK `DeviceExchangeError` is normally raised inside the DMK runtime;
9+
* tests need a structurally-equivalent value without going through DMK.
10+
* This helper centralises the cast so test files don't each roll their own.
11+
*
12+
* @param errorCode - The hex error code to embed (e.g. `'6985'`).
13+
* @returns A mock `DeviceExchangeError<TErrorCode>`.
14+
*/
15+
export function createMockDeviceExchangeError<TErrorCode = string>(
16+
errorCode: TErrorCode,
17+
): DeviceExchangeError<TErrorCode> {
18+
return {
19+
_tag: ETH_APP_COMMAND_ERROR_TAG,
20+
message: `DMK error: ${String(errorCode)}`,
21+
errorCode,
22+
originalError: undefined,
23+
} as unknown as DeviceExchangeError<TErrorCode>;
24+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { TransportStatusError } from '@ledgerhq/hw-transport';
2+
3+
import { createMockDeviceExchangeError } from './__testhelpers__/mock-error';
4+
import { translateDmkError } from './dmk-error-translator';
5+
6+
describe('translateDmkError', () => {
7+
describe('EthAppCommandError (DeviceExchangeError with EthErrorCodes)', () => {
8+
it('translates error code "6985" to TransportStatusError with statusCode 0x6985', () => {
9+
const dmkError = createMockDeviceExchangeError('6985');
10+
11+
const result = translateDmkError(dmkError);
12+
13+
expect(result).toBeInstanceOf(TransportStatusError);
14+
expect(result.statusCode).toBe(0x6985);
15+
});
16+
17+
it('translates error code "6a80" to TransportStatusError with statusCode 0x6a80', () => {
18+
const dmkError = createMockDeviceExchangeError('6a80');
19+
20+
const result = translateDmkError(dmkError);
21+
22+
expect(result).toBeInstanceOf(TransportStatusError);
23+
expect(result.statusCode).toBe(0x6a80);
24+
});
25+
26+
it('translates error code "6a84" to TransportStatusError with statusCode 0x6a84', () => {
27+
const dmkError = createMockDeviceExchangeError('6a84');
28+
29+
const result = translateDmkError(dmkError);
30+
31+
expect(result).toBeInstanceOf(TransportStatusError);
32+
expect(result.statusCode).toBe(0x6a84);
33+
});
34+
35+
it('translates error code "6982" to TransportStatusError with statusCode 0x6982', () => {
36+
const dmkError = createMockDeviceExchangeError('6982');
37+
38+
const result = translateDmkError(dmkError);
39+
40+
expect(result).toBeInstanceOf(TransportStatusError);
41+
expect(result.statusCode).toBe(0x6982);
42+
});
43+
44+
it('translates error code "6b00" to TransportStatusError with statusCode 0x6b00', () => {
45+
const dmkError = createMockDeviceExchangeError('6b00');
46+
47+
const result = translateDmkError(dmkError);
48+
49+
expect(result).toBeInstanceOf(TransportStatusError);
50+
expect(result.statusCode).toBe(0x6b00);
51+
});
52+
53+
it('translates error code "6d00" to TransportStatusError with statusCode 0x6d00', () => {
54+
const dmkError = createMockDeviceExchangeError('6d00');
55+
56+
const result = translateDmkError(dmkError);
57+
58+
expect(result).toBeInstanceOf(TransportStatusError);
59+
expect(result.statusCode).toBe(0x6d00);
60+
});
61+
62+
it('translates error code "6e00" to TransportStatusError with statusCode 0x6e00', () => {
63+
const dmkError = createMockDeviceExchangeError('6e00');
64+
65+
const result = translateDmkError(dmkError);
66+
67+
expect(result).toBeInstanceOf(TransportStatusError);
68+
expect(result.statusCode).toBe(0x6e00);
69+
});
70+
71+
it('translates error code "6f00" to TransportStatusError with statusCode 0x6f00', () => {
72+
const dmkError = createMockDeviceExchangeError('6f00');
73+
74+
const result = translateDmkError(dmkError);
75+
76+
expect(result).toBeInstanceOf(TransportStatusError);
77+
expect(result.statusCode).toBe(0x6f00);
78+
});
79+
80+
it('translates error code "6a00" to TransportStatusError with statusCode 0x6a00', () => {
81+
const dmkError = createMockDeviceExchangeError('6a00');
82+
83+
const result = translateDmkError(dmkError);
84+
85+
expect(result).toBeInstanceOf(TransportStatusError);
86+
expect(result.statusCode).toBe(0x6a00);
87+
});
88+
89+
it('translates error code "6a88" to TransportStatusError with statusCode 0x6a88', () => {
90+
const dmkError = createMockDeviceExchangeError('6a88');
91+
92+
const result = translateDmkError(dmkError);
93+
94+
expect(result).toBeInstanceOf(TransportStatusError);
95+
expect(result.statusCode).toBe(0x6a88);
96+
});
97+
});
98+
99+
describe('unknown DMK error codes', () => {
100+
it('translates unknown error code to TransportStatusError with numeric conversion', () => {
101+
const dmkError = createMockDeviceExchangeError('ffff');
102+
103+
const result = translateDmkError(dmkError);
104+
105+
expect(result).toBeInstanceOf(TransportStatusError);
106+
expect(result.statusCode).toBe(0xffff);
107+
});
108+
});
109+
110+
describe('non-DeviceExchangeError errors', () => {
111+
it('wraps generic Error instances in TransportStatusError with status 0x6f00', () => {
112+
const error = new Error('something went wrong');
113+
114+
const result = translateDmkError(error);
115+
116+
expect(result).toBeInstanceOf(TransportStatusError);
117+
expect(result.statusCode).toBe(0x6f00);
118+
});
119+
120+
it('wraps non-Error values in TransportStatusError with status 0x6f00', () => {
121+
const result = translateDmkError('string error');
122+
123+
expect(result).toBeInstanceOf(TransportStatusError);
124+
expect(result.statusCode).toBe(0x6f00);
125+
});
126+
127+
it('wraps null/undefined in TransportStatusError with status 0x6f00', () => {
128+
const result = translateDmkError(null);
129+
130+
expect(result).toBeInstanceOf(TransportStatusError);
131+
expect(result.statusCode).toBe(0x6f00);
132+
});
133+
});
134+
135+
describe('DeviceExchangeError without error code', () => {
136+
it('wraps DeviceExchangeError with void error code in TransportStatusError', () => {
137+
const dmkError = createMockDeviceExchangeError<void>(undefined);
138+
139+
const result = translateDmkError(dmkError);
140+
141+
expect(result).toBeInstanceOf(TransportStatusError);
142+
expect(result.statusCode).toBe(0x6f00);
143+
});
144+
});
145+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { DeviceExchangeError } from '@ledgerhq/device-management-kit';
2+
import { TransportStatusError } from '@ledgerhq/hw-transport';
3+
4+
const GENERIC_ERROR_STATUS_CODE = 0x6f00;
5+
6+
/**
7+
* Translates a DMK error (DeviceExchangeError with hex error codes) into a
8+
* TransportStatusError that the Ledger keyring error handler can process.
9+
*
10+
* @param error - The error from a DMK device action or command.
11+
* @returns A TransportStatusError with the corresponding APDU status code.
12+
*/
13+
export function translateDmkError(error: unknown): TransportStatusError {
14+
if (isDeviceExchangeError(error)) {
15+
const statusCode = parseHexErrorCode(error.errorCode);
16+
return new TransportStatusError(statusCode);
17+
}
18+
19+
return new TransportStatusError(GENERIC_ERROR_STATUS_CODE);
20+
}
21+
22+
export function isDeviceExchangeError(
23+
error: unknown,
24+
): error is DeviceExchangeError<string> {
25+
return (
26+
typeof error === 'object' &&
27+
error !== null &&
28+
'_tag' in error &&
29+
'errorCode' in error
30+
);
31+
}
32+
33+
function parseHexErrorCode(errorCode: unknown): number {
34+
if (typeof errorCode === 'string' && /^[0-9a-fA-F]{4}$/u.test(errorCode)) {
35+
return parseInt(errorCode, 16);
36+
}
37+
38+
return GENERIC_ERROR_STATUS_CODE;
39+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
CommandResultStatus,
3+
InvalidStatusWordError,
4+
} from '@ledgerhq/device-management-kit';
5+
6+
import {
7+
ETH_APP_COMMAND_ERROR_TAG,
8+
EthGetAppConfigurationCommand,
9+
} from './eth-get-app-configuration-command';
10+
11+
describe('EthGetAppConfigurationCommand', () => {
12+
const command = new EthGetAppConfigurationCommand();
13+
14+
it('uses the Ethereum getAppConfiguration APDU', () => {
15+
expect(command.name).toBe('getAppConfiguration');
16+
expect(command.getApdu().getRawApdu()).toStrictEqual(
17+
Uint8Array.from([0xe0, 0x06, 0x00, 0x00, 0x00]),
18+
);
19+
});
20+
21+
it('parses blind signing and web3 check flags', () => {
22+
const result = command.parseResponse({
23+
statusCode: Uint8Array.from([0x90, 0x00]),
24+
data: Uint8Array.from([0x31, 0x01, 0x02, 0x03]),
25+
});
26+
27+
expect(result).toStrictEqual({
28+
status: CommandResultStatus.Success,
29+
data: {
30+
blindSigningEnabled: true,
31+
web3ChecksEnabled: true,
32+
web3ChecksOptIn: true,
33+
version: '1.2.3',
34+
},
35+
});
36+
});
37+
38+
it('returns a DMK-style exchange error for APDU status errors', () => {
39+
const result = command.parseResponse({
40+
statusCode: Uint8Array.from([0x69, 0x85]),
41+
data: Uint8Array.from([]),
42+
});
43+
44+
expect(result).toStrictEqual({
45+
status: CommandResultStatus.Error,
46+
error: {
47+
_tag: ETH_APP_COMMAND_ERROR_TAG,
48+
errorCode: '6985',
49+
message: 'Ledger Ethereum app command failed with status 0x6985.',
50+
originalError: undefined,
51+
},
52+
});
53+
});
54+
55+
it('returns an invalid status word error when config flags are missing', () => {
56+
const result = command.parseResponse({
57+
statusCode: Uint8Array.from([0x90, 0x00]),
58+
data: Uint8Array.from([]),
59+
});
60+
61+
expect(result.status).toBe(CommandResultStatus.Error);
62+
const error =
63+
result.status === CommandResultStatus.Error ? result.error : undefined;
64+
expect(error).toBeInstanceOf(InvalidStatusWordError);
65+
expect(error).toStrictEqual(
66+
expect.objectContaining({
67+
originalError: new Error(
68+
'Cannot extract config flags from response body',
69+
),
70+
}),
71+
);
72+
});
73+
74+
it('returns an invalid status word error when version bytes are missing', () => {
75+
const result = command.parseResponse({
76+
statusCode: Uint8Array.from([0x90, 0x00]),
77+
data: Uint8Array.from([0x01]),
78+
});
79+
80+
expect(result.status).toBe(CommandResultStatus.Error);
81+
const error =
82+
result.status === CommandResultStatus.Error ? result.error : undefined;
83+
expect(error).toBeInstanceOf(InvalidStatusWordError);
84+
expect(error).toStrictEqual(
85+
expect.objectContaining({
86+
originalError: new Error(
87+
'Cannot extract version bytes from response body',
88+
),
89+
}),
90+
);
91+
});
92+
});

0 commit comments

Comments
 (0)