@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
237 lines (199 loc) • 7.92 kB
text/typescript
import { Observable, from, of, throwError } from "rxjs";
import { catchError, concatMap, delay, mergeMap } from "rxjs/operators";
import {
DeviceOnDashboardExpected,
TransportError,
TransportStatusError,
StatusCodes,
} from "@ledgerhq/errors";
import { getDeviceModel } from "@ledgerhq/devices";
import { DeviceModelId } from "@ledgerhq/types-devices";
import { ungzip } from "pako";
import { withDevice } from "./deviceAccess";
import getDeviceInfo from "./getDeviceInfo";
import customLockScreenFetchHash from "./customLockScreenFetchHash";
import getAppAndVersion from "./getAppAndVersion";
import { isDashboardName } from "./isDashboardName";
import attemptToQuitApp, { AttemptToQuitAppEvent } from "./attemptToQuitApp";
import { ImageDoesNotExistOnDevice } from "../errors";
const MAX_APDU_SIZE = 240;
export type FetchImageEvent =
| AttemptToQuitAppEvent
| {
type: "progress";
progress: number;
}
| {
type: "currentImageHash";
imgHash: string;
}
| {
type: "imageFetched";
hexImage: string;
}
| {
type: "imageAlreadyBackedUp";
};
export type FetchImageRequest = {
backupHash?: string; // When provided, will skip the backup if it matches the hash.
allowedEmpty: boolean; // Complete instead of throwing if empty.
deviceModelId: DeviceModelId;
};
export type Input = {
deviceId: string;
deviceName: string | null;
request: FetchImageRequest;
};
export default function fetchImage({
deviceId,
deviceName,
request,
}: Input): Observable<FetchImageEvent> {
const { backupHash, allowedEmpty = false, deviceModelId } = request;
const sub = withDevice(
deviceId,
deviceName ? { matchDeviceByName: deviceName } : undefined,
)(
transport =>
new Observable(subscriber => {
const timeoutSub = of<FetchImageEvent>({
type: "unresponsiveDevice",
})
.pipe(delay(1000))
.subscribe(e => subscriber.next(e));
const sub = from(getDeviceInfo(transport))
.pipe(
mergeMap(async () => {
timeoutSub.unsubscribe();
// Fetch the image hash from the device
const imgHash = await customLockScreenFetchHash(transport);
subscriber.next({ type: "currentImageHash", imgHash });
// We don't have an image to backup
if (imgHash === "") {
if (allowedEmpty) {
subscriber.complete();
return;
} else {
return subscriber.error(
new ImageDoesNotExistOnDevice(undefined, {
productName: getDeviceModel(deviceModelId).productName,
}),
);
}
} else if (backupHash === imgHash) {
subscriber.next({ type: "imageAlreadyBackedUp" });
subscriber.complete();
return;
}
// If we are here, either we didn't provide a backupHash or we are
// not up to date with the device, in either case, continue to fetch
const imageLengthResponse = await transport.send(0xe0, 0x64, 0x00, 0x00);
const imageLengthStatus = imageLengthResponse.readUInt16BE(
imageLengthResponse.length - 2,
);
if (imageLengthStatus !== StatusCodes.OK) {
// this answer success even when no image is set, but the length of the image is 0
return subscriber.error(
new TransportError("Unexpected device response", imageLengthStatus.toString(16)),
);
}
const imageLength = imageLengthResponse.readUInt32BE(0);
if (imageLength === 0) {
// It should never happen since we fetched the hash earlier but hey.
return subscriber.error(
new ImageDoesNotExistOnDevice(undefined, {
productName: getDeviceModel(deviceModelId).productName,
}),
);
}
let imageBuffer = Buffer.from([]);
let currentOffset = 0;
while (currentOffset < imageLength) {
subscriber.next({
type: "progress",
progress: (currentOffset + 1) / imageLength,
});
// 253 bytes for data, 2 for status
const chunkSize = Math.min(MAX_APDU_SIZE - 2, imageLength - currentOffset);
const chunkRequest = Buffer.alloc(5);
chunkRequest.writeUInt32BE(currentOffset);
chunkRequest.writeUInt8(chunkSize, 4);
const imageChunk = await transport.send(0xe0, 0x65, 0x00, 0x00, chunkRequest);
const chunkStatus = imageChunk.readUInt16BE(imageChunk.length - 2);
if (chunkStatus !== StatusCodes.OK) {
// TODO: map all proper errors
return subscriber.error(
new TransportError("Unexpected device response", chunkStatus.toString(16)),
);
}
imageBuffer = Buffer.concat([
imageBuffer,
imageChunk.slice(0, imageChunk.length - 2),
]);
currentOffset += chunkSize;
}
const hexImage = await parseCustomLockScreenImageFormat(imageBuffer);
subscriber.next({ type: "imageFetched", hexImage });
subscriber.complete();
}),
catchError((e: unknown) => {
if (
e instanceof DeviceOnDashboardExpected ||
(e &&
e instanceof TransportStatusError &&
[0x6e00, 0x6d00, 0x6e01, 0x6d01, 0x6d02].includes(e.statusCode))
) {
return from(getAppAndVersion(transport)).pipe(
concatMap(appAndVersion => {
return !isDashboardName(appAndVersion.name)
? attemptToQuitApp(transport, appAndVersion)
: of<FetchImageEvent>({
type: "appDetected",
});
}),
);
}
return throwError(() => e);
}),
)
.subscribe(subscriber);
return () => {
timeoutSub.unsubscribe();
sub.unsubscribe();
};
}),
);
return sub as Observable<FetchImageEvent>;
}
// transforms from a Custom Lock Screen binary image format to an LLM hex string format
const parseCustomLockScreenImageFormat: (
staxImageBuffer: Buffer,
) => Promise<string> = async staxImageBuffer => {
// const width = staxImageBuffer.readUint16LE(0); // always 400
// const height = staxImageBuffer.readUint16LE(2); // always 672
const bppCompressionByte = staxImageBuffer.readUInt8(4);
// const bpp = bppCompressionByte >> 4; // always 2
const compression = bppCompressionByte & 0x0f;
const dataLengthBuffer = Buffer.from([
staxImageBuffer.readUInt8(5),
staxImageBuffer.readUInt8(6),
staxImageBuffer.readUInt8(7),
0x00,
]);
const dataLength = dataLengthBuffer.readUInt32LE();
const imageData = staxImageBuffer.slice(8);
if (compression === 0) {
return imageData.toString("hex");
}
let uncompressedImageData = Buffer.from([]);
let offset = 0;
while (offset < dataLength) {
const currentChunkSize = imageData.readUInt16LE(offset);
offset += 2;
const chunk = imageData.slice(offset, offset + currentChunkSize);
const uncompressedChunk = await ungzip(chunk);
uncompressedImageData = Buffer.concat([uncompressedImageData, uncompressedChunk]);
offset += currentChunkSize;
}
return uncompressedImageData.toString("hex");
};