UNPKG

@ledgerhq/live-common

Version:
367 lines (317 loc) • 13 kB
import Transport from "@ledgerhq/hw-transport"; import { DeviceModelId, getDeviceModel, identifyTargetId } from "@ledgerhq/devices"; import { StatusCodes, TransportStatusError, UnexpectedBootloader } from "@ledgerhq/errors"; import { Observable, throwError, Subscription } from "rxjs"; import { App, DeviceInfo, idsToLanguage, languageIds } from "@ledgerhq/types-live"; import { LocalTracer } from "@ledgerhq/logs"; import type { ListAppsEvent, ListAppsResult, ListAppResponse } from "./types"; import hwListApps from "../hw/listApps"; import customLockScreenFetchSize from "../hw/customLockScreenFetchSize"; import { listCryptoCurrencies, currenciesByMarketcap, findCryptoCurrencyById } from "../currencies"; import ManagerAPI from "../manager/api"; import { getDeviceName } from "../device/use-cases/getDeviceNameUseCase"; import { getLatestFirmwareForDeviceUseCase } from "../device/use-cases/getLatestFirmwareForDeviceUseCase"; import { getProviderIdUseCase } from "../device/use-cases/getProviderIdUseCase"; import { calculateDependencies, mapApplicationV2ToApp } from "./polyfill"; import { ManagerApiRepository } from "../device/factories/HttpManagerApiRepositoryFactory"; import { isCustomLockScreenSupported } from "../device/use-cases/isCustomLockScreenSupported"; import { DeviceId } from "@ledgerhq/client-ids/ids"; // Hash discrepancies for these apps do NOT indicate a potential update, // these apps have a mechanism that makes their hash change every time. const appsWithDynamicHashes = ["Fido U2F", "Security Key"]; // Empty hash data means we won't have information on the app. const emptyHashData = "0".repeat(64); type ListAppsParams = { transport: Transport; deviceInfo: DeviceInfo; managerDevModeEnabled: boolean; forceProvider: number; managerApiRepository: ManagerApiRepository; deviceProxyModel?: DeviceModelId; }; export const listApps = ({ transport, deviceInfo, deviceProxyModel, managerDevModeEnabled, forceProvider, managerApiRepository, }: ListAppsParams): Observable<ListAppsEvent> => { const tracer = new LocalTracer("list-apps", { transport: transport.getTraceContext() }); tracer.trace("Using new version", { deviceInfo }); if (deviceInfo.isOSU || deviceInfo.isBootloader) { return throwError(() => new UnexpectedBootloader("")); } const deviceModelId = transport?.deviceModel?.id || identifyTargetId(deviceInfo.targetId as number)?.id || deviceProxyModel; return new Observable(o => { let sub: Subscription; async function main() { const provider = getProviderIdUseCase({ deviceInfo, forceProvider }); if (!deviceModelId) throw new Error("Bad usage of listAppsV2: missing deviceModelId"); const deviceModel = getDeviceModel(deviceModelId); const bytesPerBlock = deviceModel.getBlockSize(deviceInfo.version); /** The following are several asynchronous sequences running in parallel */ /** * Sequence 1: obtain the full data regarding apps installed on the device * -> list raw data of apps installed on device * -> then filter apps (eliminate language packs and such) * -> then fetch matching app metadata using apps' hashes */ let listAppsResponsePromise: Promise<ListAppResponse>; function listAppsWithSingleCommand(): Promise<ListAppResponse> { return hwListApps(transport); } function listAppsWithManagerApi(): Promise<ListAppResponse> { return new Promise<ListAppResponse>((resolve, reject) => { // TODO: migrate this ManagerAPI call to ManagerApiRepository sub = ManagerAPI.listInstalledApps(transport, { targetId: deviceInfo.targetId, perso: "perso_11", }).subscribe({ next: e => { switch (e.type) { case "result": resolve(e.payload); break; case "device-permission-granted": case "device-permission-requested": o.next(e); break; case "device-id": { // Normalize deviceId to DeviceId object (SocketEvent may have string or DeviceId) const deviceIdValue = typeof e.deviceId === "string" ? DeviceId.fromString(e.deviceId) : e.deviceId; o.next({ type: "device-id", deviceId: deviceIdValue, }); break; } } }, error: reject, }); }); } if (deviceInfo.managerAllowed) { // If the user has already allowed a secure channel during this session we can directly // ask the device for the installed applications instead of going through a scriptrunner, // this is a performance optimization, part of a larger rework with Manager API v2. tracer.trace("Using direct apdu listapps"); listAppsResponsePromise = listAppsWithSingleCommand().catch(e => { // For some old versions of the firmware, the listapps command is not supported. // In this case, we fallback to the scriptrunner listapps. if ( e instanceof TransportStatusError && [ StatusCodes.CLA_NOT_SUPPORTED, StatusCodes.INS_NOT_SUPPORTED, StatusCodes.UNKNOWN_APDU, 0x6e01, // No StatusCodes definition 0x6d01, // No StatusCodes definition ].includes(e.statusCode) ) { tracer.trace("Fallback to scriptrunner listapps"); return listAppsWithManagerApi(); } throw e; }); } else { // Fallback to original web-socket list apps tracer.trace("Using scriptrunner listapps"); listAppsResponsePromise = listAppsWithManagerApi(); } const filteredListAppsPromise = listAppsResponsePromise.then(result => { // Empty HashData can come from apps that are not real apps (such as language packs) // or custom applications that have been sideloaded. return result .filter(({ hash_code_data }) => hash_code_data !== emptyHashData) .map(({ hash, name }) => ({ hash, name })); }); const listAppsAndMatchesPromise = filteredListAppsPromise.then(result => { const hashes = result.map(({ hash }) => hash); const matches = result.length ? managerApiRepository .getAppsByHash(hashes) .then(matches => matches.map(appV2 => (appV2 ? mapApplicationV2ToApp(appV2) : null))) : []; return Promise.all([result, matches]); }); /** * Sequence 2: get information about current and latest firmware available * for the device */ const deviceVersionPromise = managerApiRepository.getDeviceVersion({ targetId: deviceInfo.targetId, providerId: provider, }); const currentFirmwarePromise = deviceVersionPromise.then(deviceVersion => managerApiRepository.getCurrentFirmware({ deviceId: deviceVersion.id, version: deviceInfo.version, providerId: provider, }), ); const latestFirmwarePromise = currentFirmwarePromise.then(currentFirmware => getLatestFirmwareForDeviceUseCase(deviceInfo, managerApiRepository).then( updateAvailable => ({ ...currentFirmware, updateAvailable, }), ), ); /** * Sequence 3: get catalog of apps available for the device */ const catalogForDevicesPromise = managerApiRepository .catalogForDevice({ provider, targetId: deviceInfo.targetId, firmwareVersion: deviceInfo.version, }) .then(apps => apps.map(mapApplicationV2ToApp)); /** * Sequence 4: list all currencies, sorted by market cp */ const sortedCryptoCurrenciesPromise = currenciesByMarketcap( listCryptoCurrencies(managerDevModeEnabled, true), ); /** * Sequence 5: get language pack available for the device */ const languagePackForDevicePromise = managerApiRepository.getLanguagePackagesForDevice( deviceInfo, forceProvider, ); /* Running all sequences 1 2 3 4 5 defined above in parallel */ const [[listApps, matches], catalogForDevice, firmware, sortedCryptoCurrencies, languages] = await Promise.all([ listAppsAndMatchesPromise, catalogForDevicesPromise, latestFirmwarePromise, sortedCryptoCurrenciesPromise, languagePackForDevicePromise, ]); calculateDependencies(); /** * Associate a market cap sorting index to each app of the catalog of * available apps. */ catalogForDevice.forEach(app => { const crypto = app.currencyId && findCryptoCurrencyById(app.currencyId); if (crypto) { app.indexOfMarketCap = sortedCryptoCurrencies.indexOf(crypto); } }); /** * Aggregate the data obtained above to build the list of installed apps * with their full metadata. */ const installedList: App[] = []; listApps.forEach((item, index) => { if (item == null) return; const { name: localName, hash: localHash } = item; const matchFromHash = matches[index]; if (matchFromHash && matchFromHash.hash === localHash) { installedList.push(matchFromHash as App); return; } // If the hash is not static (ex: Fido app) we need to find the app by its name using the catalog const matchFromCatalog = catalogForDevice.find(({ name }) => name === localName); tracer.trace(`Falling back to catalog for ${localName}`, { localName, matchFromCatalog: Boolean(matchFromCatalog), }); if (matchFromCatalog) { installedList.push(matchFromCatalog); } }); tracer.trace("Installed and in catalog apps", { installedApps: installedList.length, inCatalogApps: catalogForDevice.length, }); // Abused somewhere else const appByName = catalogForDevice.reduce((result, app) => { result[app.name] = app; return result; }, {}); const installedAppNames = {}; // Polyfill more data on the app installed const installed = installedList.map(({ name, hash, bytes, version }) => { installedAppNames[name] = true; const appInCatalog = appByName[name]; const updateAvailable = appInCatalog?.hash !== hash; const ignoreUpdate = appsWithDynamicHashes.includes(name); const updated = ignoreUpdate || !updateAvailable; const availableVersion = appInCatalog?.version || ""; const blocks = Math.ceil((bytes || appInCatalog.bytes || 0) / bytesPerBlock); return { name, updated, blocks, hash, version, availableVersion, }; }); // Used to hide apps that are dev tools if user didn't opt-in. const appsListNames = catalogForDevice .filter( ({ isDevTools, name }) => managerDevModeEnabled || !isDevTools || name in installedAppNames, ) .map(({ name }) => name); /** * Obtain remaining metadata: * - Ledger Stax/Europa custom picture: number of blocks taken in storage * - Installed language pack * - Device name * */ // Stax/Europa specific, account for the size of the CLS for the storage bar. let customImageBlocks = 0; if (isCustomLockScreenSupported(deviceModelId) && !deviceInfo.isRecoveryMode) { const customImageSize = await customLockScreenFetchSize(transport); if (customImageSize) { customImageBlocks = Math.ceil(customImageSize / bytesPerBlock); } } const languageId: number = deviceInfo.languageId || languageIds.english; const installedLanguagePack = languages.find( lang => lang.language === idsToLanguage[languageId], ); // Will not prompt user since we've allowed secure channel already. const deviceName = await getDeviceName(transport); const result: ListAppsResult = { appByName, appsListNames, deviceInfo, deviceModelId, installed, installedAvailable: !!installedList, installedLanguagePack, // Not strictly listApps content. firmware, customImageBlocks, deviceName, }; o.next({ type: "result", result, }); } main().then( function onfulfilled() { o.complete(); }, function onrejected(e) { o.error(e); }, ); return () => { if (sub) sub.unsubscribe(); }; }); };