From db2a08f584db34e14c975252256d02ac178e9060 Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Sun, 24 May 2026 22:46:44 +0800 Subject: [PATCH 1/9] feat(ledger): install / list / firmware for Ledger device apps Add OS-level Ledger app management alongside the existing chain handlers: - DeviceApps + DeviceAppsManager: install, list installed/available, and read firmware/device info via DMK device actions, built per-call with signer-style onInteraction / onRegisterCanceller wiring. - installApp uses InstallOrUpdateAppsDeviceAction so DMK refreshes metadata, prechecks memory, and fails OOM before writing any bytes; install progress is surfaced as 'app-install-progress' events. - Custom duck-typed DeviceActions (GetOsVersion, catalog lookup) so unlock prompts flow through onInteraction like every other method. - New DeviceOutOfMemory error code for install OOM. - deviceActionToPromise gains a raw intermediateValue hook for progress. Scope: Ledger-only (hwk-ledger-adapter / hwk-adapter-core). Does not touch the OneKey SDK stack. --- packages/hwk-adapter-core/src/types/errors.ts | 2 + .../src/utils/errorMessages.ts | 2 + .../src/__tests__/LedgerAdapter.test.ts | 83 ++++ .../src/adapter/LedgerAdapter.ts | 77 +++- .../src/connector/LedgerConnectorBase.ts | 86 +++- .../src/connector/chains/types.ts | 2 + .../src/device-apps/DeviceApps.ts | 397 ++++++++++++++++++ .../src/device-apps/DeviceAppsManager.ts | 32 ++ packages/hwk-ledger-adapter/src/errors.ts | 13 + .../src/signer/deviceActionToPromise.ts | 34 +- 10 files changed, 714 insertions(+), 14 deletions(-) create mode 100644 packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts create mode 100644 packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts diff --git a/packages/hwk-adapter-core/src/types/errors.ts b/packages/hwk-adapter-core/src/types/errors.ts index 67cd5a0a2..ca751d27c 100644 --- a/packages/hwk-adapter-core/src/types/errors.ts +++ b/packages/hwk-adapter-core/src/types/errors.ts @@ -78,6 +78,8 @@ export enum HardwareErrorCode { WrongApp = 10501, /** 0x911c Command code not supported — app predates current SDK. */ AppTooOld = 10502, + /** Not enough free storage for install/update; user must uninstall apps first. */ + DeviceOutOfMemory = 10503, // --- 11000s EVM (Ledger Ethereum App) APDU-specific --- /** 0x6a80 Invalid data — observed on blindSignTransactionFallback when the diff --git a/packages/hwk-adapter-core/src/utils/errorMessages.ts b/packages/hwk-adapter-core/src/utils/errorMessages.ts index 22d0a73cf..fc3bd0d05 100644 --- a/packages/hwk-adapter-core/src/utils/errorMessages.ts +++ b/packages/hwk-adapter-core/src/utils/errorMessages.ts @@ -22,6 +22,8 @@ export function enrichErrorMessage(code: HardwareErrorCode, originalMessage: str return `${originalMessage}. Please open the correct app on your device.`; case HardwareErrorCode.AppNotInstalled: return `${originalMessage}. The required app is not installed on the device.`; + case HardwareErrorCode.DeviceOutOfMemory: + return `${originalMessage}. Not enough free space on the device. Please uninstall some apps and try again.`; case HardwareErrorCode.TransportNotAvailable: return `${originalMessage}. Ensure the device bridge/transport is available and running.`; case HardwareErrorCode.FirmwareTooOld: diff --git a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts index a42e0d3a0..de4393670 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts @@ -1545,4 +1545,87 @@ describe('LedgerAdapter', () => { } }); }); + + describe('app management', () => { + it('listInstalledApps routes through connector.call with listInstalledApps method', async () => { + connector.call.mockResolvedValueOnce([ + { + versionName: 'Bitcoin', + versionId: 1, + version: '2.4.1', + versionDisplayName: 'Bitcoin', + description: 'BTC app', + icon: null, + bytes: 12345, + currencyId: 'bitcoin', + isDevTools: false, + }, + ]); + await adapter.connectDevice('dev-1'); + const result = await adapter.listInstalledApps('dev-1'); + + expect(connector.call).toHaveBeenCalledWith('session-abc', 'listInstalledApps', {}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.payload[0].versionName).toBe('Bitcoin'); + } + }); + + it('listAvailableApps routes through connector.call with listAvailableApps method', async () => { + connector.call.mockResolvedValueOnce([]); + await adapter.connectDevice('dev-1'); + const result = await adapter.listAvailableApps('dev-1'); + + expect(connector.call).toHaveBeenCalledWith('session-abc', 'listAvailableApps', {}); + expect(result.success).toBe(true); + }); + + it('installApp passes appName + onProgress callback through params', async () => { + connector.call.mockResolvedValueOnce(undefined); + await adapter.connectDevice('dev-1'); + const result = await adapter.installApp('dev-1', 'Cardano'); + + expect(result.success).toBe(true); + const [sessionId, method, params] = connector.call.mock.calls[0]; + expect(sessionId).toBe('session-abc'); + expect(method).toBe('installApp'); + expect(params).toMatchObject({ appName: 'Cardano' }); + expect(typeof (params as { onProgress?: unknown }).onProgress).toBe('function'); + }); + + it('installApp progress callback re-emits adapter app-install-progress events', async () => { + let capturedOnProgress: ((p: unknown) => void) | undefined; + connector.call.mockImplementationOnce(async (_sessionId, _method, params) => { + capturedOnProgress = (params as { onProgress: (p: unknown) => void }).onProgress; + capturedOnProgress?.({ progress: 0.5, requiredUserInteraction: 'none' }); + return undefined; + }); + const events: unknown[] = []; + adapter.on('app-install-progress', evt => events.push(evt)); + + await adapter.connectDevice('dev-1'); + await adapter.installApp('dev-1', 'Cardano'); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'app-install-progress', + payload: { connectId: 'dev-1', appName: 'Cardano', progress: 0.5 }, + }); + }); + + it('installApp surfaces connector errors as failure response', async () => { + connector.call.mockRejectedValueOnce( + Object.assign(new Error('Allow secure connection rejected'), { + code: HardwareErrorCode.UserAborted, + }) + ); + await adapter.connectDevice('dev-1'); + const result = await adapter.installApp('dev-1', 'Cardano'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.payload.code).toBe(HardwareErrorCode.UserAborted); + } + }); + }); }); diff --git a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts index 250cb95cd..312411e2c 100644 --- a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts +++ b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts @@ -27,6 +27,13 @@ import { import { isLedgerBleConnectionType } from '../utils/ledgerDmkTransport'; import { debugError, debugLog } from '../utils/debugLog'; +import type { + AppMetadata, + FirmwareVersion, + InstallAppCallParams, + InstallProgressCallback, + LedgerDeviceInfo, +} from '../device-apps/DeviceApps'; import type { BtcAddress, BtcGetAddressParams, @@ -434,6 +441,66 @@ export class LedgerAdapter implements IHardwareWallet { return this.callChain(connectId, deviceId, 'tron', 'tronSignMessage', params); } + // --------------------------------------------------------------------------- + // App management — OS-level Ledger app install / list. Bypasses fingerprint + // and chain-handler dispatch; installApp progress is forwarded to the adapter + // emitter as 'app-install-progress' events. + // --------------------------------------------------------------------------- + + async installApp(connectId: string, appName: string): Promise> { + try { + const params: InstallAppCallParams & { onProgress?: InstallProgressCallback } = { + appName, + onProgress: progress => { + this.emitter.emit('app-install-progress' as never, { + type: 'app-install-progress', + payload: { connectId, appName, ...progress }, + } as never); + }, + }; + await this.connectorCall(connectId, 'installApp', params); + return success(undefined); + } catch (err) { + return this.errorToFailure(err); + } + } + + async listInstalledApps(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'listInstalledApps', {}); + return success(result as AppMetadata[]); + } catch (err) { + return this.errorToFailure(err); + } + } + + async listAvailableApps(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'listAvailableApps', {}); + return success(result as AppMetadata[]); + } catch (err) { + return this.errorToFailure(err); + } + } + + async getLedgerFirmwareVersion(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'getFirmwareVersion', {}); + return success(result as FirmwareVersion); + } catch (err) { + return this.errorToFailure(err); + } + } + + async getLedgerDeviceInfo(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'getDeviceInfo', {}); + return success(result as LedgerDeviceInfo); + } catch (err) { + return this.errorToFailure(err); + } + } + // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- @@ -1548,11 +1615,17 @@ export class LedgerAdapter implements IHardwareWallet { 'code' in err && typeof (err as { code: unknown }).code === 'number' ) { - const e = err as { code: number; message?: string; appName?: string; reason?: string }; + const e = err as { + code: number; + message?: string; + appName?: string; + reason?: string; + params?: Record; + }; const params = e.code === HardwareErrorCode.DevicePermissionDenied && e.reason ? { permissionDeniedReason: e.reason } - : undefined; + : e.params; return ledgerFailure(e.code, e.message ?? 'Unknown error', e.appName, tag, params); } diff --git a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts index 8941de5e4..db07d5360 100644 --- a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts +++ b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts @@ -29,10 +29,17 @@ import { tronSignMessage, tronSignTransaction, } from './chains'; +import { DeviceAppsManager } from '../device-apps/DeviceAppsManager'; +import { collapseSignerInteraction } from './chains/utils'; +import type { + DeviceApps, + InstallAppCallParams, + ListInstalledAppsCallParams, +} from '../device-apps/DeviceApps'; +import type { ConnectorContext } from './chains/types'; import type { WrapErrorOptions } from '../errors'; import type { CancelReason } from '../signer/deviceActionToPromise'; -import type { ConnectorContext } from './chains/types'; import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; import type { ConnectionType, @@ -133,6 +140,25 @@ async function defaultLedgerKitImporter(pkg: string): Promise { } } +// Mirrors _getEthSigner in chains/evm.ts. +async function _getDeviceApps( + ctx: ConnectorContext, + sessionId: string, +): Promise { + const manager = await ctx.getDeviceAppsManager(); + const apps = await manager.getOrCreate(sessionId); + apps.onInteraction = (interaction: string) => { + ctx.emit('ui-event', { + type: collapseSignerInteraction(interaction), + payload: { sessionId }, + }); + }; + apps.onRegisterCanceller = cancel => { + ctx.registerCanceller(sessionId, cancel); + }; + return apps; +} + // --------------------------------------------------------------------------- // LedgerConnectorBase // --------------------------------------------------------------------------- @@ -164,6 +190,8 @@ export class LedgerConnectorBase implements IConnector { private _signerManager: SignerManager | null = null; + private _deviceAppsManager: DeviceAppsManager | null = null; + private _dmk: DeviceManagementKit | null = null; private readonly _eventHandlers = new Map< @@ -248,6 +276,7 @@ export class LedgerConnectorBase implements IConnector { getOrCreateDmk: () => this._getOrCreateDmk(), getDeviceManager: () => this._getDeviceManager(), getSignerManager: () => this._getSignerManager(), + getDeviceAppsManager: () => this._getDeviceAppsManager(), clearAllSigners: () => this._signerManager?.clearAll(), replaceSession: (oldSid, newSid) => this._replaceSession(oldSid, newSid), registerCanceller: (sid, cancel) => this._cancellers.set(sid, cancel), @@ -694,6 +723,48 @@ export class LedgerConnectorBase implements IConnector { }; return tronSignMessage(ctx, sessionId, internalParams); } + // OS-level device management — symmetric to chain handlers. + case 'installApp': { + const p = params as InstallAppCallParams; + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.install(p.appName, p.onProgress); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'listInstalledApps': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.listInstalled(params as ListInstalledAppsCallParams); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'listAvailableApps': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.listAvailable(); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'getFirmwareVersion': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.getFirmwareVersion(); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'getDeviceInfo': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.getDeviceInfo(); + } finally { + ctx.clearCanceller(sessionId); + } + } default: throw new Error(`LedgerConnector: unknown method "${method}"`); } @@ -796,6 +867,7 @@ export class LedgerConnectorBase implements IConnector { const mod = await importKit('@ledgerhq/device-signer-kit-ethereum'); return new mod.SignerEthBuilder(args); }); + this._deviceAppsManager = new DeviceAppsManager(dmk, importKit); } private async _getDeviceManager(): Promise { @@ -814,6 +886,14 @@ export class LedgerConnectorBase implements IConnector { return this._signerManager!; } + private async _getDeviceAppsManager(): Promise { + if (!this._deviceAppsManager) { + const dmk = await this._getOrCreateDmk(); + this._initManagers(dmk); + } + return this._deviceAppsManager!; + } + private _invalidateSession(sessionId: string): void { this._signerManager?.invalidate(sessionId); } @@ -856,6 +936,8 @@ export class LedgerConnectorBase implements IConnector { debugLog('[DMK] _resetSignersAndSessions called'); this._signerManager?.clearAll(); this._signerManager = null; + this._deviceAppsManager?.clearAll(); + this._deviceAppsManager = null; this._deviceManager?.disposeKeepingDmk(); this._deviceManager = null; } @@ -883,9 +965,11 @@ export class LedgerConnectorBase implements IConnector { } this._sessionStateSubs.clear(); this._signerManager?.clearAll(); + this._deviceAppsManager?.clearAll(); this._deviceManager?.dispose(); this._deviceManager = null; this._signerManager = null; + this._deviceAppsManager = null; this._dmk = null; } diff --git a/packages/hwk-ledger-adapter/src/connector/chains/types.ts b/packages/hwk-ledger-adapter/src/connector/chains/types.ts index d5dbebf21..578c08d66 100644 --- a/packages/hwk-ledger-adapter/src/connector/chains/types.ts +++ b/packages/hwk-ledger-adapter/src/connector/chains/types.ts @@ -1,5 +1,6 @@ import type { ConnectorEventMap, ConnectorEventType } from '@onekeyfe/hwk-adapter-core'; import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; +import type { DeviceAppsManager } from '../../device-apps/DeviceAppsManager'; import type { SignerManager } from '../../signer/SignerManager'; import type { LedgerDeviceManager } from '../../device/LedgerDeviceManager'; import type { WrapErrorOptions } from '../../errors'; @@ -16,6 +17,7 @@ export interface ConnectorContext { getOrCreateDmk(): Promise; getDeviceManager(): Promise; getSignerManager(): Promise; + getDeviceAppsManager(): Promise; clearAllSigners(): void; /** * Notify the connector that a session has been replaced (e.g. after app switch). diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts new file mode 100644 index 000000000..73d29437d --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts @@ -0,0 +1,397 @@ +import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; +import { Subject } from 'rxjs'; + +import { deviceActionToPromise } from '../signer/deviceActionToPromise'; +import { debugLog } from '../utils/debugLog'; + +import type { + DeviceManagementKit, + GetOsVersionResponse, +} from '@ledgerhq/device-management-kit'; +import type { Observable } from 'rxjs'; +import type { CancelReason } from '../signer/deviceActionToPromise'; + +const INSTALL_TIMEOUT_MS = 5 * 60_000; + +export interface AppMetadata { + versionName: string; + versionId: number; + version: string; + versionDisplayName: string | null; + description: string | null; + icon: string | null; + bytes: number | null; + currencyId: string | null; + isDevTools: boolean; +} + +export interface InstallProgress { + progress: number; + requiredUserInteraction?: string; +} + +export type InstallProgressCallback = (progress: InstallProgress) => void; + +export interface InstallAppCallParams { + appName: string; + unlockTimeout?: number; + // In-process function ref forwarded through connector.call (params typed unknown). + onProgress?: InstallProgressCallback; +} + +export interface ListInstalledAppsCallParams { + unlockTimeout?: number; +} + +export interface FirmwareVersion { + /** BOLOS version on the secure element — the user-facing firmware version. */ + seVersion: string; + /** MCU SEPH (SE–MCU link protocol) version. */ + mcuSephVersion: string; + /** MCU bootloader version. */ + mcuBootloaderVersion: string; + /** Hardware revision (e.g. "00" / "01" on Nano X). */ + hwVersion: string; +} + +/** Full GetOsVersionResponse projected to a plain serializable shape. */ +export interface LedgerDeviceInfo extends FirmwareVersion { + isBootloader: boolean; + isOsu: boolean; + targetId: number; + seTargetId?: number; + mcuTargetId?: number; + /** Secure element flags as hex string (raw bytes turned readable). */ + seFlagsHex: string; +} + +/** + * OS-level device management (install, list apps). Mirrors SignerEth: + * built fresh per call, consumer sets onInteraction / onRegisterCanceller. + */ +export class DeviceApps { + onInteraction?: (interaction: string) => void; + + onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void; + + constructor( + private readonly _dmk: DeviceManagementKit, + private readonly _sessionId: string, + private readonly _ledgerKit: LedgerKitModule, + ) {} + + async listInstalled(options?: { unlockTimeout?: number }): Promise { + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new this._ledgerKit.ListAppsWithMetadataDeviceAction({ + input: { unlockTimeout: options?.unlockTimeout }, + }), + }); + const result = await deviceActionToPromise>( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller, + ); + return result + .filter((a): a is DmkApplication => a !== null) + .map(applicationToMetadata); + } + + // Catalog lookup via custom device action — DMK has no typed wrapper for this. + async listAvailable(): Promise { + const customAction = new ListAvailableAppsDeviceAction({ + GetOsVersionCommand: this._ledgerKit.GetOsVersionCommand, + isSuccessCommandResult: this._ledgerKit.isSuccessCommandResult, + }); + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: customAction, + }); + const result = await deviceActionToPromise( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller, + ); + return result.map(applicationToMetadata); + } + + async getFirmwareVersion(): Promise { + const v = await this._fetchOsVersion(); + return { + seVersion: v.seVersion, + mcuSephVersion: v.mcuSephVersion, + mcuBootloaderVersion: v.mcuBootloaderVersion, + hwVersion: v.hwVersion, + }; + } + + async getDeviceInfo(): Promise { + const v = await this._fetchOsVersion(); + return { + isBootloader: v.isBootloader, + isOsu: v.isOsu, + targetId: v.targetId, + seTargetId: v.seTargetId, + mcuTargetId: v.mcuTargetId, + seVersion: v.seVersion, + seFlagsHex: bytesToHex(v.seFlags), + mcuSephVersion: v.mcuSephVersion, + mcuBootloaderVersion: v.mcuBootloaderVersion, + hwVersion: v.hwVersion, + }; + } + + // Sole sendCommand path — routed via executeDeviceAction so unlock-device + // interaction flows through onInteraction like the other methods. + private async _fetchOsVersion(): Promise { + const customAction = new GetOsVersionDeviceAction({ + GetOsVersionCommand: this._ledgerKit.GetOsVersionCommand, + isSuccessCommandResult: this._ledgerKit.isSuccessCommandResult, + }); + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: customAction, + }); + return deviceActionToPromise( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller, + ); + } + + async install( + appName: string, + onProgress?: InstallProgressCallback, + options?: { unlockTimeout?: number }, + ): Promise { + if (!appName) throw new Error('DeviceApps.install: appName is required'); + debugLog('[DeviceApps] install:', appName); + + // Use InstallOrUpdateAppsDeviceAction (not InstallAppDeviceAction): it runs + // UPDATE_DEVICE_METADATA → BUILD_INSTALL_PLAN → CHECK_IF_ENOUGH_MEMORY → + // INSTALL_APPLICATION, so OOM fails fast (zero bytes written) and the refreshed + // metadata feeds PredictOutOfMemoryTask accurate sizes — no client-side estimate. + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new this._ledgerKit.InstallOrUpdateAppsDeviceAction({ + input: { + applications: [{ name: appName }], + allowMissingApplication: false, + unlockTimeout: options?.unlockTimeout, + }, + }), + }); + + await deviceActionToPromise( + action, + this.onInteraction, + INSTALL_TIMEOUT_MS, + this.onRegisterCanceller, + onProgress + ? intermediateValue => { + const iv = intermediateValue as + | { + requiredUserInteraction?: string; + installPlan?: { currentProgress?: number } | null; + } + | undefined; + const progress = iv?.installPlan?.currentProgress; + if (typeof progress === 'number') { + onProgress({ + progress, + requiredUserInteraction: iv?.requiredUserInteraction, + }); + } + } + : undefined, + ); + } +} + +interface DmkApplication { + versionName: string; + versionId: number; + version: string; + versionDisplayName: string | null; + description: string | null; + icon: string | null; + bytes: number | null; + currencyId: string | null; + isDevTools: boolean; +} + +function bytesToHex(bytes: Uint8Array | undefined): string { + if (!bytes) return ''; + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +function applicationToMetadata(app: DmkApplication): AppMetadata { + return { + versionName: app.versionName, + versionId: app.versionId, + version: app.version, + versionDisplayName: app.versionDisplayName, + description: app.description, + icon: app.icon, + bytes: app.bytes, + currencyId: app.currencyId, + isDevTools: app.isDevTools, + }; +} + + +// Loosened DMK surface (we receive the module via dynamic importLedgerKit). +export interface LedgerKitModule { + ListAppsWithMetadataDeviceAction: new (args: { input: unknown }) => unknown; + InstallOrUpdateAppsDeviceAction: new (args: { input: unknown }) => unknown; + GetOsVersionCommand: new () => unknown; + isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; +} + +interface InstallOrUpdateAppsOutput { + successfullyInstalled: unknown[]; + alreadyInstalled: string[]; + missingApplications: string[]; +} + +interface DmkExecuteCapable { + // Loose return: deviceActionToPromise narrows the observable shape per-call. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + executeDeviceAction(args: { sessionId: string; deviceAction: unknown }): any; +} + +// Duck-typed DeviceAction; executeDeviceAction injects InternalApi for managerApi access. + +interface InternalApiLike { + sendCommand: (command: unknown) => Promise; + getManagerApiService: () => { + getAppList: (deviceInfo: GetOsVersionResponse) => { + run: () => Promise<{ isLeft: () => boolean; extract: () => unknown }>; + }; + }; +} + +type AnyState = + | { readonly status: DeviceActionStatus.NotStarted } + | { readonly status: DeviceActionStatus.Pending; readonly intermediateValue: unknown } + | { readonly status: DeviceActionStatus.Stopped } + | { readonly status: DeviceActionStatus.Completed; readonly output: T } + | { readonly status: DeviceActionStatus.Error; readonly error: unknown }; + +type OsVersionDeps = { + GetOsVersionCommand: new () => unknown; + isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; +}; + +// Wrapped as a DeviceAction (not raw sendCommand) so unlock-device interaction +// flows through onInteraction like every other method. +class GetOsVersionDeviceAction { + readonly input = undefined; + + constructor(private readonly _deps: OsVersionDeps) {} + + _execute(internalApi: { sendCommand: (cmd: unknown) => Promise }): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + const result = await internalApi.sendCommand(new this._deps.GetOsVersionCommand()); + if (cancelled) return; + if (!this._deps.isSuccessCommandResult(result)) { + const errObj = (result as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + subject.next({ status: DeviceActionStatus.Completed, output: result.data }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} + +class ListAvailableAppsDeviceAction { + readonly input = undefined; + + private readonly _GetOsVersionCommand: new () => unknown; + + private readonly _isSuccessCommandResult: ( + result: unknown, + ) => result is { data: GetOsVersionResponse }; + + constructor(deps: OsVersionDeps) { + this._GetOsVersionCommand = deps.GetOsVersionCommand; + this._isSuccessCommandResult = deps.isSuccessCommandResult; + } + + _execute(internalApi: InternalApiLike): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + + const osVersionResult = await internalApi.sendCommand(new this._GetOsVersionCommand()); + if (cancelled) return; + if (!this._isSuccessCommandResult(osVersionResult)) { + const errObj = (osVersionResult as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + + const managerApi = internalApi.getManagerApiService(); + const either = await managerApi.getAppList(osVersionResult.data).run(); + if (cancelled) return; + if (either.isLeft()) { + const httpErr = either.extract() as { message?: string }; + throw new Error(httpErr?.message ?? 'Manager API getAppList failed'); + } + + const apps = either.extract() as DmkApplication[]; + subject.next({ status: DeviceActionStatus.Completed, output: apps }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts new file mode 100644 index 000000000..7ffbc55b8 --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts @@ -0,0 +1,32 @@ +import { DeviceApps } from './DeviceApps'; + +import type { LedgerKitModule } from './DeviceApps'; +import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; + +/** Per-call DeviceApps factory (mirrors SignerManager); DMK actions hold per-action state. */ +export class DeviceAppsManager { + private readonly _dmk: DeviceManagementKit; + + private readonly _importLedgerKit: (pkg: string) => Promise; + + constructor( + dmk: DeviceManagementKit, + importLedgerKit: (pkg: string) => Promise, + ) { + this._dmk = dmk; + this._importLedgerKit = importLedgerKit; + } + + async getOrCreate(sessionId: string): Promise { + const ledgerKit = (await this._importLedgerKit( + '@ledgerhq/device-management-kit', + )) as LedgerKitModule; + return new DeviceApps(this._dmk, sessionId, ledgerKit); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this + invalidate(_sessionId: string): void {} + + // eslint-disable-next-line class-methods-use-this + clearAll(): void {} +} diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index fccb4f03d..85662c3e3 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -407,6 +407,17 @@ export function isAppNotInstalledError(err: unknown): boolean { return false; } +/** DMK install ran out of space on the device. */ +export function isOutOfMemoryError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as Record; + if (e._tag === 'OutOfMemoryDAError') return true; + if (typeof e.message === 'string' && /out of memory|not enough.*space|insufficient.*memory/i.test(e.message)) { + return true; + } + return false; +} + /** Check for device disconnected errors. */ export function isDeviceDisconnectedError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; @@ -512,6 +523,8 @@ export function mapLedgerError( code = HardwareErrorCode.WrongApp; } else if (isAppNotInstalledError(err)) { code = HardwareErrorCode.AppNotInstalled; + } else if (isOutOfMemoryError(err)) { + code = HardwareErrorCode.DeviceOutOfMemory; } else if (isDeviceDisconnectedError(err)) { code = HardwareErrorCode.DeviceDisconnected; } else if (isTimeoutError(err)) { diff --git a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts index d4cc89cd0..b518e3465 100644 --- a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts +++ b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts @@ -38,7 +38,10 @@ export function deviceActionToPromise( action: DeviceAction, onInteraction?: (interaction: string) => void, timeoutMs: number = IDLE_WATCHDOG_MS, - onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void + onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void, + // Raw intermediateValue per Pending tick — for callers that need DMK-specific + // fields beyond requiredUserInteraction (e.g. install progress). + onIntermediate?: (intermediateValue: unknown) => void ): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -136,17 +139,26 @@ export function deviceActionToPromise( onInteraction?.('interaction-complete'); sub?.unsubscribe(); rejectWithStepContext(state.error, lastStep, observedSteps, reject); - } else if (state.status === DeviceActionStatus.Pending && onInteraction) { - const interaction = state.intermediateValue?.requiredUserInteraction; - if (interaction && interaction !== 'none') { - // unlock-device is DMK's own bounded poll. Other interaction - // states keep the caller-provided watchdog so a stuck observable - // cannot hold the queue slot forever. - if (interaction === 'unlock-device' && timer) { - clearTimeout(timer); - timer = null; + } else if (state.status === DeviceActionStatus.Pending) { + if (onIntermediate) { + try { + onIntermediate(state.intermediateValue); + } catch { + // listener errors must not abort the device action + } + } + if (onInteraction) { + const interaction = state.intermediateValue?.requiredUserInteraction; + if (interaction && interaction !== 'none') { + // unlock-device is DMK's own bounded poll. Other interaction + // states keep the caller-provided watchdog so a stuck observable + // cannot hold the queue slot forever. + if (interaction === 'unlock-device' && timer) { + clearTimeout(timer); + timer = null; + } + onInteraction(String(interaction)); } - onInteraction(String(interaction)); } } }, From 60af8866a4a7bac9a5497d38f7125694013658d0 Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Tue, 26 May 2026 23:09:57 +0800 Subject: [PATCH 2/9] fix: clear ledger interaction on none state --- .../__tests__/deviceActionToPromise.test.ts | 106 ++++++++++++++++++ .../src/signer/deviceActionToPromise.ts | 63 +++++++++-- 2 files changed, 157 insertions(+), 12 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts index 01cceb19f..285cd1a11 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts @@ -138,6 +138,112 @@ describe('deviceActionToPromise', () => { expect(onInteraction).toHaveBeenCalledWith('interaction-complete'); }); + it('should clear active interaction when DMK reports none after a prompt', async () => { + const onInteraction = jest.fn(); + let observer: + | { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + } + | undefined; + const action = { + cancel: jest.fn(), + observable: { + subscribe(nextObserver: { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + }) { + observer = nextObserver; + return { unsubscribe: jest.fn() }; + }, + }, + } as unknown as DeviceAction; + const promise = deviceActionToPromise(action, onInteraction, 0); + + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + + observer?.next({ status: 'completed', output: 'done' }); + + await expect(promise).resolves.toBe('done'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + }); + + it('should not clear interaction repeatedly for consecutive none states', async () => { + const onInteraction = jest.fn(); + const action = createMockAction([ + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { status: 'completed', output: 'done' }, + ]); + + await deviceActionToPromise(action, onInteraction); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(3); + }); + + it('should not emit duplicate events for repeated active interaction states', async () => { + const onInteraction = jest.fn(); + const action = createMockAction([ + { + status: 'pending', + intermediateValue: { requiredUserInteraction: 'confirm-on-device' }, + }, + { + status: 'pending', + intermediateValue: { requiredUserInteraction: 'confirm-on-device' }, + }, + { status: 'pending', intermediateValue: { requiredUserInteraction: 'none' } }, + { status: 'completed', output: 'done' }, + ]); + + await deviceActionToPromise(action, onInteraction); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'confirm-on-device'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(3); + }); + it('should reject if observable completes without result', async () => { const action = createMockAction([{ status: 'pending' }]); await expect(deviceActionToPromise(action)).rejects.toThrow('completed without result'); diff --git a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts index b518e3465..bb025e840 100644 --- a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts +++ b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts @@ -1,5 +1,7 @@ import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; +import { debugLog } from '../utils/debugLog'; + import type { DeviceAction, DeviceActionState } from '../types'; /** @@ -46,6 +48,7 @@ export function deviceActionToPromise( return new Promise((resolve, reject) => { let settled = false; let lastStep: string | undefined; + let activeInteraction: string | undefined; const observedSteps: string[] = []; // eslint-disable-next-line prefer-const -- assigned once after declaration, but must be declared before use in cleanup let sub: { unsubscribe: () => void }; @@ -65,6 +68,47 @@ export function deviceActionToPromise( } }; + const clearActiveInteraction = () => { + if (activeInteraction) { + activeInteraction = undefined; + onInteraction?.('interaction-complete'); + return true; + } + return false; + }; + + const emitInteraction = (interaction: unknown) => { + if (!onInteraction) { + return; + } + + if (!interaction || interaction === 'none') { + clearActiveInteraction(); + return; + } + + const interactionName = String(interaction); + if (interactionName === activeInteraction) { + return; + } + + activeInteraction = interactionName; + debugLog( + '[DeviceAction] requiredUserInteraction', + `interaction=${interactionName}`, + `step=${lastStep ?? 'unknown'}`, + `observedSteps=${observedSteps.join(',') || 'none'}` + ); + // unlock-device is DMK's own bounded poll. Other interaction + // states keep the caller-provided watchdog so a stuck observable + // cannot hold the queue slot forever. + if (interaction === 'unlock-device' && timer) { + clearTimeout(timer); + timer = null; + } + onInteraction(interactionName); + }; + const armIdleWatchdog = () => { if (timer) clearTimeout(timer); if (timeoutMs > 0) { @@ -130,13 +174,17 @@ export function deviceActionToPromise( if (state.status === DeviceActionStatus.Completed) { settled = true; if (timer) clearTimeout(timer); - onInteraction?.('interaction-complete'); + if (!clearActiveInteraction()) { + onInteraction?.('interaction-complete'); + } sub?.unsubscribe(); resolve(state.output); } else if (state.status === DeviceActionStatus.Error) { settled = true; if (timer) clearTimeout(timer); - onInteraction?.('interaction-complete'); + if (!clearActiveInteraction()) { + onInteraction?.('interaction-complete'); + } sub?.unsubscribe(); rejectWithStepContext(state.error, lastStep, observedSteps, reject); } else if (state.status === DeviceActionStatus.Pending) { @@ -149,16 +197,7 @@ export function deviceActionToPromise( } if (onInteraction) { const interaction = state.intermediateValue?.requiredUserInteraction; - if (interaction && interaction !== 'none') { - // unlock-device is DMK's own bounded poll. Other interaction - // states keep the caller-provided watchdog so a stuck observable - // cannot hold the queue slot forever. - if (interaction === 'unlock-device' && timer) { - clearTimeout(timer); - timer = null; - } - onInteraction(String(interaction)); - } + emitInteraction(interaction); } } }, From 22b1e76ac248f2283380a78e747bb334b5f33e4d Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 10:07:13 +0800 Subject: [PATCH 3/9] chore: trim ledger interaction debug logs --- .../src/signer/deviceActionToPromise.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts index bb025e840..65ef073d4 100644 --- a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts +++ b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts @@ -41,8 +41,6 @@ export function deviceActionToPromise( onInteraction?: (interaction: string) => void, timeoutMs: number = IDLE_WATCHDOG_MS, onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void, - // Raw intermediateValue per Pending tick — for callers that need DMK-specific - // fields beyond requiredUserInteraction (e.g. install progress). onIntermediate?: (intermediateValue: unknown) => void ): Promise { return new Promise((resolve, reject) => { @@ -70,6 +68,12 @@ export function deviceActionToPromise( const clearActiveInteraction = () => { if (activeInteraction) { + debugLog( + '[DeviceAction] requiredUserInteraction', + 'interaction=none', + `prev=${activeInteraction}`, + `step=${lastStep ?? 'unknown'}` + ); activeInteraction = undefined; onInteraction?.('interaction-complete'); return true; @@ -96,8 +100,7 @@ export function deviceActionToPromise( debugLog( '[DeviceAction] requiredUserInteraction', `interaction=${interactionName}`, - `step=${lastStep ?? 'unknown'}`, - `observedSteps=${observedSteps.join(',') || 'none'}` + `step=${lastStep ?? 'unknown'}` ); // unlock-device is DMK's own bounded poll. Other interaction // states keep the caller-provided watchdog so a stuck observable From 794a28aba2bb2e8f1b49bbdc95be2cbd6456c0df Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 14:28:19 +0800 Subject: [PATCH 4/9] fix(ledger): address app-install review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - progress now flows through ConnectorEventMap 'app-install-progress' instead of passing an onProgress function ref via connector.call params (would have been stripped by IHardwareBridge structured-clone / JSON serializers). Adapter forwards the event with sessionId → connectId mapping; both ConnectorEventMap and HardwareEventMap have typed entries (no more `as never`). - device-app dispatch cases (installApp / listInstalledApps / listAvailableApps / getFirmwareVersion / getDeviceInfo) wrap errors with ctx.wrapError and invalidate the session on failure, mirroring chain handlers — DMK errors now flow through mapLedgerError / isOutOfMemoryError instead of bypassing classification. - InstallProgress payload drops the raw DMK requiredUserInteraction string; raw signals go to debugLog for post-hoc diagnosis. The public 'ui-event' channel continues to surface the collapsed EConnectorInteraction so install UI keeps working. - DeviceApps.install throws AppNotFoundInCatalogError when DMK resolves Completed with missingApplications populated, so a no-op install doesn't silently look like success. - deviceActionToPromise consolidates four reject paths (watchdog timeout / external canceller / rxjs error / rxjs complete) through a shared completeInteraction() helper so an outstanding interaction prompt always gets a closing 'interaction-complete'. - Drop the dead InstallAppCallParams.unlockTimeout field (LedgerConnectorBase never forwarded it to apps.install and the public adapter API never exposed it). --- .../hwk-adapter-core/src/types/connector.ts | 11 +++- packages/hwk-adapter-core/src/types/wallet.ts | 9 +++ .../src/__tests__/LedgerAdapter.test.ts | 41 ++++++++---- .../__tests__/deviceActionToPromise.test.ts | 40 +++++++++++ .../src/adapter/LedgerAdapter.ts | 42 ++++++++---- .../src/connector/LedgerConnectorBase.ts | 30 ++++++++- .../src/device-apps/DeviceApps.ts | 66 +++++++++++++------ .../src/signer/deviceActionToPromise.ts | 18 +++-- 8 files changed, 205 insertions(+), 52 deletions(-) diff --git a/packages/hwk-adapter-core/src/types/connector.ts b/packages/hwk-adapter-core/src/types/connector.ts index c4f838c8e..21f758324 100644 --- a/packages/hwk-adapter-core/src/types/connector.ts +++ b/packages/hwk-adapter-core/src/types/connector.ts @@ -33,7 +33,12 @@ export interface ConnectorSession { deviceInfo: DeviceInfo; } -export type ConnectorEventType = 'device-connect' | 'device-disconnect' | 'ui-request' | 'ui-event'; +export type ConnectorEventType = + | 'device-connect' + | 'device-disconnect' + | 'ui-request' + | 'ui-event' + | 'app-install-progress'; /** * Interaction event types emitted via 'ui-event'. @@ -68,6 +73,10 @@ export interface ConnectorEventMap { 'device-disconnect': { connectId: string }; 'ui-request': { type: string; payload?: unknown }; 'ui-event': ConnectorUiEvent; + // OS-level Ledger app install progress. Emitted from inside the connector + // so the callback ref never has to cross the IHardwareBridge boundary. + // `progress` is a 0..1 fraction reported by DMK's InstallOrUpdateAppsDeviceAction. + 'app-install-progress': { sessionId: string; appName: string; progress: number }; } export interface IConnector { diff --git a/packages/hwk-adapter-core/src/types/wallet.ts b/packages/hwk-adapter-core/src/types/wallet.ts index 5d0f166dc..110514a4f 100644 --- a/packages/hwk-adapter-core/src/types/wallet.ts +++ b/packages/hwk-adapter-core/src/types/wallet.ts @@ -83,6 +83,15 @@ export interface HardwareEventMap { // UnlockDevice / InteractionComplete. Subscribe with hw.on('ui-event', handler). 'ui-event': ConnectorUiEvent; + // OS-level Ledger app install progress (forwarded from IConnector + // 'app-install-progress'). The adapter re-emits with `connectId` instead + // of the connector-internal `sessionId`. Subscribe with + // hw.on('app-install-progress', handler). + 'app-install-progress': { + type: 'app-install-progress'; + payload: { connectId: string; appName: string; progress: number }; + }; + // Device events [DEVICE.CONNECT]: { type: typeof DEVICE.CONNECT; payload: DeviceInfo }; [DEVICE.DISCONNECT]: { type: typeof DEVICE.DISCONNECT; payload: { connectId: string } }; diff --git a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts index de4393670..1f87131e2 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts @@ -1580,7 +1580,7 @@ describe('LedgerAdapter', () => { expect(result.success).toBe(true); }); - it('installApp passes appName + onProgress callback through params', async () => { + it('installApp passes appName through params (no function refs)', async () => { connector.call.mockResolvedValueOnce(undefined); await adapter.connectDevice('dev-1'); const result = await adapter.installApp('dev-1', 'Cardano'); @@ -1589,30 +1589,49 @@ describe('LedgerAdapter', () => { const [sessionId, method, params] = connector.call.mock.calls[0]; expect(sessionId).toBe('session-abc'); expect(method).toBe('installApp'); - expect(params).toMatchObject({ appName: 'Cardano' }); - expect(typeof (params as { onProgress?: unknown }).onProgress).toBe('function'); + expect(params).toEqual({ appName: 'Cardano' }); + // Params must be serializable — no function refs may cross the connector + // boundary (would be dropped by IHardwareBridge structured-clone / JSON). + for (const value of Object.values(params as Record)) { + expect(typeof value).not.toBe('function'); + } }); - it('installApp progress callback re-emits adapter app-install-progress events', async () => { - let capturedOnProgress: ((p: unknown) => void) | undefined; - connector.call.mockImplementationOnce(async (_sessionId, _method, params) => { - capturedOnProgress = (params as { onProgress: (p: unknown) => void }).onProgress; - capturedOnProgress?.({ progress: 0.5, requiredUserInteraction: 'none' }); - return undefined; - }); + it('forwards connector app-install-progress events with connectId re-keyed from sessionId', async () => { + connector.call.mockResolvedValueOnce(undefined); const events: unknown[] = []; adapter.on('app-install-progress', evt => events.push(evt)); await adapter.connectDevice('dev-1'); + // Simulate the connector emitting progress mid-install. + connector._emit('app-install-progress', { + sessionId: 'session-abc', + appName: 'Cardano', + progress: 0.5, + }); await adapter.installApp('dev-1', 'Cardano'); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + expect(events[0]).toEqual({ type: 'app-install-progress', payload: { connectId: 'dev-1', appName: 'Cardano', progress: 0.5 }, }); }); + it('drops app-install-progress events with no matching session', async () => { + const events: unknown[] = []; + adapter.on('app-install-progress', evt => events.push(evt)); + + // No connectDevice() called → _sessions is empty → forwarder drops. + connector._emit('app-install-progress', { + sessionId: 'stale-session', + appName: 'Cardano', + progress: 0.1, + }); + + expect(events).toHaveLength(0); + }); + it('installApp surfaces connector errors as failure response', async () => { connector.call.mockRejectedValueOnce( Object.assign(new Error('Allow secure connection rejected'), { diff --git a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts index 285cd1a11..1df7c8430 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts @@ -244,6 +244,46 @@ describe('deviceActionToPromise', () => { expect(onInteraction).toHaveBeenCalledTimes(3); }); + it('should clear active interaction when observable errors directly', async () => { + const onInteraction = jest.fn(); + let observer: + | { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + } + | undefined; + const action = { + cancel: jest.fn(), + observable: { + subscribe(nextObserver: { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + }) { + observer = nextObserver; + return { unsubscribe: jest.fn() }; + }, + }, + } as unknown as DeviceAction; + const error = new Error('transport failed'); + const promise = deviceActionToPromise(action, onInteraction, 0); + + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + observer?.error?.(error); + + await expect(promise).rejects.toThrow('transport failed'); + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(2); + }); + it('should reject if observable completes without result', async () => { const action = createMockAction([{ status: 'pending' }]); await expect(deviceActionToPromise(action)).rejects.toThrow('completed without result'); diff --git a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts index 312411e2c..724697fef 100644 --- a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts +++ b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts @@ -30,8 +30,6 @@ import { debugError, debugLog } from '../utils/debugLog'; import type { AppMetadata, FirmwareVersion, - InstallAppCallParams, - InstallProgressCallback, LedgerDeviceInfo, } from '../device-apps/DeviceApps'; import type { @@ -449,16 +447,10 @@ export class LedgerAdapter implements IHardwareWallet { async installApp(connectId: string, appName: string): Promise> { try { - const params: InstallAppCallParams & { onProgress?: InstallProgressCallback } = { - appName, - onProgress: progress => { - this.emitter.emit('app-install-progress' as never, { - type: 'app-install-progress', - payload: { connectId, appName, ...progress }, - } as never); - }, - }; - await this.connectorCall(connectId, 'installApp', params); + // Progress is emitted from the connector via the 'app-install-progress' + // event (see appInstallProgressForwarder); no callback is passed here so + // installApp params stay fully serializable across IHardwareBridge. + await this.connectorCall(connectId, 'installApp', { appName }); return success(undefined); } catch (err) { return this.errorToFailure(err); @@ -1669,16 +1661,42 @@ export class LedgerAdapter implements IHardwareWallet { this.emitter.emit('ui-event', event); }; + // Forward 'app-install-progress' from the connector (carries sessionId) to the + // public hw.emitter as a connectId-keyed event. We translate sessionId → connectId + // via the live _sessions map; if no mapping exists (race during teardown) we drop. + private appInstallProgressForwarder = (data: { + sessionId: string; + appName: string; + progress: number; + }): void => { + let connectId: string | undefined; + for (const [cid, sid] of this._sessions) { + if (sid === data.sessionId) { + connectId = cid; + break; + } + } + if (!connectId) { + return; + } + this.emitter.emit('app-install-progress', { + type: 'app-install-progress', + payload: { connectId, appName: data.appName, progress: data.progress }, + }); + }; + private registerEventListeners(): void { this.connector.on('device-connect', this.deviceConnectHandler); this.connector.on('device-disconnect', this.deviceDisconnectHandler); this.connector.on('ui-event', this.uiEventForwarder); + this.connector.on('app-install-progress', this.appInstallProgressForwarder); } private unregisterEventListeners(): void { this.connector.off('device-connect', this.deviceConnectHandler); this.connector.off('device-disconnect', this.deviceDisconnectHandler); this.connector.off('ui-event', this.uiEventForwarder); + this.connector.off('app-install-progress', this.appInstallProgressForwarder); } // --------------------------------------------------------------------------- diff --git a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts index db07d5360..e310c52b4 100644 --- a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts +++ b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts @@ -724,11 +724,27 @@ export class LedgerConnectorBase implements IConnector { return tronSignMessage(ctx, sessionId, internalParams); } // OS-level device management — symmetric to chain handlers. + // Each case uses try/catch/finally to mirror chain handlers: catch wraps + // raw DMK errors (so mapLedgerError / isOutOfMemoryError run) and + // invalidates the session (so a stale sid isn't reused by subsequent + // listInstalledApps / signTransaction calls). case 'installApp': { const p = params as InstallAppCallParams; const apps = await _getDeviceApps(ctx, sessionId); try { - return await apps.install(p.appName, p.onProgress); + // Progress callback is built here (not on params) so the function ref + // never crosses the IHardwareBridge boundary. Emits as a connector + // event; the adapter re-emits to its public typed emitter. + return await apps.install(p.appName, ({ progress }) => { + ctx.emit('app-install-progress', { + sessionId, + appName: p.appName, + progress, + }); + }); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); } finally { ctx.clearCanceller(sessionId); } @@ -737,6 +753,9 @@ export class LedgerConnectorBase implements IConnector { const apps = await _getDeviceApps(ctx, sessionId); try { return await apps.listInstalled(params as ListInstalledAppsCallParams); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); } finally { ctx.clearCanceller(sessionId); } @@ -745,6 +764,9 @@ export class LedgerConnectorBase implements IConnector { const apps = await _getDeviceApps(ctx, sessionId); try { return await apps.listAvailable(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); } finally { ctx.clearCanceller(sessionId); } @@ -753,6 +775,9 @@ export class LedgerConnectorBase implements IConnector { const apps = await _getDeviceApps(ctx, sessionId); try { return await apps.getFirmwareVersion(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); } finally { ctx.clearCanceller(sessionId); } @@ -761,6 +786,9 @@ export class LedgerConnectorBase implements IConnector { const apps = await _getDeviceApps(ctx, sessionId); try { return await apps.getDeviceInfo(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); } finally { ctx.clearCanceller(sessionId); } diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts index 73d29437d..bb9c9f774 100644 --- a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts @@ -25,18 +25,32 @@ export interface AppMetadata { isDevTools: boolean; } +/** + * Progress fraction reported by DMK's InstallOrUpdateAppsDeviceAction. + * Note: DMK's raw `requiredUserInteraction` for install (e.g. allow-secure-connection, + * allow-manager) is intentionally NOT exposed here — those signals are routed through + * the public 'ui-event' channel (collapsed to EConnectorInteraction) and additionally + * captured via debugLog in the connector for diagnosis. + */ export interface InstallProgress { progress: number; - requiredUserInteraction?: string; } export type InstallProgressCallback = (progress: InstallProgress) => void; export interface InstallAppCallParams { appName: string; - unlockTimeout?: number; - // In-process function ref forwarded through connector.call (params typed unknown). - onProgress?: InstallProgressCallback; +} + +/** Raised by DeviceApps.install when DMK reports the requested app is not in the catalog. */ +export class AppNotFoundInCatalogError extends Error { + readonly missingApplications: string[]; + + constructor(missing: string[]) { + super(`Ledger app not found in catalog: ${missing.join(', ')}`); + this.name = 'AppNotFoundInCatalogError'; + this.missingApplications = missing; + } } export interface ListInstalledAppsCallParams { @@ -185,29 +199,39 @@ export class DeviceApps { }), }); - await deviceActionToPromise( + const result = await deviceActionToPromise( action, this.onInteraction, INSTALL_TIMEOUT_MS, this.onRegisterCanceller, - onProgress - ? intermediateValue => { - const iv = intermediateValue as - | { - requiredUserInteraction?: string; - installPlan?: { currentProgress?: number } | null; - } - | undefined; - const progress = iv?.installPlan?.currentProgress; - if (typeof progress === 'number') { - onProgress({ - progress, - requiredUserInteraction: iv?.requiredUserInteraction, - }); + intermediateValue => { + const iv = intermediateValue as + | { + requiredUserInteraction?: string; + installPlan?: { currentProgress?: number } | null; } - } - : undefined, + | undefined; + // Surface DMK's install-specific interaction string (e.g. allow-secure-connection, + // allow-manager, verify-app) into the debug log for post-hoc diagnosis. The + // public 'ui-event' channel still emits the collapsed EConnectorInteraction so + // existing UI consumers keep working. + if (iv?.requiredUserInteraction && iv.requiredUserInteraction !== 'none') { + debugLog('[DeviceApps] install interaction:', iv.requiredUserInteraction); + } + const progress = iv?.installPlan?.currentProgress; + if (onProgress && typeof progress === 'number') { + onProgress({ progress }); + } + }, ); + + // DMK can resolve Completed with `missingApplications` populated when the + // requested name is not in the catalog. allowMissingApplication=false should + // make DMK reject, but we double-check on this side so the adapter never + // returns success(undefined) for an install that didn't actually install. + if (result?.missingApplications?.length > 0) { + throw new AppNotFoundInCatalogError(result.missingApplications); + } } } diff --git a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts index 65ef073d4..1e65ec797 100644 --- a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts +++ b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts @@ -112,12 +112,19 @@ export function deviceActionToPromise( onInteraction(interactionName); }; + const completeInteraction = () => { + if (!clearActiveInteraction()) { + onInteraction?.('interaction-complete'); + } + }; + const armIdleWatchdog = () => { if (timer) clearTimeout(timer); if (timeoutMs > 0) { timer = setTimeout(() => { if (!settled) { settled = true; + completeInteraction(); cancelAction(); reject( new Error( @@ -139,6 +146,7 @@ export function deviceActionToPromise( } settled = true; if (timer) clearTimeout(timer); + completeInteraction(); cancelAction(); const err = new Error(reason?.message ?? 'Device action cancelled'); if (reason?.code !== undefined) { @@ -177,17 +185,13 @@ export function deviceActionToPromise( if (state.status === DeviceActionStatus.Completed) { settled = true; if (timer) clearTimeout(timer); - if (!clearActiveInteraction()) { - onInteraction?.('interaction-complete'); - } + completeInteraction(); sub?.unsubscribe(); resolve(state.output); } else if (state.status === DeviceActionStatus.Error) { settled = true; if (timer) clearTimeout(timer); - if (!clearActiveInteraction()) { - onInteraction?.('interaction-complete'); - } + completeInteraction(); sub?.unsubscribe(); rejectWithStepContext(state.error, lastStep, observedSteps, reject); } else if (state.status === DeviceActionStatus.Pending) { @@ -208,6 +212,7 @@ export function deviceActionToPromise( if (!settled) { settled = true; if (timer) clearTimeout(timer); + completeInteraction(); sub?.unsubscribe(); rejectWithStepContext(err, lastStep, observedSteps, reject); } @@ -216,6 +221,7 @@ export function deviceActionToPromise( if (!settled) { settled = true; if (timer) clearTimeout(timer); + completeInteraction(); reject(new Error('Device action completed without result')); } }, From 908beb3d278b37c1c76ce5798ba0e49ce64189eb Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 14:38:53 +0800 Subject: [PATCH 5/9] fix(ledger): replace ReDoS-prone OOM regex with string includes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the previous `/out of memory|not enough.*space| insufficient.*memory/i` pattern as polynomial-time on uncontrolled input — `.*` could backtrack catastrophically on adversarial strings like "not enough" repeated N times. Switch to plain Array.some + String.includes which runs in linear time and is impossible to ReDoS. Matched substrings are the same three phrases. --- packages/hwk-ledger-adapter/src/errors.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index 85662c3e3..76c8c269f 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -408,12 +408,22 @@ export function isAppNotInstalledError(err: unknown): boolean { } /** DMK install ran out of space on the device. */ +const OUT_OF_MEMORY_MESSAGE_SUBSTRINGS = [ + 'out of memory', + 'not enough space', + 'insufficient memory', +]; + export function isOutOfMemoryError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as Record; if (e._tag === 'OutOfMemoryDAError') return true; - if (typeof e.message === 'string' && /out of memory|not enough.*space|insufficient.*memory/i.test(e.message)) { - return true; + if (typeof e.message === 'string') { + const msg = e.message.toLowerCase(); + // Plain substring check (no regex) avoids ReDoS on adversarial inputs: + // the previous /not enough.*space|insufficient.*memory/i pattern could + // backtrack catastrophically on crafted strings like "not enough" × N. + return OUT_OF_MEMORY_MESSAGE_SUBSTRINGS.some(s => msg.includes(s)); } return false; } From a1b39d87ad68bd319bce8e683cee721a4580a23e Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 14:54:13 +0800 Subject: [PATCH 6/9] chore(ledger): fix prettier / eslint lint issues Apply prettier auto-fix to LedgerAdapter, LedgerConnectorBase, DeviceApps, DeviceAppsManager (line-wrapping + trailing-comma cleanup, imports collapsed to single line where they fit). Add scoped eslint-disable for max-classes-per-file and the no-useless-constructor / no-empty-function pair on TS parameter-property constructors, matching the existing convention in SignerEth/Btc/Sol. --- .../src/adapter/LedgerAdapter.ts | 6 +--- .../src/connector/LedgerConnectorBase.ts | 5 +--- .../src/device-apps/DeviceApps.ts | 28 +++++++++---------- .../src/device-apps/DeviceAppsManager.ts | 7 ++--- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts index 724697fef..4d36fe560 100644 --- a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts +++ b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts @@ -27,11 +27,7 @@ import { import { isLedgerBleConnectionType } from '../utils/ledgerDmkTransport'; import { debugError, debugLog } from '../utils/debugLog'; -import type { - AppMetadata, - FirmwareVersion, - LedgerDeviceInfo, -} from '../device-apps/DeviceApps'; +import type { AppMetadata, FirmwareVersion, LedgerDeviceInfo } from '../device-apps/DeviceApps'; import type { BtcAddress, BtcGetAddressParams, diff --git a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts index e310c52b4..503929367 100644 --- a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts +++ b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts @@ -141,10 +141,7 @@ async function defaultLedgerKitImporter(pkg: string): Promise { } // Mirrors _getEthSigner in chains/evm.ts. -async function _getDeviceApps( - ctx: ConnectorContext, - sessionId: string, -): Promise { +async function _getDeviceApps(ctx: ConnectorContext, sessionId: string): Promise { const manager = await ctx.getDeviceAppsManager(); const apps = await manager.getOrCreate(sessionId); apps.onInteraction = (interaction: string) => { diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts index bb9c9f774..079102ab9 100644 --- a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts @@ -1,13 +1,11 @@ +/* eslint-disable max-classes-per-file -- DeviceApps + the duck-typed custom DeviceActions stay co-located because the custom actions only exist to feed deviceActionToPromise / DMK's executeDeviceAction. */ import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; import { Subject } from 'rxjs'; import { deviceActionToPromise } from '../signer/deviceActionToPromise'; import { debugLog } from '../utils/debugLog'; -import type { - DeviceManagementKit, - GetOsVersionResponse, -} from '@ledgerhq/device-management-kit'; +import type { DeviceManagementKit, GetOsVersionResponse } from '@ledgerhq/device-management-kit'; import type { Observable } from 'rxjs'; import type { CancelReason } from '../signer/deviceActionToPromise'; @@ -88,11 +86,13 @@ export class DeviceApps { onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void; + /* eslint-disable no-useless-constructor, no-empty-function -- TS parameter properties pattern */ constructor( private readonly _dmk: DeviceManagementKit, private readonly _sessionId: string, - private readonly _ledgerKit: LedgerKitModule, + private readonly _ledgerKit: LedgerKitModule ) {} + /* eslint-enable no-useless-constructor, no-empty-function */ async listInstalled(options?: { unlockTimeout?: number }): Promise { const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ @@ -105,11 +105,9 @@ export class DeviceApps { action, this.onInteraction, undefined, - this.onRegisterCanceller, + this.onRegisterCanceller ); - return result - .filter((a): a is DmkApplication => a !== null) - .map(applicationToMetadata); + return result.filter((a): a is DmkApplication => a !== null).map(applicationToMetadata); } // Catalog lookup via custom device action — DMK has no typed wrapper for this. @@ -126,7 +124,7 @@ export class DeviceApps { action, this.onInteraction, undefined, - this.onRegisterCanceller, + this.onRegisterCanceller ); return result.map(applicationToMetadata); } @@ -172,14 +170,14 @@ export class DeviceApps { action, this.onInteraction, undefined, - this.onRegisterCanceller, + this.onRegisterCanceller ); } async install( appName: string, onProgress?: InstallProgressCallback, - options?: { unlockTimeout?: number }, + options?: { unlockTimeout?: number } ): Promise { if (!appName) throw new Error('DeviceApps.install: appName is required'); debugLog('[DeviceApps] install:', appName); @@ -222,7 +220,7 @@ export class DeviceApps { if (onProgress && typeof progress === 'number') { onProgress({ progress }); } - }, + } ); // DMK can resolve Completed with `missingApplications` populated when the @@ -266,7 +264,6 @@ function applicationToMetadata(app: DmkApplication): AppMetadata { }; } - // Loosened DMK surface (we receive the module via dynamic importLedgerKit). export interface LedgerKitModule { ListAppsWithMetadataDeviceAction: new (args: { input: unknown }) => unknown; @@ -315,6 +312,7 @@ type OsVersionDeps = { class GetOsVersionDeviceAction { readonly input = undefined; + // eslint-disable-next-line no-useless-constructor, no-empty-function constructor(private readonly _deps: OsVersionDeps) {} _execute(internalApi: { sendCommand: (cmd: unknown) => Promise }): { @@ -362,7 +360,7 @@ class ListAvailableAppsDeviceAction { private readonly _GetOsVersionCommand: new () => unknown; private readonly _isSuccessCommandResult: ( - result: unknown, + result: unknown ) => result is { data: GetOsVersionResponse }; constructor(deps: OsVersionDeps) { diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts index 7ffbc55b8..f8e14a8dc 100644 --- a/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts @@ -9,17 +9,14 @@ export class DeviceAppsManager { private readonly _importLedgerKit: (pkg: string) => Promise; - constructor( - dmk: DeviceManagementKit, - importLedgerKit: (pkg: string) => Promise, - ) { + constructor(dmk: DeviceManagementKit, importLedgerKit: (pkg: string) => Promise) { this._dmk = dmk; this._importLedgerKit = importLedgerKit; } async getOrCreate(sessionId: string): Promise { const ledgerKit = (await this._importLedgerKit( - '@ledgerhq/device-management-kit', + '@ledgerhq/device-management-kit' )) as LedgerKitModule; return new DeviceApps(this._dmk, sessionId, ledgerKit); } From bff8398c29261548edbcbe0bde87dda9e6f029d0 Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 16:36:10 +0800 Subject: [PATCH 7/9] refactor(ledger): split device-apps into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard fix for the max-classes-per-file lint error instead of suppressing the rule at file scope. AppNotFoundInCatalogError and the two duck-typed DMK custom DeviceActions are independent concerns and already had their own scope of concerns. - device-apps/errors.ts — AppNotFoundInCatalogError - device-apps/customActions.ts — GetOsVersionDeviceAction + ListAvailableAppsDeviceAction (still co-located: shared internal OsVersionDeps / InternalApiLike / AnyState types, two same-shape DeviceAction implementations; matches the slip39.ts precedent in the repo) - device-apps/DeviceApps.ts — main class + public API types --- .../src/device-apps/DeviceApps.ts | 165 +----------------- .../src/device-apps/customActions.ts | 157 +++++++++++++++++ .../src/device-apps/errors.ts | 10 ++ 3 files changed, 170 insertions(+), 162 deletions(-) create mode 100644 packages/hwk-ledger-adapter/src/device-apps/customActions.ts create mode 100644 packages/hwk-ledger-adapter/src/device-apps/errors.ts diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts index 079102ab9..c4bc7d992 100644 --- a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts @@ -1,12 +1,10 @@ -/* eslint-disable max-classes-per-file -- DeviceApps + the duck-typed custom DeviceActions stay co-located because the custom actions only exist to feed deviceActionToPromise / DMK's executeDeviceAction. */ -import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; -import { Subject } from 'rxjs'; - import { deviceActionToPromise } from '../signer/deviceActionToPromise'; import { debugLog } from '../utils/debugLog'; +import { GetOsVersionDeviceAction, ListAvailableAppsDeviceAction } from './customActions'; +import { AppNotFoundInCatalogError } from './errors'; +import type { DmkApplication } from './customActions'; import type { DeviceManagementKit, GetOsVersionResponse } from '@ledgerhq/device-management-kit'; -import type { Observable } from 'rxjs'; import type { CancelReason } from '../signer/deviceActionToPromise'; const INSTALL_TIMEOUT_MS = 5 * 60_000; @@ -40,17 +38,6 @@ export interface InstallAppCallParams { appName: string; } -/** Raised by DeviceApps.install when DMK reports the requested app is not in the catalog. */ -export class AppNotFoundInCatalogError extends Error { - readonly missingApplications: string[]; - - constructor(missing: string[]) { - super(`Ledger app not found in catalog: ${missing.join(', ')}`); - this.name = 'AppNotFoundInCatalogError'; - this.missingApplications = missing; - } -} - export interface ListInstalledAppsCallParams { unlockTimeout?: number; } @@ -233,18 +220,6 @@ export class DeviceApps { } } -interface DmkApplication { - versionName: string; - versionId: number; - version: string; - versionDisplayName: string | null; - description: string | null; - icon: string | null; - bytes: number | null; - currencyId: string | null; - isDevTools: boolean; -} - function bytesToHex(bytes: Uint8Array | undefined): string { if (!bytes) return ''; return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); @@ -283,137 +258,3 @@ interface DmkExecuteCapable { // eslint-disable-next-line @typescript-eslint/no-explicit-any executeDeviceAction(args: { sessionId: string; deviceAction: unknown }): any; } - -// Duck-typed DeviceAction; executeDeviceAction injects InternalApi for managerApi access. - -interface InternalApiLike { - sendCommand: (command: unknown) => Promise; - getManagerApiService: () => { - getAppList: (deviceInfo: GetOsVersionResponse) => { - run: () => Promise<{ isLeft: () => boolean; extract: () => unknown }>; - }; - }; -} - -type AnyState = - | { readonly status: DeviceActionStatus.NotStarted } - | { readonly status: DeviceActionStatus.Pending; readonly intermediateValue: unknown } - | { readonly status: DeviceActionStatus.Stopped } - | { readonly status: DeviceActionStatus.Completed; readonly output: T } - | { readonly status: DeviceActionStatus.Error; readonly error: unknown }; - -type OsVersionDeps = { - GetOsVersionCommand: new () => unknown; - isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; -}; - -// Wrapped as a DeviceAction (not raw sendCommand) so unlock-device interaction -// flows through onInteraction like every other method. -class GetOsVersionDeviceAction { - readonly input = undefined; - - // eslint-disable-next-line no-useless-constructor, no-empty-function - constructor(private readonly _deps: OsVersionDeps) {} - - _execute(internalApi: { sendCommand: (cmd: unknown) => Promise }): { - observable: Observable>; - cancel: () => void; - } { - const subject = new Subject>(); - let cancelled = false; - - (async () => { - try { - subject.next({ - status: DeviceActionStatus.Pending, - intermediateValue: { requiredUserInteraction: 'none' }, - }); - const result = await internalApi.sendCommand(new this._deps.GetOsVersionCommand()); - if (cancelled) return; - if (!this._deps.isSuccessCommandResult(result)) { - const errObj = (result as { error?: { message?: string } })?.error; - throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); - } - subject.next({ status: DeviceActionStatus.Completed, output: result.data }); - subject.complete(); - } catch (err) { - if (cancelled) return; - subject.next({ status: DeviceActionStatus.Error, error: err }); - subject.complete(); - } - })(); - - return { - observable: subject.asObservable(), - cancel: () => { - cancelled = true; - subject.next({ status: DeviceActionStatus.Stopped }); - subject.complete(); - }, - }; - } -} - -class ListAvailableAppsDeviceAction { - readonly input = undefined; - - private readonly _GetOsVersionCommand: new () => unknown; - - private readonly _isSuccessCommandResult: ( - result: unknown - ) => result is { data: GetOsVersionResponse }; - - constructor(deps: OsVersionDeps) { - this._GetOsVersionCommand = deps.GetOsVersionCommand; - this._isSuccessCommandResult = deps.isSuccessCommandResult; - } - - _execute(internalApi: InternalApiLike): { - observable: Observable>; - cancel: () => void; - } { - const subject = new Subject>(); - let cancelled = false; - - (async () => { - try { - subject.next({ - status: DeviceActionStatus.Pending, - intermediateValue: { requiredUserInteraction: 'none' }, - }); - - const osVersionResult = await internalApi.sendCommand(new this._GetOsVersionCommand()); - if (cancelled) return; - if (!this._isSuccessCommandResult(osVersionResult)) { - const errObj = (osVersionResult as { error?: { message?: string } })?.error; - throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); - } - - const managerApi = internalApi.getManagerApiService(); - const either = await managerApi.getAppList(osVersionResult.data).run(); - if (cancelled) return; - if (either.isLeft()) { - const httpErr = either.extract() as { message?: string }; - throw new Error(httpErr?.message ?? 'Manager API getAppList failed'); - } - - const apps = either.extract() as DmkApplication[]; - subject.next({ status: DeviceActionStatus.Completed, output: apps }); - subject.complete(); - } catch (err) { - if (cancelled) return; - subject.next({ status: DeviceActionStatus.Error, error: err }); - subject.complete(); - } - })(); - - return { - observable: subject.asObservable(), - cancel: () => { - cancelled = true; - subject.next({ status: DeviceActionStatus.Stopped }); - subject.complete(); - }, - }; - } -} diff --git a/packages/hwk-ledger-adapter/src/device-apps/customActions.ts b/packages/hwk-ledger-adapter/src/device-apps/customActions.ts new file mode 100644 index 000000000..6e381bdea --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/customActions.ts @@ -0,0 +1,157 @@ +/* eslint-disable max-classes-per-file -- both classes are duck-typed DMK DeviceAction implementations sharing the same internal types (OsVersionDeps / InternalApiLike / AnyState); splitting would push those types into a third file with no payoff. */ +/** + * Duck-typed DMK DeviceAction implementations for the two flows DMK has no + * typed wrapper for: fetching OS version and listing available apps in the + * catalog. Both are routed through `executeDeviceAction` so the + * `unlock-device` interaction surfaces through the same onInteraction + * pipeline as every other call. + */ +import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; +import { Subject } from 'rxjs'; + +import type { GetOsVersionResponse } from '@ledgerhq/device-management-kit'; +import type { Observable } from 'rxjs'; + +export interface DmkApplication { + versionName: string; + versionId: number; + version: string; + versionDisplayName: string | null; + description: string | null; + icon: string | null; + bytes: number | null; + currencyId: string | null; + isDevTools: boolean; +} + +export type OsVersionDeps = { + GetOsVersionCommand: new () => unknown; + isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; +}; + +interface InternalApiLike { + sendCommand: (command: unknown) => Promise; + getManagerApiService: () => { + getAppList: (deviceInfo: GetOsVersionResponse) => { + run: () => Promise<{ isLeft: () => boolean; extract: () => unknown }>; + }; + }; +} + +type AnyState = + | { readonly status: DeviceActionStatus.NotStarted } + | { readonly status: DeviceActionStatus.Pending; readonly intermediateValue: unknown } + | { readonly status: DeviceActionStatus.Stopped } + | { readonly status: DeviceActionStatus.Completed; readonly output: T } + | { readonly status: DeviceActionStatus.Error; readonly error: unknown }; + +// Wrapped as a DeviceAction (not raw sendCommand) so unlock-device interaction +// flows through onInteraction like every other method. +export class GetOsVersionDeviceAction { + readonly input = undefined; + + // eslint-disable-next-line no-useless-constructor, no-empty-function + constructor(private readonly _deps: OsVersionDeps) {} + + _execute(internalApi: { sendCommand: (cmd: unknown) => Promise }): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + const result = await internalApi.sendCommand(new this._deps.GetOsVersionCommand()); + if (cancelled) return; + if (!this._deps.isSuccessCommandResult(result)) { + const errObj = (result as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + subject.next({ status: DeviceActionStatus.Completed, output: result.data }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} + +export class ListAvailableAppsDeviceAction { + readonly input = undefined; + + private readonly _GetOsVersionCommand: new () => unknown; + + private readonly _isSuccessCommandResult: ( + result: unknown + ) => result is { data: GetOsVersionResponse }; + + constructor(deps: OsVersionDeps) { + this._GetOsVersionCommand = deps.GetOsVersionCommand; + this._isSuccessCommandResult = deps.isSuccessCommandResult; + } + + _execute(internalApi: InternalApiLike): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + + const osVersionResult = await internalApi.sendCommand(new this._GetOsVersionCommand()); + if (cancelled) return; + if (!this._isSuccessCommandResult(osVersionResult)) { + const errObj = (osVersionResult as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + + const managerApi = internalApi.getManagerApiService(); + const either = await managerApi.getAppList(osVersionResult.data).run(); + if (cancelled) return; + if (either.isLeft()) { + const httpErr = either.extract() as { message?: string }; + throw new Error(httpErr?.message ?? 'Manager API getAppList failed'); + } + + const apps = either.extract() as DmkApplication[]; + subject.next({ status: DeviceActionStatus.Completed, output: apps }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} diff --git a/packages/hwk-ledger-adapter/src/device-apps/errors.ts b/packages/hwk-ledger-adapter/src/device-apps/errors.ts new file mode 100644 index 000000000..3f6adf180 --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/errors.ts @@ -0,0 +1,10 @@ +/** Raised by DeviceApps.install when DMK reports the requested app is not in the catalog. */ +export class AppNotFoundInCatalogError extends Error { + readonly missingApplications: string[]; + + constructor(missing: string[]) { + super(`Ledger app not found in catalog: ${missing.join(', ')}`); + this.name = 'AppNotFoundInCatalogError'; + this.missingApplications = missing; + } +} From f74dc5eb2b04c5d1db920ebe059431c85be36896 Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 17:05:16 +0800 Subject: [PATCH 8/9] fix(ledger): restore semantic equivalence of OOM substring check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous ReDoS fix (908beb3d) lost matching coverage: the old regex `/not enough.*space|insufficient.*memory/i` matched intermediate tokens like "Not enough free space" / "Insufficient available memory" via `.*`, but the substring list only had the exact phrases. Switch to AND-checks ('not enough' && 'space', 'insufficient' && 'memory') so any message containing both anchor tokens — regardless of words in between — still hits, while keeping linear-time evaluation. Verified equivalent to the original regex on 10 representative cases including the tricky token-order edge cases. --- packages/hwk-ledger-adapter/src/errors.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index 76c8c269f..553ac9b6b 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -408,22 +408,19 @@ export function isAppNotInstalledError(err: unknown): boolean { } /** DMK install ran out of space on the device. */ -const OUT_OF_MEMORY_MESSAGE_SUBSTRINGS = [ - 'out of memory', - 'not enough space', - 'insufficient memory', -]; - export function isOutOfMemoryError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as Record; if (e._tag === 'OutOfMemoryDAError') return true; if (typeof e.message === 'string') { const msg = e.message.toLowerCase(); - // Plain substring check (no regex) avoids ReDoS on adversarial inputs: - // the previous /not enough.*space|insufficient.*memory/i pattern could - // backtrack catastrophically on crafted strings like "not enough" × N. - return OUT_OF_MEMORY_MESSAGE_SUBSTRINGS.some(s => msg.includes(s)); + // Substring AND-checks preserve the prior regex semantics (".*" between + // tokens — "not enough free space", "insufficient available memory") while + // staying linear-time. The earlier /not enough.*space|insufficient.*memory/i + // pattern was flagged by CodeQL as ReDoS-prone. + if (msg.includes('out of memory')) return true; + if (msg.includes('not enough') && msg.includes('space')) return true; + if (msg.includes('insufficient') && msg.includes('memory')) return true; } return false; } From 484be56347bee8717861a465042db03de167b9df Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Wed, 27 May 2026 17:35:52 +0800 Subject: [PATCH 9/9] fix(ledger): identify OOM solely by DMK _tag, drop message heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original regex was added as a "safety net" for messages that happen to carry the OOM phrasing but lack `_tag = OutOfMemoryDAError`. In practice DMK always sets that tag for `OutOfMemoryDAError`, so the message heuristic was speculative — and the two attempts at safely rewriting the regex either lost coverage (literal substrings) or over-classified (token AND-checks). Drop the heuristic entirely. `isOutOfMemoryError` now identifies only the canonical DMK tag. If we ever observe a real OOM that doesn't carry the tag, add a precise check then — don't speculate now. Eliminates the CodeQL ReDoS finding and the equivalence concern in one go. --- packages/hwk-ledger-adapter/src/errors.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index 553ac9b6b..bf87c6172 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -407,22 +407,11 @@ export function isAppNotInstalledError(err: unknown): boolean { return false; } -/** DMK install ran out of space on the device. */ +/** DMK install ran out of space on the device. Identified by the DMK error tag. */ export function isOutOfMemoryError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as Record; - if (e._tag === 'OutOfMemoryDAError') return true; - if (typeof e.message === 'string') { - const msg = e.message.toLowerCase(); - // Substring AND-checks preserve the prior regex semantics (".*" between - // tokens — "not enough free space", "insufficient available memory") while - // staying linear-time. The earlier /not enough.*space|insufficient.*memory/i - // pattern was flagged by CodeQL as ReDoS-prone. - if (msg.includes('out of memory')) return true; - if (msg.includes('not enough') && msg.includes('space')) return true; - if (msg.includes('insufficient') && msg.includes('memory')) return true; - } - return false; + return e._tag === 'OutOfMemoryDAError'; } /** Check for device disconnected errors. */