UNPKG

15.8 kBPlain TextView Raw
1import EventEmitter from "events";
2import type { DeviceModel } from "@ledgerhq/devices";
3import {
4 TransportPendingOperation,
5 TransportError,
6 StatusCodes,
7 getAltStatusMessage,
8 TransportStatusError,
9} from "@ledgerhq/errors";
10import { LocalTracer, TraceContext, LogType } from "@ledgerhq/logs";
11export { TransportError, TransportStatusError, StatusCodes, getAltStatusMessage };
12
13const DEFAULT_LOG_TYPE = "transport";
14
15/**
16 */
17export type Subscription = {
18 unsubscribe: () => void;
19};
20
21/**
22 */
23export type Device = any; // Should be a union type of all possible Device object's shape
24
25export 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 */
33export 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 */
43export 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 */
54export 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}