@metamask/eth-ledger-bridge-keyring
Version:
A MetaMask compatible keyring, for ledger hardware wallets
375 lines • 19.1 kB
JavaScript
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var _LedgerDmkBridge_instances, _LedgerDmkBridge_transportMiddleware, _LedgerDmkBridge_sdk, _LedgerDmkBridge_opts, _LedgerDmkBridge_isConnected, _LedgerDmkBridge_sessionState$, _LedgerDmkBridge_sessionSubscription, _LedgerDmkBridge_waitForDeviceAction, _LedgerDmkBridge_toError, _LedgerDmkBridge_startSessionMonitoring;
import { CloseAppCommand, DeviceActionStatus, DeviceManagementKitBuilder, DeviceStatus, GetAppAndVersionCommand, isSuccessCommandResult, OpenAppCommand } from "@ledgerhq/device-management-kit";
import { concat, defer, firstValueFrom, of, Subject, Subscription, timer } from "rxjs";
import { catchError, distinctUntilChanged, endWith, filter, map, switchMap } from "rxjs/operators";
import { isDeviceExchangeError, translateDmkError } from "./dmk-error-translator.mjs";
import { EthGetAppConfigurationCommand } from "./eth-get-app-configuration-command.mjs";
import { hexToBytes, stripHexPrefix, stripPathPrefix, toHexString } from "./internal-utils.mjs";
import { LedgerDmkTransportMiddleware } from "./ledger-dmk-transport-middleware.mjs";
/**
* LedgerDmkBridge is a bridge between the LedgerKeyring and the
* LedgerDmkTransportMiddleware.
* It initializes and manages the DeviceManagementKit internally.
* The transport factory is injected via constructor, making it platform-agnostic.
*/
export class LedgerDmkBridge {
get onSessionStateChange() {
return __classPrivateFieldGet(this, _LedgerDmkBridge_sessionState$, "f").asObservable();
}
get isDeviceConnected() {
return __classPrivateFieldGet(this, _LedgerDmkBridge_isConnected, "f");
}
constructor(opts) {
_LedgerDmkBridge_instances.add(this);
_LedgerDmkBridge_transportMiddleware.set(this, void 0);
_LedgerDmkBridge_sdk.set(this, void 0);
_LedgerDmkBridge_opts.set(this, void 0);
_LedgerDmkBridge_isConnected.set(this, false);
_LedgerDmkBridge_sessionState$.set(this, new Subject());
_LedgerDmkBridge_sessionSubscription.set(this, null);
__classPrivateFieldSet(this, _LedgerDmkBridge_sdk, new DeviceManagementKitBuilder()
.addTransport(opts.transportFactory)
.build(), "f");
__classPrivateFieldSet(this, _LedgerDmkBridge_opts, opts, "f");
__classPrivateFieldSet(this, _LedgerDmkBridge_transportMiddleware, new LedgerDmkTransportMiddleware(__classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f")), "f");
}
/**
* Compatibility shim for the shared {@link LedgerBridge} interface.
*
* DMK session setup happens externally via `updateSessionId` or `connect`,
* so this method is a no-op.
*
* @returns A promise that resolves immediately.
*/
async init() {
return undefined;
}
/**
* Destroys the bridge and cleans up resources.
*
* Unsubscribes session monitoring, disposes the transport middleware, and
* completes the session-state subject so all subscribers are released. A
* fresh subject is installed afterward so the bridge can be reconnected
* after destroy. State cleanup happens in a `finally` block so the bridge
* is marked disconnected even when middleware `dispose()` rejects.
*
* @returns A promise that resolves when cleanup is complete.
*/
async destroy() {
__classPrivateFieldGet(this, _LedgerDmkBridge_sessionSubscription, "f")?.unsubscribe();
__classPrivateFieldSet(this, _LedgerDmkBridge_sessionSubscription, null, "f");
try {
await __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").dispose();
}
finally {
__classPrivateFieldSet(this, _LedgerDmkBridge_isConnected, false, "f");
__classPrivateFieldGet(this, _LedgerDmkBridge_sessionState$, "f").next({ connected: false });
__classPrivateFieldGet(this, _LedgerDmkBridge_sessionState$, "f").complete();
__classPrivateFieldSet(this, _LedgerDmkBridge_sessionState$, new Subject(), "f");
}
}
/**
* Returns the bridge options captured at construction.
*
* Compatibility shim for the shared {@link LedgerBridge} interface. The DMK
* bridge does not use runtime-reconfigurable options; this method exists
* only to satisfy the interface contract.
*
* @returns A promise that resolves with the current bridge options.
*/
async getOptions() {
return __classPrivateFieldGet(this, _LedgerDmkBridge_opts, "f");
}
/**
* Replaces the stored bridge options.
*
* Compatibility shim for the shared {@link LedgerBridge} interface. Stored
* options are not re-applied to the underlying DMK instance or transport
* middleware; the values used at construction time remain in effect.
*
* @param opts - The options to set for the bridge.
* @returns A promise that resolves when options are set.
*/
async setOptions(opts) {
__classPrivateFieldSet(this, _LedgerDmkBridge_opts, opts, "f");
}
/**
* Updates the session ID used for communication.
*
* @param sessionId - The session ID from DMK.
* @returns A promise that resolves with true if the session was updated successfully.
*/
async updateSessionId(sessionId) {
await __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").setSessionId(sessionId);
__classPrivateFieldSet(this, _LedgerDmkBridge_isConnected, true, "f");
__classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_startSessionMonitoring).call(this, sessionId);
return true;
}
/**
* Starts device discovery for the configured DMK transport.
*
* @param args - Optional DMK discovery options.
* @returns An observable that emits discovered devices.
*/
startDiscovering(...args) {
return __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").startDiscovering(...args);
}
/**
* Connects to a discovered device using the configured DMK transport.
*
* @param args - The DMK connection arguments.
* @returns The created session ID.
*/
async connect(...args) {
const sessionId = await __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").connect(...args);
__classPrivateFieldSet(this, _LedgerDmkBridge_isConnected, true, "f");
__classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_startSessionMonitoring).call(this, sessionId);
return sessionId;
}
/**
* Compatibility shim for the shared {@link LedgerBridge} interface.
*
* DMK transport selection happens through the injected transport factory
* at construction time, so this method is a no-op that always succeeds.
*
* @param _transportType - The requested transport type (ignored).
* @returns A promise that resolves with `true`.
*/
async updateTransportMethod(_transportType) {
return true;
}
async openEthApp() {
const sessionId = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").getSessionId();
const result = await __classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f").sendCommand({
sessionId,
command: new OpenAppCommand({ appName: 'Ethereum' }),
});
if (!isSuccessCommandResult(result)) {
throw __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_toError).call(this, result.error);
}
}
async closeApps() {
const sessionId = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").getSessionId();
const result = await __classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f").sendCommand({
sessionId,
command: new CloseAppCommand(),
});
if (!isSuccessCommandResult(result)) {
throw __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_toError).call(this, result.error);
}
}
/**
* Compatibility shim for the shared {@link LedgerBridge} interface.
*
* The Ethereum signer kit automatically opens the Ethereum app before each
* signing operation via DMK's `CallTaskInAppDeviceAction`, so this method
* is a no-op that always succeeds.
*
* @returns A promise that resolves with `true`.
*/
async attemptMakeApp() {
return true;
}
/**
* Retrieves public key/address for a given HD path.
*
* @param options0 - The parameters object.
* @param options0.hdPath - The HD path to derive the public key from.
* @returns A promise that resolves with the public key response.
*/
async getPublicKey({ hdPath, }) {
const { observable } = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f")
.getEthSigner()
.getAddress(stripPathPrefix(hdPath), {
checkOnDevice: false,
returnChainCode: true,
});
const response = await __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_waitForDeviceAction).call(this, observable);
return {
publicKey: response.publicKey,
address: response.address,
chainCode: response.chainCode,
};
}
/**
* Signs an Ethereum transaction.
*
* @param options0 - The parameters object.
* @param options0.tx - The transaction hex string to sign.
* @param options0.hdPath - The HD path for signing.
* @returns A promise that resolves with the transaction signature.
*/
async deviceSignTransaction({ tx, hdPath, }) {
const { observable } = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f")
.getEthSigner()
.signTransaction(stripPathPrefix(hdPath), hexToBytes(tx));
const signature = await __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_waitForDeviceAction).call(this, observable);
return {
v: toHexString(signature.v),
r: stripHexPrefix(signature.r),
s: stripHexPrefix(signature.s),
};
}
/**
* Signs a personal message.
*
* @param options0 - The parameters object.
* @param options0.hdPath - The HD path for signing.
* @param options0.message - The message to sign.
* @returns A promise that resolves with the message signature.
*/
async deviceSignMessage({ hdPath, message, }) {
const { observable } = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f")
.getEthSigner()
.signMessage(stripPathPrefix(hdPath), hexToBytes(message));
const signature = await __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_waitForDeviceAction).call(this, observable);
return {
v: signature.v,
r: stripHexPrefix(signature.r),
s: stripHexPrefix(signature.s),
};
}
/**
* Signs EIP-712 typed data.
*
* @param options0 - The parameters object.
* @param options0.hdPath - The HD path for signing.
* @param options0.message - The typed data message to sign.
* @returns A promise that resolves with the typed data signature.
*/
async deviceSignTypedData({ hdPath, message, }) {
const { observable } = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f")
.getEthSigner()
.signTypedData(stripPathPrefix(hdPath), message);
const signature = await __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_waitForDeviceAction).call(this, observable);
return {
v: signature.v,
r: stripHexPrefix(signature.r),
s: stripHexPrefix(signature.s),
};
}
async deviceSignDelegationAuthorization({ hdPath, chainId, contractAddress, nonce, }) {
const { observable } = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f")
.getEthSigner()
.signDelegationAuthorization(stripPathPrefix(hdPath), chainId, contractAddress, nonce);
const signature = await __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_waitForDeviceAction).call(this, observable);
return {
v: toHexString(signature.v),
r: stripHexPrefix(signature.r),
s: stripHexPrefix(signature.s),
};
}
/**
* Retrieves the current app name and version.
*
* @returns A promise that resolves with the app name and version.
*/
async getAppNameAndVersion() {
const sessionId = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").getSessionId();
const command = new GetAppAndVersionCommand();
const result = await __classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f").sendCommand({
sessionId,
command,
});
if (!isSuccessCommandResult(result)) {
throw __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_toError).call(this, result.error);
}
return {
appName: result.data.name,
version: result.data.version,
};
}
/**
* Retrieves the Ethereum app configuration using the eth-specific
* GetAppConfiguration APDU command (CLA 0xE0, INS 0x06).
*
* @returns A promise that resolves with the app configuration.
*/
async getAppConfiguration() {
const sessionId = __classPrivateFieldGet(this, _LedgerDmkBridge_transportMiddleware, "f").getSessionId();
const command = new EthGetAppConfigurationCommand();
const result = await __classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f").sendCommand({
sessionId,
command,
});
if (!isSuccessCommandResult(result)) {
throw __classPrivateFieldGet(this, _LedgerDmkBridge_instances, "m", _LedgerDmkBridge_toError).call(this, result.error);
}
const { data } = result;
// The `ethapp.adoc` spec for `getAppConfiguration`
// (https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#get-app-configuration)
// defines four flag bits. This bridge only consumes three (blind signing,
// web3 checks enabled, web3 checks opt-in). The fourth — `0x02` *ERC 20
// Token information needs to be provided externally* — is intentionally
// mapped to `erc20ProvisioningNecessary: 0` because the rest of the
// keyring does not implement the `PROVIDE ERC 20 TOKEN INFORMATION`
// provisioning flow.
//
// TODO: The shared `AppConfigurationResponse` interface also requires
// `starkEnabled` and `starkv2Supported`, which are not exposed by the
// `getAppConfiguration` APDU at all. They are stubbed to 0 to satisfy
// the interface contract.
return {
arbitraryDataEnabled: data.blindSigningEnabled ? 1 : 0,
erc20ProvisioningNecessary: 0,
starkEnabled: 0,
starkv2Supported: 0,
version: data.version,
};
}
}
_LedgerDmkBridge_transportMiddleware = new WeakMap(), _LedgerDmkBridge_sdk = new WeakMap(), _LedgerDmkBridge_opts = new WeakMap(), _LedgerDmkBridge_isConnected = new WeakMap(), _LedgerDmkBridge_sessionState$ = new WeakMap(), _LedgerDmkBridge_sessionSubscription = new WeakMap(), _LedgerDmkBridge_instances = new WeakSet(), _LedgerDmkBridge_waitForDeviceAction = async function _LedgerDmkBridge_waitForDeviceAction(observable) {
const state = await firstValueFrom(observable.pipe(catchError((error) => {
throw translateDmkError(error);
}), filter(({ status }) => status === DeviceActionStatus.Completed ||
status === DeviceActionStatus.Error)));
if (state.status === DeviceActionStatus.Completed) {
return state.output;
}
if (state.status === DeviceActionStatus.Error) {
throw translateDmkError(state.error);
}
// Unreachable: the filter above only lets Completed or Error states
// through. Defensive guard retained for type safety.
throw new Error('Ledger device action ended without completion.');
}, _LedgerDmkBridge_toError = function _LedgerDmkBridge_toError(error) {
if (isDeviceExchangeError(error)) {
return translateDmkError(error);
}
if (error instanceof Error) {
return error;
}
return new Error('Ledger command failed.');
}, _LedgerDmkBridge_startSessionMonitoring = function _LedgerDmkBridge_startSessionMonitoring(sessionId) {
__classPrivateFieldGet(this, _LedgerDmkBridge_sessionSubscription, "f")?.unsubscribe();
const MAX_BACKOFF_MS = 30000;
const BASE_DELAY_MS = 1000;
const MAX_RETRIES = 10;
const createMonitoringStream = (retryCount = 0) => __classPrivateFieldGet(this, _LedgerDmkBridge_sdk, "f").getDeviceSessionState({ sessionId }).pipe(map((state) => ({
connected: state.deviceStatus !== DeviceStatus.NOT_CONNECTED,
})), catchError(() => {
if (retryCount >= MAX_RETRIES) {
return of({ connected: false });
}
const delay = Math.min(BASE_DELAY_MS * 2 ** retryCount, MAX_BACKOFF_MS);
return concat(of({ connected: false }), defer(() => timer(delay).pipe(switchMap(() => createMonitoringStream(retryCount + 1)))));
}), endWith({ connected: false }));
__classPrivateFieldSet(this, _LedgerDmkBridge_sessionSubscription, createMonitoringStream()
.pipe(distinctUntilChanged((a, b) => a.connected === b.connected))
.subscribe((state) => {
__classPrivateFieldSet(this, _LedgerDmkBridge_isConnected, state.connected, "f");
__classPrivateFieldGet(this, _LedgerDmkBridge_sessionState$, "f").next(state);
}), "f");
};
//# sourceMappingURL=ledger-dmk-bridge.mjs.map