@ledgerhq/speculos-transport 0.1.8-fix-build-number-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +33 -0
- package/.turbo/turbo-build.log +4 -0
- package/.unimportedrc.json +4 -0
- package/CHANGELOG.md +132 -0
- package/LICENSE.txt +21 -0
- package/README.md +38 -0
- package/lib/index.d.ts +69 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +280 -0
- package/lib/index.js.map +1 -0
- package/lib-es/index.d.ts +69 -0
- package/lib-es/index.d.ts.map +1 -0
- package/lib-es/index.js +270 -0
- package/lib-es/index.js.map +1 -0
- package/package.json +59 -0
- package/src/index.ts +340 -0
- package/tsconfig.json +12 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn, exec, ChildProcessWithoutNullStreams } from "child_process";
|
|
2
|
+
import { log } from "@ledgerhq/logs";
|
|
3
|
+
import { DeviceModelId } from "@ledgerhq/devices";
|
|
4
|
+
import SpeculosTransportHttp from "@ledgerhq/hw-transport-node-speculos-http";
|
|
5
|
+
import SpeculosTransportWebsocket from "@ledgerhq/hw-transport-node-speculos";
|
|
6
|
+
import { getEnv } from "@ledgerhq/live-env";
|
|
7
|
+
import { delay } from "@ledgerhq/live-promise";
|
|
8
|
+
|
|
9
|
+
export type SpeculosDevice = {
|
|
10
|
+
transport: SpeculosTransport;
|
|
11
|
+
id: string;
|
|
12
|
+
appPath: string;
|
|
13
|
+
ports: ReturnType<typeof getPorts>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SpeculosTransport = SpeculosTransportHttp | SpeculosTransportWebsocket;
|
|
17
|
+
|
|
18
|
+
export { DeviceModelId };
|
|
19
|
+
|
|
20
|
+
export type SpeculosDeviceInternal =
|
|
21
|
+
| {
|
|
22
|
+
process: ChildProcessWithoutNullStreams;
|
|
23
|
+
apduPort: number;
|
|
24
|
+
buttonPort: number;
|
|
25
|
+
automationPort: number;
|
|
26
|
+
transport: SpeculosTransportWebsocket;
|
|
27
|
+
destroy: () => void;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
process: ChildProcessWithoutNullStreams;
|
|
31
|
+
apiPort: string | undefined;
|
|
32
|
+
transport: SpeculosTransportHttp;
|
|
33
|
+
destroy: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// FIXME we need to figure out a better system, using a filesystem file?
|
|
37
|
+
let idCounter: number;
|
|
38
|
+
const isSpeculosWebsocket = getEnv("SPECULOS_USE_WEBSOCKET");
|
|
39
|
+
const data: Record<string, SpeculosDeviceInternal | undefined> = {};
|
|
40
|
+
|
|
41
|
+
export function getMemorySpeculosDeviceInternal(id: string): SpeculosDeviceInternal | undefined {
|
|
42
|
+
return data[id];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const modelMap: Record<string, DeviceModelId> = {
|
|
46
|
+
nanos: DeviceModelId.nanoS,
|
|
47
|
+
"nanos+": DeviceModelId.nanoSP,
|
|
48
|
+
nanox: DeviceModelId.nanoX,
|
|
49
|
+
blue: DeviceModelId.blue,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const reverseModelMap: Record<string, string> = {};
|
|
53
|
+
for (const k in modelMap) {
|
|
54
|
+
reverseModelMap[modelMap[k]] = k;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Release a speculos device
|
|
58
|
+
*/
|
|
59
|
+
export async function releaseSpeculosDevice(id: string) {
|
|
60
|
+
log("speculos", "release " + id);
|
|
61
|
+
const obj = data[id];
|
|
62
|
+
|
|
63
|
+
if (obj) {
|
|
64
|
+
await obj.destroy();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Close all speculos devices
|
|
70
|
+
*/
|
|
71
|
+
export function closeAllSpeculosDevices() {
|
|
72
|
+
return Promise.all(Object.keys(data).map(releaseSpeculosDevice));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// to keep in sync from https://github.com/LedgerHQ/speculos/tree/master/speculos/cxlib
|
|
76
|
+
const existingSdks = [
|
|
77
|
+
"nanos-cx-2.0.elf",
|
|
78
|
+
"nanos-cx-2.1.elf",
|
|
79
|
+
"nanosp-cx-1.0.3.elf",
|
|
80
|
+
"nanosp-cx-1.0.elf",
|
|
81
|
+
"nanox-cx-2.0.2.elf",
|
|
82
|
+
"nanox-cx-2.0.elf",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function inferSDK(firmware: string, model: string): string | undefined {
|
|
86
|
+
const begin = `${model.toLowerCase()}-cx-`;
|
|
87
|
+
if (existingSdks.includes(begin + firmware + ".elf")) return firmware;
|
|
88
|
+
const shortVersion = firmware.slice(0, 3);
|
|
89
|
+
if (existingSdks.includes(begin + shortVersion + ".elf")) return shortVersion;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const getPorts = (idCounter: number, isSpeculosWebsocket?: boolean) => {
|
|
93
|
+
if (isSpeculosWebsocket) {
|
|
94
|
+
const apduPort = 30000 + idCounter;
|
|
95
|
+
const vncPort = 35000 + idCounter;
|
|
96
|
+
const buttonPort = 40000 + idCounter;
|
|
97
|
+
const automationPort = 45000 + idCounter;
|
|
98
|
+
|
|
99
|
+
return { apduPort, vncPort, buttonPort, automationPort };
|
|
100
|
+
} else {
|
|
101
|
+
const apiPort = 30000 + idCounter;
|
|
102
|
+
const vncPort = 35000 + idCounter;
|
|
103
|
+
|
|
104
|
+
return { apiPort, vncPort };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function conventionalAppSubpath(
|
|
109
|
+
model: DeviceModelId,
|
|
110
|
+
firmware: string,
|
|
111
|
+
appName: string,
|
|
112
|
+
appVersion: string,
|
|
113
|
+
) {
|
|
114
|
+
return `${reverseModelMap[model]}/${firmware}/${appName.replace(/ /g, "")}/app_${appVersion}.elf`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Dependency {
|
|
118
|
+
name: string;
|
|
119
|
+
appVersion?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* instanciate a speculos device that runs through docker
|
|
124
|
+
*/
|
|
125
|
+
export async function createSpeculosDevice(
|
|
126
|
+
arg: {
|
|
127
|
+
model: DeviceModelId;
|
|
128
|
+
firmware: string;
|
|
129
|
+
appName: string;
|
|
130
|
+
appVersion: string;
|
|
131
|
+
dependency?: string;
|
|
132
|
+
dependencies?: Dependency[];
|
|
133
|
+
seed: string;
|
|
134
|
+
// Root folder from which you need to lookup app binaries
|
|
135
|
+
coinapps: string;
|
|
136
|
+
// if you want to force a specific app path
|
|
137
|
+
overridesAppPath?: string;
|
|
138
|
+
onSpeculosDeviceCreated?: (device: SpeculosDevice) => Promise<void>;
|
|
139
|
+
},
|
|
140
|
+
maxRetry = 3,
|
|
141
|
+
): Promise<SpeculosDevice> {
|
|
142
|
+
const {
|
|
143
|
+
overridesAppPath,
|
|
144
|
+
model,
|
|
145
|
+
firmware,
|
|
146
|
+
appName,
|
|
147
|
+
appVersion,
|
|
148
|
+
seed,
|
|
149
|
+
coinapps,
|
|
150
|
+
dependency,
|
|
151
|
+
dependencies,
|
|
152
|
+
} = arg;
|
|
153
|
+
idCounter = idCounter ?? getEnv("SPECULOS_PID_OFFSET");
|
|
154
|
+
const speculosID = `speculosID-${++idCounter}`;
|
|
155
|
+
const ports = getPorts(idCounter, isSpeculosWebsocket);
|
|
156
|
+
|
|
157
|
+
const sdk = inferSDK(firmware, model);
|
|
158
|
+
|
|
159
|
+
const subpath = overridesAppPath || conventionalAppSubpath(model, firmware, appName, appVersion);
|
|
160
|
+
const appPath = `./apps/${subpath}`;
|
|
161
|
+
|
|
162
|
+
const params = [
|
|
163
|
+
"run",
|
|
164
|
+
"-v",
|
|
165
|
+
`${coinapps}:/speculos/apps`,
|
|
166
|
+
...(isSpeculosWebsocket
|
|
167
|
+
? [
|
|
168
|
+
// websocket ports
|
|
169
|
+
"-p",
|
|
170
|
+
`${ports.apduPort}:40000`,
|
|
171
|
+
"-p",
|
|
172
|
+
`${ports.vncPort}:41000`,
|
|
173
|
+
"-p",
|
|
174
|
+
`${ports.buttonPort}:42000`,
|
|
175
|
+
"-p",
|
|
176
|
+
`${ports.automationPort}:43000`,
|
|
177
|
+
]
|
|
178
|
+
: [
|
|
179
|
+
// http ports
|
|
180
|
+
"-p",
|
|
181
|
+
`${ports.apiPort}:40000`,
|
|
182
|
+
"-p",
|
|
183
|
+
`${ports.vncPort}:41000`,
|
|
184
|
+
]),
|
|
185
|
+
"-e",
|
|
186
|
+
`SPECULOS_APPNAME=${appName}:${appVersion}`,
|
|
187
|
+
"--name",
|
|
188
|
+
`${speculosID}`,
|
|
189
|
+
process.env.SPECULOS_IMAGE_TAG ?? "ghcr.io/ledgerhq/speculos:sha-e262a0c",
|
|
190
|
+
"--model",
|
|
191
|
+
model.toLowerCase(),
|
|
192
|
+
appPath,
|
|
193
|
+
...(dependency
|
|
194
|
+
? [
|
|
195
|
+
"-l",
|
|
196
|
+
`${dependency}:./apps/${conventionalAppSubpath(model, firmware, dependency, appVersion)}`,
|
|
197
|
+
]
|
|
198
|
+
: []),
|
|
199
|
+
...(dependencies !== undefined
|
|
200
|
+
? dependencies.flatMap(dependency => [
|
|
201
|
+
"-l",
|
|
202
|
+
`${dependency.name}:./apps/${conventionalAppSubpath(model, firmware, dependency.name, dependency.appVersion ? dependency.appVersion : "1.0.0")}`,
|
|
203
|
+
])
|
|
204
|
+
: []),
|
|
205
|
+
...(sdk ? ["--sdk", sdk] : []),
|
|
206
|
+
"--display",
|
|
207
|
+
"headless",
|
|
208
|
+
...(process.env.CI ? ["--vnc-password", "live", "--vnc-port", "41000"] : []),
|
|
209
|
+
...(isSpeculosWebsocket
|
|
210
|
+
? [
|
|
211
|
+
// websocket ports
|
|
212
|
+
"--apdu-port",
|
|
213
|
+
"40000",
|
|
214
|
+
"--button-port",
|
|
215
|
+
"42000",
|
|
216
|
+
"--automation-port",
|
|
217
|
+
"43000",
|
|
218
|
+
]
|
|
219
|
+
: [
|
|
220
|
+
// http ports
|
|
221
|
+
"--api-port",
|
|
222
|
+
"40000",
|
|
223
|
+
]),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
log("speculos", `${speculosID}: spawning = ${params.join(" ")}`);
|
|
227
|
+
|
|
228
|
+
const p = spawn("docker", [...params, "--seed", `${seed}`]);
|
|
229
|
+
|
|
230
|
+
let resolveReady: (value: boolean) => void;
|
|
231
|
+
let rejectReady: (e: Error) => void;
|
|
232
|
+
const ready = new Promise((resolve, reject) => {
|
|
233
|
+
resolveReady = resolve;
|
|
234
|
+
rejectReady = reject;
|
|
235
|
+
});
|
|
236
|
+
let destroyed = false;
|
|
237
|
+
|
|
238
|
+
const destroy = () => {
|
|
239
|
+
if (destroyed) return;
|
|
240
|
+
destroyed = true;
|
|
241
|
+
new Promise((resolve, reject) => {
|
|
242
|
+
if (!data[speculosID]) return;
|
|
243
|
+
delete data[speculosID];
|
|
244
|
+
exec(`docker rm -f ${speculosID}`, (error, stdout, stderr) => {
|
|
245
|
+
if (error) {
|
|
246
|
+
log("speculos-error", `${speculosID} not destroyed ${error} ${stderr}`);
|
|
247
|
+
reject(error);
|
|
248
|
+
} else {
|
|
249
|
+
log("speculos", `destroyed ${speculosID}`);
|
|
250
|
+
resolve(undefined);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
p.stdout.on("data", data => {
|
|
257
|
+
if (data) {
|
|
258
|
+
log("speculos-stdout", `${speculosID}: ${String(data).trim()}`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
let latestStderr: string | undefined;
|
|
262
|
+
p.stderr.on("data", data => {
|
|
263
|
+
if (!data) return;
|
|
264
|
+
latestStderr = data;
|
|
265
|
+
|
|
266
|
+
if (!data.includes("apdu: ")) {
|
|
267
|
+
log("speculos-stderr", `${speculosID}: ${String(data).trim()}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (/using\s(?:SDK|API_LEVEL)/.test(data)) {
|
|
271
|
+
setTimeout(() => resolveReady(true), 500);
|
|
272
|
+
} else if (data.includes("is already in use by container")) {
|
|
273
|
+
rejectReady(
|
|
274
|
+
new Error("speculos already in use! Try `ledger-live cleanSpeculos` or check logs"),
|
|
275
|
+
);
|
|
276
|
+
} else if (data.includes("address already in use")) {
|
|
277
|
+
if (maxRetry > 0) {
|
|
278
|
+
log("speculos", "retrying speculos connection");
|
|
279
|
+
destroy();
|
|
280
|
+
resolveReady(false);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
p.on("close", () => {
|
|
285
|
+
log("speculos", `${speculosID} closed`);
|
|
286
|
+
|
|
287
|
+
if (!destroyed) {
|
|
288
|
+
destroy();
|
|
289
|
+
rejectReady(new Error(`speculos process failure. ${latestStderr || ""}`));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
const hasSucceed = await ready;
|
|
293
|
+
|
|
294
|
+
if (!hasSucceed) {
|
|
295
|
+
await delay(1000);
|
|
296
|
+
return createSpeculosDevice(arg, maxRetry - 1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let transport: SpeculosTransport;
|
|
300
|
+
if (isSpeculosWebsocket) {
|
|
301
|
+
transport = await SpeculosTransportWebsocket.open({
|
|
302
|
+
apduPort: ports?.apduPort as number,
|
|
303
|
+
buttonPort: ports?.buttonPort as number,
|
|
304
|
+
automationPort: ports?.automationPort as number,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
data[speculosID] = {
|
|
308
|
+
process: p,
|
|
309
|
+
apduPort: ports.apduPort as number,
|
|
310
|
+
buttonPort: ports.buttonPort as number,
|
|
311
|
+
automationPort: ports.automationPort as number,
|
|
312
|
+
transport,
|
|
313
|
+
destroy,
|
|
314
|
+
};
|
|
315
|
+
} else {
|
|
316
|
+
transport = await SpeculosTransportHttp.open({
|
|
317
|
+
apiPort: ports.apiPort?.toString(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
data[speculosID] = {
|
|
321
|
+
process: p,
|
|
322
|
+
apiPort: ports.apiPort?.toString(),
|
|
323
|
+
transport,
|
|
324
|
+
destroy,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const device = {
|
|
329
|
+
id: speculosID,
|
|
330
|
+
transport,
|
|
331
|
+
appPath,
|
|
332
|
+
ports,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
if (arg.onSpeculosDeviceCreated != null) {
|
|
336
|
+
await arg.onSpeculosDeviceCreated(device);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return device;
|
|
340
|
+
}
|
package/tsconfig.json
ADDED