1 | import EventEmitter from "events";
|
2 | import type { DeviceModel } from "@ledgerhq/devices";
|
3 | import {
|
4 | TransportPendingOperation,
|
5 | TransportError,
|
6 | StatusCodes,
|
7 | getAltStatusMessage,
|
8 | TransportStatusError,
|
9 | } from "@ledgerhq/errors";
|
10 | import { LocalTracer, TraceContext, LogType } from "@ledgerhq/logs";
|
11 | export { TransportError, TransportStatusError, StatusCodes, getAltStatusMessage };
|
12 |
|
13 | const DEFAULT_LOG_TYPE = "transport";
|
14 |
|
15 | /**
|
16 | */
|
17 | export type Subscription = {
|
18 | unsubscribe: () => void;
|
19 | };
|
20 |
|
21 | /**
|
22 | */
|
23 | export type Device = any; // Should be a union type of all possible Device object's shape
|
24 |
|
25 | export type DescriptorEventType = "add" | "remove";
|
26 | /**
|
27 | * A "descriptor" is a parameter that is specific to the implementation, and can be an ID, file path, or URL.
|
28 | * type: add or remove event
|
29 | * descriptor: a parameter that can be passed to open(descriptor)
|
30 | * deviceModel: device info on the model (is it a nano s, nano x, ...)
|
31 | * device: transport specific device info
|
32 | */
|
33 | export interface DescriptorEvent<Descriptor> {
|
34 | type: DescriptorEventType;
|
35 | descriptor: Descriptor;
|
36 | deviceModel?: DeviceModel | null | undefined;
|
37 | device?: Device;
|
38 | }
|
39 |
|
40 | /**
|
41 | * Observer generic type, following the Observer pattern
|
42 | */
|
43 | export type Observer<EventType, EventError = unknown> = Readonly<{
|
44 | next: (event: EventType) => unknown;
|
45 | error: (e: EventError) => unknown;
|
46 | complete: () => unknown;
|
47 | }>;
|
48 |
|
49 | /**
|
50 | * The Transport class defines a generic interface for communicating with a Ledger hardware wallet.
|
51 | * There are different kind of transports based on the technology (channels like U2F, HID, Bluetooth, Webusb) and environment (Node, Web,...).
|
52 | * It is an abstract class that needs to be implemented.
|
53 | */
|
54 | export default class Transport {
|
55 | exchangeTimeout = 30000;
|
56 | unresponsiveTimeout = 15000;
|
57 | deviceModel: DeviceModel | null | undefined = null;
|
58 | tracer: LocalTracer;
|
59 |
|
60 | constructor({ context, logType }: { context?: TraceContext; logType?: LogType } = {}) {
|
61 | this.tracer = new LocalTracer(logType ?? DEFAULT_LOG_TYPE, context);
|
62 | }
|
63 |
|
64 | /**
|
65 | * Check if the transport is supported on the current platform/browser.
|
66 | * @returns {Promise<boolean>} A promise that resolves with a boolean indicating support.
|
67 | */
|
68 | static readonly isSupported: () => Promise<boolean>;
|
69 |
|
70 | /**
|
71 | * List all available descriptors for the transport.
|
72 | * For a better granularity, checkout `listen()`.
|
73 | *
|
74 | * @returns {Promise<Array<any>>} A promise that resolves with an array of descriptors.
|
75 | * @example
|
76 | * TransportFoo.list().then(descriptors => ...)
|
77 | */
|
78 | static readonly list: () => Promise<Array<any>>;
|
79 |
|
80 | /**
|
81 | * Listen for device events for the transport. The method takes an observer of DescriptorEvent and returns a Subscription.
|
82 | * A DescriptorEvent is an object containing a "descriptor" and a "type" field. The "type" field can be "add" or "remove", and the "descriptor" field can be passed to the "open" method.
|
83 | * The "listen" method will first emit all currently connected devices and then will emit events as they occur, such as when a USB device is plugged in or a Bluetooth device becomes discoverable.
|
84 | * @param {Observer<DescriptorEvent<any>>} observer - An object with "next", "error", and "complete" functions, following the observer pattern.
|
85 | * @returns {Subscription} A Subscription object on which you can call ".unsubscribe()" to stop listening to descriptors.
|
86 | * @example
|
87 | const sub = TransportFoo.listen({
|
88 | next: e => {
|
89 | if (e.type==="add") {
|
90 | sub.unsubscribe();
|
91 | const transport = await TransportFoo.open(e.descriptor);
|
92 | ...
|
93 | }
|
94 | },
|
95 | error: error => {},
|
96 | complete: () => {}
|
97 | })
|
98 | */
|
99 | static readonly listen: (observer: Observer<DescriptorEvent<any>>) => Subscription;
|
100 |
|
101 | /**
|
102 | * Attempt to create a Transport instance with a specific descriptor.
|
103 | * @param {any} descriptor - The descriptor to open the transport with.
|
104 | * @param {number} timeout - An optional timeout for the transport connection.
|
105 | * @param {TraceContext} context Optional tracing/log context
|
106 | * @returns {Promise<Transport>} A promise that resolves with a Transport instance.
|
107 | * @example
|
108 | TransportFoo.open(descriptor).then(transport => ...)
|
109 | */
|
110 | static readonly open: (
|
111 | descriptor?: any,
|
112 | timeoutMs?: number,
|
113 | context?: TraceContext,
|
114 | ) => Promise<Transport>;
|
115 |
|
116 | /**
|
117 | * Send data to the device using a low level API.
|
118 | * It's recommended to use the "send" method for a higher level API.
|
119 | * @param {Buffer} apdu - The data to send.
|
120 | * @param {Object} options - Contains optional options for the exchange function
|
121 | * - abortTimeoutMs: stop the exchange after a given timeout. Another timeout exists
|
122 | * to detect unresponsive device (see `unresponsiveTimeout`). This timeout aborts the exchange.
|
123 | * @returns {Promise<Buffer>} A promise that resolves with the response data from the device.
|
124 | */
|
125 | exchange(
|
126 | _apdu: Buffer,
|
127 | { abortTimeoutMs: _abortTimeoutMs }: { abortTimeoutMs?: number } = {},
|
128 | ): Promise<Buffer> {
|
129 | throw new Error("exchange not implemented");
|
130 | }
|
131 |
|
132 | /**
|
133 | * Send apdus in batch to the device using a low level API.
|
134 | * The default implementation is to call exchange for each apdu.
|
135 | * @param {Array<Buffer>} apdus - array of apdus to send.
|
136 | * @param {Observer<Buffer>} observer - an observer that will receive the response of each apdu.
|
137 | * @returns {Subscription} A Subscription object on which you can call ".unsubscribe()" to stop sending apdus.
|
138 | */
|
139 | exchangeBulk(apdus: Buffer[], observer: Observer<Buffer>): Subscription {
|
140 | let unsubscribed = false;
|
141 | const unsubscribe = () => {
|
142 | unsubscribed = true;
|
143 | };
|
144 |
|
145 | const main = async () => {
|
146 | if (unsubscribed) return;
|
147 | for (const apdu of apdus) {
|
148 | const r = await this.exchange(apdu);
|
149 | if (unsubscribed) return;
|
150 | const status = r.readUInt16BE(r.length - 2);
|
151 | if (status !== StatusCodes.OK) {
|
152 | throw new TransportStatusError(status);
|
153 | }
|
154 | observer.next(r);
|
155 | }
|
156 | };
|
157 |
|
158 | main().then(
|
159 | () => !unsubscribed && observer.complete(),
|
160 | e => !unsubscribed && observer.error(e),
|
161 | );
|
162 |
|
163 | return { unsubscribe };
|
164 | }
|
165 |
|
166 | /**
|
167 | * Set the "scramble key" for the next data exchanges with the device.
|
168 | * Each app can have a different scramble key and it is set internally during instantiation.
|
169 | * @param {string} key - The scramble key to set.
|
170 | * deprecated This method is no longer needed for modern transports and should be migrated away from.
|
171 | * no @ before deprecated as it breaks documentationjs on version 14.0.2
|
172 | * https://github.com/documentationjs/documentation/issues/1596
|
173 | */
|
174 | setScrambleKey(_key: string) {}
|
175 |
|
176 | /**
|
177 | * Close the connection with the device.
|
178 | *
|
179 | * Note: for certain transports (hw-transport-node-hid-singleton for ex), once the promise resolved,
|
180 | * the transport instance is actually still cached, and the device is disconnected only after a defined timeout.
|
181 | * But for the consumer of the Transport, this does not matter and it can consider the transport to be closed.
|
182 | *
|
183 | * @returns {Promise<void>} A promise that resolves when the transport is closed.
|
184 | */
|
185 | close(): Promise<void> {
|
186 | return Promise.resolve();
|
187 | }
|
188 |
|
189 | _events = new EventEmitter();
|
190 |
|
191 | /**
|
192 | * Listen for an event on the transport instance.
|
193 | * Transport implementations may have specific events. Common events include:
|
194 | * "disconnect" : triggered when the transport is disconnected.
|
195 | * @param {string} eventName - The name of the event to listen for.
|
196 | * @param {(...args: Array<any>) => any} cb - The callback function to be invoked when the event occurs.
|
197 | */
|
198 | on(eventName: string, cb: (...args: Array<any>) => any): void {
|
199 | this._events.on(eventName, cb);
|
200 | }
|
201 |
|
202 | /**
|
203 | * Stop listening to an event on an instance of transport.
|
204 | */
|
205 | off(eventName: string, cb: (...args: Array<any>) => any): void {
|
206 | this._events.removeListener(eventName, cb);
|
207 | }
|
208 |
|
209 | emit(event: string, ...args: any): void {
|
210 | this._events.emit(event, ...args);
|
211 | }
|
212 |
|
213 | /**
|
214 | * Enable or not logs of the binary exchange
|
215 | */
|
216 | setDebugMode() {
|
217 | console.warn(
|
218 | "setDebugMode is deprecated. use @ledgerhq/logs instead. No logs are emitted in this anymore.",
|
219 | );
|
220 | }
|
221 |
|
222 | /**
|
223 | * Set a timeout (in milliseconds) for the exchange call. Only some transport might implement it. (e.g. U2F)
|
224 | */
|
225 | setExchangeTimeout(exchangeTimeout: number): void {
|
226 | this.exchangeTimeout = exchangeTimeout;
|
227 | }
|
228 |
|
229 | /**
|
230 | * Define the delay before emitting "unresponsive" on an exchange that does not respond
|
231 | */
|
232 | setExchangeUnresponsiveTimeout(unresponsiveTimeout: number): void {
|
233 | this.unresponsiveTimeout = unresponsiveTimeout;
|
234 | }
|
235 |
|
236 | /**
|
237 | * Send data to the device using the higher level API.
|
238 | *
|
239 | * @param {number} cla - The instruction class for the command.
|
240 | * @param {number} ins - The instruction code for the command.
|
241 | * @param {number} p1 - The first parameter for the instruction.
|
242 | * @param {number} p2 - The second parameter for the instruction.
|
243 | * @param {Buffer} data - The data to be sent. Defaults to an empty buffer.
|
244 | * @param {Array<number>} statusList - A list of acceptable status codes for the response. Defaults to [StatusCodes.OK].
|
245 | * @param {Object} options - Contains optional options for the exchange function
|
246 | * - abortTimeoutMs: stop the send after a given timeout. Another timeout exists
|
247 | * to detect unresponsive device (see `unresponsiveTimeout`). This timeout aborts the exchange.
|
248 | * @returns {Promise<Buffer>} A promise that resolves with the response data from the device.
|
249 | */
|
250 | send = async (
|
251 | cla: number,
|
252 | ins: number,
|
253 | p1: number,
|
254 | p2: number,
|
255 | data: Buffer = Buffer.alloc(0),
|
256 | statusList: Array<number> = [StatusCodes.OK],
|
257 | { abortTimeoutMs }: { abortTimeoutMs?: number } = {},
|
258 | ): Promise<Buffer> => {
|
259 | const tracer = this.tracer.withUpdatedContext({ function: "send" });
|
260 |
|
261 | if (data.length >= 256) {
|
262 | tracer.trace("data.length exceeded 256 bytes limit", { dataLength: data.length });
|
263 | throw new TransportError(
|
264 | "data.length exceed 256 bytes limit. Got: " + data.length,
|
265 | "DataLengthTooBig",
|
266 | );
|
267 | }
|
268 |
|
269 | tracer.trace("Starting an exchange", { abortTimeoutMs });
|
270 | const response = await this.exchange(
|
271 | // The size of the data is added in 1 byte just before `data`
|
272 | Buffer.concat([Buffer.from([cla, ins, p1, p2]), Buffer.from([data.length]), data]),
|
273 | { abortTimeoutMs },
|
274 | );
|
275 | tracer.trace("Received response from exchange");
|
276 | const sw = response.readUInt16BE(response.length - 2);
|
277 |
|
278 | if (!statusList.some(s => s === sw)) {
|
279 | throw new TransportStatusError(sw);
|
280 | }
|
281 |
|
282 | return response;
|
283 | };
|
284 |
|
285 | /**
|
286 | * create() allows to open the first descriptor available or
|
287 | * throw if there is none or if timeout is reached.
|
288 | * This is a light helper, alternative to using listen() and open() (that you may need for any more advanced usecase)
|
289 | * @example
|
290 | TransportFoo.create().then(transport => ...)
|
291 | */
|
292 | static create(openTimeout = 3000, listenTimeout?: number): Promise<Transport> {
|
293 | return new Promise((resolve, reject) => {
|
294 | let found = false;
|
295 | const sub = this.listen({
|
296 | next: e => {
|
297 | found = true;
|
298 | if (sub) sub.unsubscribe();
|
299 | if (listenTimeoutId) clearTimeout(listenTimeoutId);
|
300 | this.open(e.descriptor, openTimeout).then(resolve, reject);
|
301 | },
|
302 | error: e => {
|
303 | if (listenTimeoutId) clearTimeout(listenTimeoutId);
|
304 | reject(e);
|
305 | },
|
306 | complete: () => {
|
307 | if (listenTimeoutId) clearTimeout(listenTimeoutId);
|
308 |
|
309 | if (!found) {
|
310 | reject(new TransportError(this.ErrorMessage_NoDeviceFound, "NoDeviceFound"));
|
311 | }
|
312 | },
|
313 | });
|
314 | const listenTimeoutId = listenTimeout
|
315 | ? setTimeout(() => {
|
316 | sub.unsubscribe();
|
317 | reject(new TransportError(this.ErrorMessage_ListenTimeout, "ListenTimeout"));
|
318 | }, listenTimeout)
|
319 | : null;
|
320 | });
|
321 | }
|
322 |
|
323 | // Blocks other exchange to happen concurrently
|
324 | exchangeBusyPromise: Promise<void> | null | undefined;
|
325 |
|
326 | /**
|
327 | * Wrapper to make an exchange "atomic" (blocking any other exchange)
|
328 | *
|
329 | * It also handles "unresponsiveness" by emitting "unresponsive" and "responsive" events.
|
330 | *
|
331 | * @param f The exchange job, using the transport to run
|
332 | * @returns a Promise resolving with the output of the given job
|
333 | */
|
334 | async exchangeAtomicImpl<Output>(f: () => Promise<Output>): Promise<Output> {
|
335 | const tracer = this.tracer.withUpdatedContext({
|
336 | function: "exchangeAtomicImpl",
|
337 | unresponsiveTimeout: this.unresponsiveTimeout,
|
338 | });
|
339 |
|
340 | if (this.exchangeBusyPromise) {
|
341 | tracer.trace("Atomic exchange is already busy");
|
342 | throw new TransportPendingOperation(
|
343 | "An action was already pending on the Ledger device. Please deny or reconnect.",
|
344 | );
|
345 | }
|
346 |
|
347 | // Sets the atomic guard
|
348 | let resolveBusy;
|
349 | const busyPromise: Promise<void> = new Promise(r => {
|
350 | resolveBusy = r;
|
351 | });
|
352 | this.exchangeBusyPromise = busyPromise;
|
353 |
|
354 | // The device unresponsiveness handler
|
355 | let unresponsiveReached = false;
|
356 | const timeout = setTimeout(() => {
|
357 | tracer.trace(`Timeout reached, emitting Transport event "unresponsive"`, {
|
358 | unresponsiveTimeout: this.unresponsiveTimeout,
|
359 | });
|
360 | unresponsiveReached = true;
|
361 | this.emit("unresponsive");
|
362 | }, this.unresponsiveTimeout);
|
363 |
|
364 | try {
|
365 | const res = await f();
|
366 |
|
367 | if (unresponsiveReached) {
|
368 | tracer.trace("Device was unresponsive, emitting responsive");
|
369 | this.emit("responsive");
|
370 | }
|
371 |
|
372 | return res;
|
373 | } finally {
|
374 | tracer.trace("Finalize, clearing busy guard");
|
375 |
|
376 | clearTimeout(timeout);
|
377 | if (resolveBusy) resolveBusy();
|
378 | this.exchangeBusyPromise = null;
|
379 | }
|
380 | }
|
381 |
|
382 | decorateAppAPIMethods(self: Record<string, any>, methods: Array<string>, scrambleKey: string) {
|
383 | for (const methodName of methods) {
|
384 | self[methodName] = this.decorateAppAPIMethod(methodName, self[methodName], self, scrambleKey);
|
385 | }
|
386 | }
|
387 |
|
388 | _appAPIlock: string | null = null;
|
389 |
|
390 | decorateAppAPIMethod<R, A extends any[]>(
|
391 | methodName: string,
|
392 | f: (...args: A) => Promise<R>,
|
393 | ctx: any,
|
394 | scrambleKey: string,
|
395 | ): (...args: A) => Promise<R> {
|
396 | return async (...args) => {
|
397 | const { _appAPIlock } = this;
|
398 |
|
399 | if (_appAPIlock) {
|
400 | return Promise.reject(
|
401 | new TransportError("Ledger Device is busy (lock " + _appAPIlock + ")", "TransportLocked"),
|
402 | );
|
403 | }
|
404 |
|
405 | try {
|
406 | this._appAPIlock = methodName;
|
407 | this.setScrambleKey(scrambleKey);
|
408 | return await f.apply(ctx, args);
|
409 | } finally {
|
410 | this._appAPIlock = null;
|
411 | }
|
412 | };
|
413 | }
|
414 |
|
415 | /**
|
416 | * Sets the context used by the logging/tracing mechanism
|
417 | *
|
418 | * Useful when re-using (cached) the same Transport instance,
|
419 | * but with a new tracing context.
|
420 | *
|
421 | * @param context A TraceContext, that can undefined to reset the context
|
422 | */
|
423 | setTraceContext(context?: TraceContext) {
|
424 | this.tracer = this.tracer.withContext(context);
|
425 | }
|
426 |
|
427 | /**
|
428 | * Updates the context used by the logging/tracing mechanism
|
429 | *
|
430 | * The update only overrides the key-value that are already defined in the current context.
|
431 | *
|
432 | * @param contextToAdd A TraceContext that will be added to the current context
|
433 | */
|
434 | updateTraceContext(contextToAdd: TraceContext) {
|
435 | this.tracer.updateContext(contextToAdd);
|
436 | }
|
437 |
|
438 | /**
|
439 | * Gets the tracing context of the transport instance
|
440 | */
|
441 | getTraceContext(): TraceContext | undefined {
|
442 | return this.tracer.getContext();
|
443 | }
|
444 |
|
445 | static ErrorMessage_ListenTimeout = "No Ledger device found (timeout)";
|
446 | static ErrorMessage_NoDeviceFound = "No Ledger device found";
|
447 | }
|