import semver from "semver"; import { Observable, concat, from, of, throwError, defer, merge } from "rxjs"; import { mergeMap, concatMap, map, catchError, delay } from "rxjs/operators"; import { TransportStatusError, FirmwareOrAppUpdateRequired, UserRefusedOnDevice, BtcUnmatchedApp, UpdateYourApp, DisconnectedDeviceDuringOperation, DisconnectedDevice, StatusCodes, LockedDeviceError, LatestFirmwareVersionRequired, } from "@ledgerhq/errors"; import type Transport from "@ledgerhq/hw-transport"; import { type DerivationMode, DeviceInfo, FirmwareUpdateContext } from "@ledgerhq/types-live"; import type { AppOp, SkippedAppOp } from "../apps/types"; import { getCryptoCurrencyById } from "../currencies"; import appSupportsQuitApp from "../appSupportsQuitApp"; import { withDevice } from "./deviceAccess"; import inlineAppInstall from "../apps/inlineAppInstall"; import { isDashboardName } from "./isDashboardName"; import getAppAndVersion from "./getAppAndVersion"; import getDeviceInfo from "./getDeviceInfo"; import getAddress from "./getAddress"; import openApp from "./openApp"; import quitApp from "./quitApp"; import { mustUpgrade, getMinVersion, getDeprecationConfig } from "../apps"; import isUpdateAvailable from "./isUpdateAvailable"; import { LockedDeviceEvent } from "./actions/types"; import { getLatestFirmwareForDeviceUseCase } from "../device/use-cases/getLatestFirmwareForDeviceUseCase"; import { type ApplicationDependency, type ApplicationConstraint, type ApplicationVersionConstraint, DeviceModelId, } from "@ledgerhq/device-management-kit"; import { ConnectAppDeviceAction } from "@ledgerhq/live-dmk-shared"; import { ConnectAppEventMapper } from "./connectAppEventMapper"; import { DeviceId } from "@ledgerhq/client-ids/ids"; import { DeviceModelId as LLDeviceModelId } from "@ledgerhq/types-devices"; import { isDmkTransport } from "./dmkUtils"; /** * Represents the deprecation status of a device. * * @property warningScreenVisible - Whether the generic deprecation warning screen should be shown. * @property clearSigningScreenVisible - Whether the clear signing deprecation warning screen should be shown. * @property errorScreenVisible - Whether the deprecation error screen should be shown (blocking usage). * @property modelId - The modeID of the affected product * @property date - The date when the deprecation becomes effective. * @property warningScreenRules - Optional configuration for the warning screen. * @property clearSigningScreenRules - Optional configuration for the clear signing screen. * @property errorScreenRules - Optional configuration for the error screen. * @property onContinue - Callback invoked when the user chooses to continue or throw an error despite the deprecation warning. */ export type DeviceDeprecationRules = { warningScreenVisible: boolean; clearSigningScreenVisible: boolean; errorScreenVisible: boolean; modelId: LLDeviceModelId; date: Date; warningScreenRules?: DeviceDeprecationScreenRules; clearSigningScreenRules?: DeviceDeprecationScreenRules; errorScreenRules?: DeviceDeprecationScreenRules; onContinue: (isError?: boolean) => void; }; /** * Configuration defining exceptions to device deprecation restrictions. * * @property exeption - List of token or main coin identifiers exempt from the restriction. * @property deprecatedFlow - List of flow identifiers (e.g., send, receive) to restrict. */ export type DeviceDeprecationScreenRules = { exception?: string[]; deprecatedFlow?: string[]; }; export type RequiresDerivation = { currencyId: string; path: string; derivationMode: DerivationMode; forceFormat?: string; }; export type Input = { deviceId: string; deviceName: string | null; request: ConnectAppRequest; }; export type ConnectAppRequest = { appName: string; requiresDerivation?: RequiresDerivation; dependencies?: string[]; requireLatestFirmware?: boolean; outdatedApp?: AppAndVersion; allowPartialDependencies: boolean; }; export type AppAndVersion = { name: string; version: string; flags: number | Buffer; }; export type ConnectAppEvent = | { type: "unresponsiveDevice"; } | { type: "disconnected"; expected?: boolean; } | { type: "device-update-last-seen"; deviceInfo: DeviceInfo; latestFirmware: FirmwareUpdateContext | null | undefined; } | { type: "device-permission-requested"; } | { type: "device-permission-granted"; } | { type: "device-id"; deviceId: DeviceId; } | { type: "app-not-installed"; appNames: string[]; appName: string; } | { type: "inline-install"; progress: number; itemProgress: number; currentAppOp: AppOp; installQueue: string[]; } | { type: "deprecation"; deprecate: DeviceDeprecationRules; } | { type: "some-apps-skipped"; skippedAppOps: SkippedAppOp[]; } | { type: "listing-apps"; } | { type: "listed-apps"; installQueue: string[]; } | { type: "dependencies-resolved"; } | { type: "latest-firmware-resolved"; } | { type: "ask-quit-app"; } | { type: "ask-open-app"; appName: string; } | { type: "has-outdated-app"; outdatedApp: AppAndVersion; } | { type: "opened"; app?: AppAndVersion; derivation?: { address: string; }; } | { type: "display-upgrade-warning"; displayUpgradeWarning: boolean; } | LockedDeviceEvent; export const openAppFromDashboard = ( transport: Transport, appName: string, ): Observable => from(getDeviceInfo(transport)).pipe( mergeMap(deviceInfo => merge( // Nb Allows LLD/LLM to update lastSeenDevice, this can run in parallel // since there are no more device exchanges. from(getLatestFirmwareForDeviceUseCase(deviceInfo)).pipe( concatMap(latestFirmware => of({ type: "device-update-last-seen", deviceInfo, latestFirmware, }), ), ), concat( of({ type: "ask-open-app", appName, }), defer(() => from(openApp(transport, appName))).pipe( concatMap(() => of({ type: "device-permission-granted", }), ), catchError(e => { if (e && e instanceof TransportStatusError) { switch (e.statusCode) { case 0x6984: // No StatusCodes definition case 0x6807: // No StatusCodes definition return inlineAppInstall({ transport, appNames: [appName], onSuccessObs: () => from(openAppFromDashboard(transport, appName)), }) as Observable; case StatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED: case 0x5501: // No StatusCodes definition return throwError(() => new UserRefusedOnDevice()); } } else if (e instanceof LockedDeviceError) { // openAppFromDashboard is exported, so LockedDeviceError should be handled here too return of({ type: "lockedDevice", } as ConnectAppEvent); } return throwError(() => e); }), ), ), ), ), ); const attemptToQuitApp = (transport, appAndVersion?: AppAndVersion): Observable => appAndVersion && appSupportsQuitApp(appAndVersion) ? from(quitApp(transport)).pipe( concatMap(() => of({ type: "disconnected", expected: true, }), ), catchError(e => throwError(() => e)), ) : of({ type: "ask-quit-app", }); const derivationLogic = ( transport: Transport, { requiresDerivation: { currencyId, ...derivationRest }, appAndVersion, appName, }: { requiresDerivation: RequiresDerivation; appAndVersion?: AppAndVersion; appName: string; }, ): Observable => defer(() => from( getAddress(transport, { currency: getCryptoCurrencyById(currencyId), ...derivationRest, }), ), ).pipe( map(({ address }) => ({ type: "opened", app: appAndVersion, derivation: { address, }, })), catchError(e => { if (!e) return throwError(() => e); if (e instanceof BtcUnmatchedApp) { return of({ type: "ask-open-app", appName, }); } if (e instanceof TransportStatusError) { const { statusCode } = e; if ( statusCode === StatusCodes.SECURITY_STATUS_NOT_SATISFIED || statusCode === StatusCodes.INCORRECT_LENGTH || (0x6600 <= statusCode && statusCode <= 0x67ff) ) { return of({ type: "ask-open-app", appName, }); } switch (statusCode) { case 0x6f04: // FW-90. app was locked... | No StatusCodes definition case StatusCodes.HALTED: // FW-90. app bricked, a reboot fixes it. case StatusCodes.INS_NOT_SUPPORTED: // this is likely because it's the wrong app (LNS 1.3.1) return attemptToQuitApp(transport, appAndVersion); } } else if (e instanceof LockedDeviceError) { // derivationLogic is also called inside the catchError of cmd below // so it needs to handle LockedDeviceError too return of({ type: "lockedDevice", } as ConnectAppEvent); } return throwError(() => e); }), ); /** * @param allowPartialDependencies If some dependencies need to be installed, and if set to true, * skip any app install if the app is not found from the provider. */ const cmd = (transport: Transport, { request }: Input): Observable => { const { appName, requiresDerivation, dependencies, requireLatestFirmware, outdatedApp, allowPartialDependencies = false, } = request; return new Observable(o => { const timeoutSub = of({ type: "unresponsiveDevice", }) .pipe(delay(1000)) .subscribe(e => o.next(e as ConnectAppEvent)); const innerSub = ({ appName, dependencies, requireLatestFirmware, }: ConnectAppRequest): Observable => defer(() => from(getAppAndVersion(transport))).pipe( concatMap((appAndVersion): Observable => { timeoutSub.unsubscribe(); if (isDashboardName(appAndVersion.name)) { // check if we meet minimum fw if (requireLatestFirmware || outdatedApp) { return from(getDeviceInfo(transport)).pipe( mergeMap((deviceInfo: DeviceInfo) => from(getLatestFirmwareForDeviceUseCase(deviceInfo)).pipe( mergeMap((latest: FirmwareUpdateContext | undefined | null) => { const isLatest = !latest || semver.eq(deviceInfo.version, latest.final.version); if ( (!requireLatestFirmware || (requireLatestFirmware && isLatest)) && outdatedApp ) { return from(isUpdateAvailable(deviceInfo, outdatedApp)).pipe( mergeMap(isAvailable => isAvailable ? throwError( () => new UpdateYourApp(undefined, { managerAppName: outdatedApp.name, }), ) : throwError( () => new LatestFirmwareVersionRequired( "LatestFirmwareVersionRequired", { latest: latest?.final.version, current: deviceInfo.version, }, ), ), ), ); } if (isLatest) { o.next({ type: "latest-firmware-resolved" }); return innerSub({ appName, dependencies, allowPartialDependencies, // requireLatestFirmware // Resolved!. }); } else { return throwError( () => new LatestFirmwareVersionRequired("LatestFirmwareVersionRequired", { latest: latest.final.version, current: deviceInfo.version, }), ); } }), ), ), ); } // check if we meet dependencies if (dependencies?.length) { const completesInDashboard = isDashboardName(appName); return inlineAppInstall({ transport, appNames: [...(completesInDashboard ? [] : [appName]), ...dependencies], onSuccessObs: () => { o.next({ type: "dependencies-resolved", }); return innerSub({ appName, allowPartialDependencies, // dependencies // Resolved! }); }, allowPartialDependencies, }); } // maybe we want to be in the dashboard if (appName === appAndVersion.name) { const e: ConnectAppEvent = { type: "opened", app: appAndVersion, }; return of(e); } // we're in dashboard return openAppFromDashboard(transport, appName); } const appNeedsUpgrade = mustUpgrade(appAndVersion.name, appAndVersion.version); if (appNeedsUpgrade) { // quit app, check provider's app update for device's minimum requirements. o.next({ type: "has-outdated-app", outdatedApp: appAndVersion, }); } // need dashboard to check firmware, install dependencies, or verify app update if ( dependencies?.length || requireLatestFirmware || appAndVersion.name !== appName || appNeedsUpgrade ) { return attemptToQuitApp(transport, appAndVersion as AppAndVersion); } if (requiresDerivation) { return derivationLogic(transport, { requiresDerivation, appAndVersion: appAndVersion as AppAndVersion, appName, }); } else { const e: ConnectAppEvent = { type: "opened", app: appAndVersion, }; return of(e); } }), catchError((e: unknown) => { if ( (typeof e === "object" && e !== null && "_tag" in e && e._tag === "DeviceDisconnectedWhileSendingError") || e instanceof DisconnectedDeviceDuringOperation || e instanceof DisconnectedDevice ) { return of({ type: "disconnected", }); } if (e && e instanceof TransportStatusError) { switch (e.statusCode) { case StatusCodes.CLA_NOT_SUPPORTED: // in 1.3.1 dashboard case StatusCodes.INS_NOT_SUPPORTED: // in 1.3.1 and bitcoin app // fallback on "old way" because device does not support getAppAndVersion if (!requiresDerivation) { // if there is no derivation, there is nothing we can do to check an app (e.g. requiring non coin app) return throwError(() => new FirmwareOrAppUpdateRequired()); } return derivationLogic(transport, { requiresDerivation, appName, }); } } else if (e instanceof LockedDeviceError) { return of({ type: "lockedDevice", } as ConnectAppEvent); } return throwError(() => e); }), ); const sub = innerSub({ appName, dependencies, requireLatestFirmware, allowPartialDependencies, }).subscribe(o); return () => { timeoutSub.unsubscribe(); sub.unsubscribe(); }; }); }; const appNameToDependency = (appName: string): ApplicationDependency => { const constraints = Object.values(DeviceModelId).reduce( (result, model) => { const minVersion = getMinVersion(appName, model); if (minVersion) { result.push({ minVersion: minVersion as ApplicationVersionConstraint, applicableModels: [model], }); } return result; }, [], ); return { name: appName, constraints, }; }; export default function connectAppFactory( { isLdmkConnectAppEnabled, }: { isLdmkConnectAppEnabled: boolean; } = { isLdmkConnectAppEnabled: false }, ) { if (!isLdmkConnectAppEnabled) { return ({ deviceId, deviceName, request }: Input): Observable => withDevice( deviceId, deviceName ? { matchDeviceByName: deviceName } : undefined, )(transport => cmd(transport, { deviceId, deviceName, request })); } return ({ deviceId, deviceName, request }: Input): Observable => { const { appName, requiresDerivation, dependencies, requireLatestFirmware, allowPartialDependencies = false, } = request; return withDevice( deviceId, deviceName ? { matchDeviceByName: deviceName } : undefined, )(transport => { if (!isDmkTransport(transport)) { return cmd(transport, { deviceId, deviceName, request }); } const { dmk, sessionId } = transport; const deviceAction = new ConnectAppDeviceAction({ input: { application: appNameToDependency(appName), dependencies: dependencies ? dependencies.map(name => appNameToDependency(name)) : [], requireLatestFirmware, allowMissingApplication: allowPartialDependencies, unlockTimeout: 0, // Expect to fail immediately when device is locked requiredDerivation: requiresDerivation ? async () => { try { dmk._unsafeBypassIntentQueue({ bypass: true, sessionId }); const { currencyId, ...derivationRest } = requiresDerivation; const derivation = await getAddress(transport, { currency: getCryptoCurrencyById(currencyId), ...derivationRest, }); return derivation.address; } finally { dmk._unsafeBypassIntentQueue({ bypass: false, sessionId }); } } : undefined, deprecationConfig: getDeprecationConfig(appName, dependencies), }, }); const observable = dmk.executeDeviceAction({ sessionId, deviceAction, }); return new ConnectAppEventMapper(dmk, sessionId, appName, observable).map(); }); }; }