UNPKG

@ledgerhq/hw-transport

Version:

Ledger Hardware Wallet common interface of the communication layer

267 lines (233 loc) • 9.63 kB
import Transport, { type DescriptorEvent, type Observer, type Subscription, StatusCodes, } from "./Transport"; import { TransportRaceCondition } from "./errors"; type Options = { abortTimeoutMs?: number }; type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void }; const noop = () => {}; const sw = (code: number) => Buffer.from([code >> 8, code & 0xff]); const OK = sw(StatusCodes.OK); const flush = () => new Promise(resolve => setTimeout(resolve, 0)); const observer = () => ({ next: jest.fn(), error: jest.fn(), complete: jest.fn() }); const counts = (o: ReturnType<typeof observer>) => [o.next, o.complete, o.error].map(f => f.mock.calls.length); function deferred<T>(): Deferred<T> { let resolve: (v: T) => void = noop; let reject: (e: unknown) => void = noop; const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } class FakeTransport extends Transport { calls: Array<[number[], Options]> = []; reply: () => Promise<Buffer> = () => Promise.resolve(OK); override exchange(apdu: Buffer, options: Options = {}): Promise<Buffer> { this.calls.push([[...apdu], options]); return this.reply(); } } describe("Transport", () => { it("exposes defaults, tracing and base implementations", async () => { const transport = new Transport({ context: { a: 1 }, logType: "custom" }); const warn = jest.spyOn(console, "warn").mockImplementation(noop); const listener = jest.fn(); expect(() => transport.exchange(Buffer.alloc(0))).toThrow("exchange not implemented"); expect(transport.setScrambleKey("B0L0")).toBeUndefined(); transport.setDebugMode(); transport.updateTraceContext({ b: 2 }); transport.on("e", listener); transport.emit("e", 42); transport.off("e", listener); transport.emit("e", 43); expect([ transport.exchangeTimeout, transport.unresponsiveTimeout, transport.deviceModel, ]).toEqual([30000, 15000, null]); expect([transport.tracer.getType(), new Transport().tracer.getType()]).toEqual([ "custom", "transport", ]); expect(transport.getTraceContext()).toEqual({ a: 1, b: 2 }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("setDebugMode is deprecated")); expect(listener.mock.calls).toEqual([[42]]); transport.setExchangeTimeout(1); transport.setExchangeUnresponsiveTimeout(2); transport.setTraceContext(); expect([ transport.exchangeTimeout, transport.unresponsiveTimeout, transport.getTraceContext(), ]).toEqual([1, 2, undefined]); await expect(transport.close()).resolves.toBeUndefined(); warn.mockRestore(); }); it("send assembles the apdu, forwards options and validates the status word", async () => { const transport = new FakeTransport(); transport.reply = () => Promise.resolve(sw(0x6a82)); await expect( transport.send(0xe0, 1, 2, 3, Buffer.from([0xaa]), [0x6a82], { abortTimeoutMs: 5 }), ).resolves.toEqual(sw(0x6a82)); await expect(transport.send(0xe0, 1, 2, 3)).rejects.toMatchObject({ name: "TransportStatusError", statusCode: 0x6a82, }); await expect(transport.send(0, 0, 0, 0, Buffer.alloc(256))).rejects.toMatchObject({ id: "DataLengthTooBig", message: "data.length exceed 256 bytes limit. Got: 256", }); expect(transport.calls).toEqual([ [[0xe0, 1, 2, 3, 1, 0xaa], { abortTimeoutMs: 5 }], [[0xe0, 1, 2, 3, 0], {}], ]); }); it.each<[string, Buffer, boolean, number[], unknown[]]>([ ["relays every response then completes", OK, false, [2, 1, 0], []], [ "stops on a bad status word", sw(0x6700), false, [0, 0, 1], [expect.objectContaining({ name: "TransportStatusError", statusCode: 0x6700 })], ], ["goes silent once unsubscribed", OK, true, [0, 0, 0], []], ])("exchangeBulk %s", async (_title, reply, unsubscribe, expected, errors) => { const transport = new FakeTransport(); transport.reply = () => Promise.resolve(reply); const o = observer(); const sub = transport.exchangeBulk([Buffer.from([1]), Buffer.from([2])], o); if (unsubscribe) sub.unsubscribe(); await flush(); expect(counts(o)).toEqual(expected); expect(o.error.mock.calls.flat()).toEqual(errors); expect(transport.calls.map(([apdu]) => apdu)).toEqual(expected[0] === 2 ? [[1], [2]] : [[1]]); }); it("decorates app API methods with a scramble key and a re-entrancy lock", async () => { const transport = new FakeTransport(); const setScrambleKey = jest.spyOn(transport, "setScrambleKey"); const pending = deferred<string>(); const api = { prefix: "app", slow: (): Promise<string> => pending.promise, greet(suffix: string): Promise<string> { return Promise.resolve(`${this.prefix}:${suffix}`); }, fail: (): Promise<string> => Promise.reject(new Error("boom")), }; transport.decorateAppAPIMethods(api, ["slow", "greet", "fail"], "BTC"); const first = api.slow(); expect(transport._appAPIlock).toBe("slow"); await expect(api.greet("hi")).rejects.toMatchObject({ id: "TransportLocked", message: "Ledger Device is busy (lock slow)", }); pending.resolve("done"); await expect(first).resolves.toBe("done"); await expect(api.greet("hi")).resolves.toBe("app:hi"); await expect(api.fail()).rejects.toThrow("boom"); expect([transport._appAPIlock, setScrambleKey.mock.calls.flat()]).toEqual([ null, ["BTC", "BTC", "BTC"], ]); }); describe("exchangeAtomicImpl", () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it("serialises exchanges and always releases the guard", async () => { const transport = new FakeTransport(); const job = deferred<string>(); const first = transport.exchangeAtomicImpl(() => job.promise); expect(transport.exchangeBusyPromise).toBeInstanceOf(Promise); await expect(transport.exchangeAtomicImpl(() => Promise.resolve("x"))).rejects.toThrow( TransportRaceCondition, ); job.resolve("a"); await expect(first).resolves.toBe("a"); await expect( transport.exchangeAtomicImpl(() => Promise.reject(new Error("boom"))), ).rejects.toThrow("boom"); expect(transport.exchangeBusyPromise).toBeNull(); }); it.each<[string, (job: Deferred<string>) => void, string[]]>([ ["responsive once the job resolves", job => job.resolve("v"), ["unresponsive", "responsive"]], ["nothing more when the job rejects", job => job.reject(new Error("e")), ["unresponsive"]], ])("emits unresponsive then %s", async (_title, settle, expected) => { const transport = new FakeTransport(); transport.setExchangeUnresponsiveTimeout(500); const events: string[] = []; transport.on("unresponsive", () => events.push("unresponsive")); transport.on("responsive", () => events.push("responsive")); const job = deferred<string>(); const run = transport.exchangeAtomicImpl(() => job.promise); jest.advanceTimersByTime(499); expect(events).toEqual([]); jest.advanceTimersByTime(1); settle(job); await run.catch(noop); expect(events).toEqual(expected); }); }); }); describe("Transport.create", () => { const unsubscribe = jest.fn(); const opened = new Transport(); let openCalls: unknown[][] = []; let listenImpl: (o: Observer<DescriptorEvent<string>>) => Subscription; let openImpl: (descriptor?: unknown, timeoutMs?: number) => Promise<Transport>; class Fake extends Transport { static readonly listen = (o: Observer<DescriptorEvent<string>>) => listenImpl(o); static readonly open = (descriptor?: unknown, timeoutMs?: number) => openImpl(descriptor, timeoutMs); } const emit = (fn: (o: Observer<DescriptorEvent<string>>) => void) => { listenImpl = o => { setTimeout(() => fn(o), 0); return { unsubscribe }; }; }; beforeEach(() => { unsubscribe.mockClear(); openCalls = []; listenImpl = () => ({ unsubscribe }); openImpl = (descriptor, timeoutMs) => { openCalls.push([descriptor, timeoutMs]); return Promise.resolve(opened); }; }); it.each([[undefined], [5000]])( "handles descriptor events (listenTimeout=%s)", async listenTimeout => { emit(o => { o.next({ type: "add", descriptor: "d1" }); o.complete(); }); await expect(Fake.create(1500, listenTimeout)).resolves.toBe(opened); expect([openCalls, unsubscribe.mock.calls.length]).toEqual([[["d1", 1500]], 1]); emit(o => o.complete()); await expect(Fake.create(1500, listenTimeout)).rejects.toMatchObject({ id: "NoDeviceFound", message: Transport.ErrorMessage_NoDeviceFound, }); const failure = new Error("listen failed"); emit(o => o.error(failure)); await expect(Fake.create(1500, listenTimeout)).rejects.toBe(failure); }, ); it("rejects when open fails", async () => { openImpl = () => Promise.reject(new Error("nope")); emit(o => o.next({ type: "add", descriptor: "d1" })); await expect(Fake.create()).rejects.toThrow("nope"); }); it("rejects with ListenTimeout when no descriptor shows up", async () => { await expect(Fake.create(3000, 1)).rejects.toMatchObject({ id: "ListenTimeout", message: Transport.ErrorMessage_ListenTimeout, }); expect(unsubscribe).toHaveBeenCalledTimes(1); }); });