@onekeyfe/hardware-cli 1.1.26-alpha.106 → 1.1.26-alpha.4
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/.eslintignore +4 -0
- package/dist/chains.d.ts +6 -0
- package/dist/chains.js +191 -87
- package/dist/cli.js +615 -496
- package/dist/index.d.ts +16 -89
- package/dist/index.js +1 -2
- package/dist/sdk.d.ts +15 -5
- package/dist/sdk.js +237 -131
- package/dist/session.d.ts +22 -0
- package/dist/session.js +83 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/process-utils.d.ts +2 -0
- package/dist/storage/process-utils.js +44 -0
- package/dist/storage/secure-storage.linux.d.ts +11 -0
- package/dist/storage/secure-storage.linux.js +59 -0
- package/dist/storage/secure-storage.macos.d.ts +11 -0
- package/dist/storage/secure-storage.macos.js +65 -0
- package/dist/storage/storage-factory.d.ts +3 -0
- package/dist/storage/storage-factory.js +14 -0
- package/dist/storage/types.d.ts +18 -0
- package/dist/storage/types.js +2 -0
- package/package.json +15 -13
- package/src/chains.ts +229 -85
- package/src/cli.ts +620 -297
- package/src/sdk.ts +244 -125
- package/src/session.ts +89 -0
- package/src/storage/index.ts +2 -0
- package/src/storage/process-utils.ts +50 -0
- package/src/storage/secure-storage.linux.ts +68 -0
- package/src/storage/secure-storage.macos.ts +68 -0
- package/src/storage/storage-factory.ts +13 -0
- package/src/storage/types.ts +17 -0
- package/tsconfig.json +5 -7
- package/.claude-plugin/plugin.json +0 -14
- package/AGENTS.md +0 -40
- package/CLAUDE.md +0 -40
- package/README.md +0 -112
- package/evals/cases.json +0 -373
- package/evals/run-evals.sh +0 -136
- package/rollup.config.js +0 -28
package/src/sdk.ts
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
* SDK Factory — creates and initializes the hardware SDK instance
|
|
3
3
|
* for CLI usage with the appropriate transport.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Passphrase flow aligns with app-monorepo CLI:
|
|
6
|
+
* - Standard wallet: --use-empty-passphrase, auto-respond
|
|
7
|
+
* - Hidden wallet: interactive 1/2/3 selection (standard / pinentry / on-device)
|
|
8
|
+
* - Session caching: passphraseState + sessionId stored in OS keychain,
|
|
9
|
+
* preloaded via preloadSessionCache on next invocation
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
13
|
+
import * as readline from 'node:readline';
|
|
12
14
|
import HardwareSDK from '@onekeyfe/hd-common-connect-sdk';
|
|
13
15
|
import { DEVICE, UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core';
|
|
14
|
-
import { UsbPlugin } from '@onekeyfe/hd-transport-usb';
|
|
15
|
-
import * as readline from 'readline';
|
|
16
16
|
|
|
17
17
|
import type { ConnectSettings } from '@onekeyfe/hd-core';
|
|
18
18
|
|
|
@@ -23,133 +23,221 @@ export interface SDKOptions {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Current per-invocation CLI options. Event handlers read from this object
|
|
27
|
+
* so that invoking createSDK() with different opts never results in stale
|
|
28
|
+
* closure captures — the SDK is a singleton, but opts can change per call.
|
|
29
|
+
*/
|
|
30
|
+
let currentOpts: SDKOptions = {};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Promise-singleton that resolves to the initialised SDK. Set on first call,
|
|
34
|
+
* reused by all subsequent (and concurrent) callers.
|
|
35
|
+
*
|
|
36
|
+
* Using a Promise instead of a boolean flag eliminates the race where two
|
|
37
|
+
* concurrent createSDK() calls could both pass the "initialised?" check
|
|
38
|
+
* before the flag was set, then both run init() — each call replaces the
|
|
39
|
+
* internal Core/Connector at the hd-core layer and leaks USB handles.
|
|
40
|
+
*
|
|
41
|
+
* Mirrors app-monorepo's apps/cli/src/commands/device/hardware-sdk.ts
|
|
42
|
+
* (sdkReadyPromise). Cleared by disposeSDK() so REPL/test harnesses can
|
|
43
|
+
* re-init cleanly after an explicit tear-down.
|
|
44
|
+
*/
|
|
45
|
+
let sdkReadyPromise: Promise<typeof HardwareSDK> | null = null;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Pinentry — secure passphrase input via native OS dialog
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const PINENTRY_PROGRAMS = ['pinentry-mac', 'pinentry', 'pinentry-gnome3', 'pinentry-qt'];
|
|
52
|
+
|
|
53
|
+
function findPinentry(): string | null {
|
|
54
|
+
for (const prog of PINENTRY_PROGRAMS) {
|
|
55
|
+
try {
|
|
56
|
+
const result = execFileSync('which', [prog], {
|
|
57
|
+
encoding: 'utf-8',
|
|
58
|
+
timeout: 2000,
|
|
59
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
if (result.trim()) return prog;
|
|
62
|
+
} catch {
|
|
63
|
+
// not found
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function promptPassphraseViaPinentry(): Promise<{
|
|
70
|
+
value: string;
|
|
71
|
+
passphraseOnDevice: boolean;
|
|
72
|
+
}> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const pinentryBin = findPinentry();
|
|
75
|
+
if (!pinentryBin) {
|
|
76
|
+
process.stderr.write('[onekey-hw] No pinentry found, falling back to on-device entry.\n');
|
|
77
|
+
resolve({ value: '', passphraseOnDevice: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const commands = [
|
|
82
|
+
'SETDESC OneKey Hardware Wallet',
|
|
83
|
+
'SETPROMPT Enter passphrase',
|
|
84
|
+
'GETPIN',
|
|
85
|
+
'BYE',
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const child = execFile(
|
|
89
|
+
pinentryBin,
|
|
90
|
+
[],
|
|
91
|
+
{ timeout: 120_000, encoding: 'utf-8' },
|
|
92
|
+
(error, stdout) => {
|
|
93
|
+
if (error) {
|
|
94
|
+
if (error.killed || (stdout && stdout.includes('ERR 83886179'))) {
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
'[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n'
|
|
97
|
+
);
|
|
98
|
+
resolve({ value: '', passphraseOnDevice: true });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
reject(error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const dataLine = stdout.split('\n').find(l => l.startsWith('D '));
|
|
106
|
+
if (dataLine) {
|
|
107
|
+
resolve({ value: dataLine.slice(2), passphraseOnDevice: false });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (stdout.includes('ERR 83886179') || stdout.includes('Operation cancelled')) {
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
'[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n'
|
|
114
|
+
);
|
|
115
|
+
resolve({ value: '', passphraseOnDevice: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Empty passphrase — on-device
|
|
120
|
+
resolve({ value: '', passphraseOnDevice: true });
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
child.stdin?.write(commands);
|
|
125
|
+
child.stdin?.end();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Interactive prompts
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Prompt user to select wallet type (aligns with app-monorepo flow):
|
|
135
|
+
* 1. Standard wallet (no passphrase)
|
|
136
|
+
* 2. Hidden wallet — enter passphrase via pinentry (secure OS dialog)
|
|
137
|
+
* 3. Hidden wallet — enter passphrase on device screen
|
|
28
138
|
*/
|
|
29
|
-
function
|
|
139
|
+
function resolvePassphraseByChoice(
|
|
140
|
+
choice: '1' | '2' | '3'
|
|
141
|
+
): Promise<{ value: string; passphraseOnDevice: boolean }> {
|
|
142
|
+
if (choice === '1') return Promise.resolve({ value: '', passphraseOnDevice: false });
|
|
143
|
+
if (choice === '2') return promptPassphraseViaPinentry();
|
|
144
|
+
return Promise.resolve({ value: '', passphraseOnDevice: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function promptPassphraseMode(): Promise<{
|
|
148
|
+
value: string;
|
|
149
|
+
passphraseOnDevice: boolean;
|
|
150
|
+
}> {
|
|
30
151
|
if (!process.stdin.isTTY) {
|
|
31
|
-
|
|
32
|
-
return Promise.resolve('');
|
|
152
|
+
return Promise.resolve({ value: '', passphraseOnDevice: true });
|
|
33
153
|
}
|
|
34
154
|
|
|
35
155
|
return new Promise(resolve => {
|
|
36
156
|
const rl = readline.createInterface({
|
|
37
157
|
input: process.stdin,
|
|
38
|
-
output: process.stderr,
|
|
158
|
+
output: process.stderr,
|
|
159
|
+
terminal: true,
|
|
39
160
|
});
|
|
40
161
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
162
|
+
const prompt = () => {
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
[
|
|
165
|
+
'[onekey-hw] Select wallet type:',
|
|
166
|
+
' 1. Standard wallet (no passphrase)',
|
|
167
|
+
' 2. Hidden wallet — enter passphrase on this computer (pinentry)',
|
|
168
|
+
' 3. Hidden wallet — enter passphrase on device screen',
|
|
169
|
+
'',
|
|
170
|
+
].join('\n')
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
rl.question('Enter selection [1/2/3]: ', answer => {
|
|
174
|
+
const n = answer.trim() as '1' | '2' | '3';
|
|
175
|
+
if (n === '1' || n === '2' || n === '3') {
|
|
55
176
|
rl.close();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Ctrl+C
|
|
59
|
-
process.exit(1);
|
|
60
|
-
} else if (c === '\u007F' || c === '\b') {
|
|
61
|
-
// Backspace
|
|
62
|
-
input = input.slice(0, -1);
|
|
63
|
-
} else {
|
|
64
|
-
input += c;
|
|
65
|
-
process.stderr.write('*');
|
|
177
|
+
resolvePassphraseByChoice(n).then(resolve);
|
|
178
|
+
return;
|
|
66
179
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} else {
|
|
70
|
-
rl.question(question, answer => {
|
|
71
|
-
rl.close();
|
|
72
|
-
resolve(answer);
|
|
180
|
+
process.stderr.write('Invalid selection. Enter 1, 2, or 3.\n');
|
|
181
|
+
prompt();
|
|
73
182
|
});
|
|
74
|
-
}
|
|
183
|
+
};
|
|
184
|
+
prompt();
|
|
75
185
|
});
|
|
76
186
|
}
|
|
77
187
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
* - Passphrase input (for hidden wallets)
|
|
84
|
-
* - Button confirmation (user must physically press on device)
|
|
85
|
-
*
|
|
86
|
-
* Reference: packages/core/src/core/index.ts lines 315-330, 1021-1098
|
|
87
|
-
*/
|
|
88
|
-
function registerEventHandlers(sdk: typeof HardwareSDK, opts: SDKOptions): void {
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Event handlers
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
function registerEventHandlers(sdk: typeof HardwareSDK): void {
|
|
89
193
|
sdk.on(UI_EVENT, (message: any) => {
|
|
90
|
-
// PIN Request
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
194
|
+
// PIN Request — always on-device for CLI security (no terminal echo).
|
|
195
|
+
// The sentinel '@@ONEKEY_INPUT_PIN_IN_DEVICE' makes DeviceCommands send
|
|
196
|
+
// `BixinPinInputOnDevice` so the device switches to on-device PIN entry.
|
|
197
|
+
// Sending an empty string would be treated as a wrong (empty) PIN and
|
|
198
|
+
// would consume a PIN retry on Classic/1S/Mini/Pure.
|
|
199
|
+
// Touch/Pro never emit PinMatrixRequest — they handle PIN on touchscreen
|
|
200
|
+
// before this code path runs, so the sentinel is harmless there.
|
|
94
201
|
if (message.type === UI_REQUEST.REQUEST_PIN) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// No uiResponse needed — device handles PIN input internally
|
|
101
|
-
} else {
|
|
102
|
-
// Classic devices: PIN entry via matrix
|
|
103
|
-
// In CLI mode, prompt user or let agent handle
|
|
104
|
-
process.stderr.write('[onekey-hw] PIN required. Please enter PIN on your device.\n');
|
|
105
|
-
promptUser('PIN (on-device numpad mapping): ', true).then(pin => {
|
|
106
|
-
sdk.uiResponse({
|
|
107
|
-
type: UI_RESPONSE.RECEIVE_PIN,
|
|
108
|
-
payload: pin,
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
}
|
|
202
|
+
process.stderr.write('[onekey-hw] Please enter PIN on your device screen...\n');
|
|
203
|
+
sdk.uiResponse({
|
|
204
|
+
type: UI_RESPONSE.RECEIVE_PIN,
|
|
205
|
+
payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE',
|
|
206
|
+
});
|
|
112
207
|
}
|
|
113
208
|
|
|
114
209
|
// Passphrase Request
|
|
115
|
-
// User must provide passphrase for hidden wallet access.
|
|
116
|
-
// Passphrase can be entered on-device (Touch/Pro) or via host.
|
|
117
210
|
if (message.type === UI_REQUEST.REQUEST_PASSPHRASE) {
|
|
118
|
-
|
|
119
|
-
|
|
211
|
+
// 1. Explicit --use-empty-passphrase: auto-respond
|
|
212
|
+
if (currentOpts.useEmptyPassphrase) {
|
|
120
213
|
sdk.uiResponse({
|
|
121
214
|
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
|
|
122
|
-
payload: {
|
|
123
|
-
value: '',
|
|
124
|
-
passphraseOnDevice: false,
|
|
125
|
-
save: false,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
} else {
|
|
129
|
-
process.stderr.write('[onekey-hw] Passphrase required for hidden wallet.\n');
|
|
130
|
-
promptUser('Enter passphrase (or press Enter for on-device entry): ').then(passphrase => {
|
|
131
|
-
if (passphrase === '') {
|
|
132
|
-
// Enter on device
|
|
133
|
-
sdk.uiResponse({
|
|
134
|
-
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
|
|
135
|
-
payload: {
|
|
136
|
-
value: '',
|
|
137
|
-
passphraseOnDevice: true,
|
|
138
|
-
save: false,
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
} else {
|
|
142
|
-
sdk.uiResponse({
|
|
143
|
-
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
|
|
144
|
-
payload: {
|
|
145
|
-
value: passphrase,
|
|
146
|
-
passphraseOnDevice: false,
|
|
147
|
-
save: false,
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
}
|
|
215
|
+
payload: { value: '', passphraseOnDevice: false, save: false },
|
|
151
216
|
});
|
|
217
|
+
return;
|
|
152
218
|
}
|
|
219
|
+
|
|
220
|
+
// 2. Interactive: 1/2/3 selection
|
|
221
|
+
promptPassphraseMode()
|
|
222
|
+
.then(result => {
|
|
223
|
+
sdk.uiResponse({
|
|
224
|
+
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
|
|
225
|
+
payload: {
|
|
226
|
+
value: result.value,
|
|
227
|
+
passphraseOnDevice: result.passphraseOnDevice,
|
|
228
|
+
save: false,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
'[onekey-hw] Passphrase prompt failed, falling back to on-device.\n'
|
|
235
|
+
);
|
|
236
|
+
sdk.uiResponse({
|
|
237
|
+
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
|
|
238
|
+
payload: { value: '', passphraseOnDevice: true, save: false },
|
|
239
|
+
});
|
|
240
|
+
});
|
|
153
241
|
}
|
|
154
242
|
|
|
155
243
|
// Passphrase On Device
|
|
@@ -158,42 +246,73 @@ function registerEventHandlers(sdk: typeof HardwareSDK, opts: SDKOptions): void
|
|
|
158
246
|
}
|
|
159
247
|
|
|
160
248
|
// Button Confirmation
|
|
161
|
-
// User must physically press confirm/reject on the device.
|
|
162
249
|
if (message.type === UI_REQUEST.REQUEST_BUTTON) {
|
|
163
250
|
process.stderr.write('[onekey-hw] Please confirm the action on your device...\n');
|
|
164
251
|
}
|
|
165
252
|
});
|
|
166
253
|
|
|
167
|
-
// Device connection events — only show when device has a known name
|
|
168
254
|
sdk.on(DEVICE.CONNECT, (device: any) => {
|
|
169
255
|
const name = device?.label || device?.name;
|
|
170
|
-
if (name) {
|
|
171
|
-
process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
|
|
172
|
-
}
|
|
256
|
+
if (name) process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
|
|
173
257
|
});
|
|
174
258
|
|
|
175
259
|
sdk.on(DEVICE.DISCONNECT, (device: any) => {
|
|
176
260
|
const name = device?.label || device?.name;
|
|
177
|
-
if (name) {
|
|
178
|
-
process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
|
|
179
|
-
}
|
|
261
|
+
if (name) process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
|
|
180
262
|
});
|
|
181
263
|
}
|
|
182
264
|
|
|
183
|
-
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// SDK Factory
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async function initSDK(): Promise<typeof HardwareSDK> {
|
|
184
270
|
const settings: Partial<ConnectSettings> = {
|
|
185
271
|
debug: false,
|
|
186
272
|
fetchConfig: true,
|
|
273
|
+
env: 'node-usb',
|
|
187
274
|
};
|
|
275
|
+
await HardwareSDK.init(settings);
|
|
188
276
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
277
|
+
// Defensive: strip any stale listeners (e.g. left over from a previous
|
|
278
|
+
// dispose/init cycle in a long-running process) before wiring ours.
|
|
279
|
+
// Mirrors app-monorepo's cleanupHardwareSDKInstance() which removes
|
|
280
|
+
// listeners for ALL events at once.
|
|
281
|
+
// @ts-expect-error CoreApi types require an event name; passing none
|
|
282
|
+
// removes listeners for ALL events (Node.js EventEmitter semantics).
|
|
283
|
+
HardwareSDK.removeAllListeners();
|
|
284
|
+
registerEventHandlers(HardwareSDK);
|
|
285
|
+
|
|
286
|
+
return HardwareSDK;
|
|
287
|
+
}
|
|
192
288
|
|
|
193
|
-
|
|
289
|
+
export async function createSDK(opts: SDKOptions): Promise<typeof HardwareSDK> {
|
|
290
|
+
// Always refresh opts — event handlers read from the module-level ref,
|
|
291
|
+
// so subsequent createSDK() calls pick up new opts without re-registering.
|
|
292
|
+
currentOpts = opts;
|
|
194
293
|
|
|
195
|
-
|
|
196
|
-
|
|
294
|
+
if (!sdkReadyPromise) {
|
|
295
|
+
sdkReadyPromise = initSDK();
|
|
296
|
+
}
|
|
297
|
+
return sdkReadyPromise;
|
|
298
|
+
}
|
|
197
299
|
|
|
198
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Release the SDK and clear the singleton. Must be called before the CLI
|
|
302
|
+
* process exits — the USB transport holds open handles (event listeners,
|
|
303
|
+
* polling timers) that otherwise keep Node.js alive for ~26s.
|
|
304
|
+
*
|
|
305
|
+
* Safe to call multiple times (no-op after the first successful call).
|
|
306
|
+
*/
|
|
307
|
+
export async function disposeSDK(): Promise<void> {
|
|
308
|
+
if (!sdkReadyPromise) return;
|
|
309
|
+
try {
|
|
310
|
+
const sdk = await sdkReadyPromise;
|
|
311
|
+
sdk.dispose();
|
|
312
|
+
} catch {
|
|
313
|
+
// ignore errors during cleanup
|
|
314
|
+
} finally {
|
|
315
|
+
sdkReadyPromise = null;
|
|
316
|
+
currentOpts = {};
|
|
317
|
+
}
|
|
199
318
|
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passphrase session management for hd-cli.
|
|
3
|
+
*
|
|
4
|
+
* Aligns with app-monorepo's CLI pattern:
|
|
5
|
+
* Login: getPassphraseState → passphraseState + sessionId → keychain
|
|
6
|
+
* Command: keychain → preloadSessionCache → SDK call (no passphrase prompt)
|
|
7
|
+
* Stale: error 112 → clear keychain → re-prompt → retry
|
|
8
|
+
* Logout: keychain delete
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { preloadSessionCache } from '@onekeyfe/hd-core';
|
|
12
|
+
|
|
13
|
+
import { createSecureStorage } from './storage';
|
|
14
|
+
|
|
15
|
+
import type { ISecureStorage } from './storage';
|
|
16
|
+
|
|
17
|
+
// Keychain key format: scoped by deviceId for multi-device support
|
|
18
|
+
function psKey(deviceId: string): string {
|
|
19
|
+
return `onekey-hw:${deviceId}/passphrase-state`;
|
|
20
|
+
}
|
|
21
|
+
function sidKey(deviceId: string): string {
|
|
22
|
+
return `onekey-hw:${deviceId}/session-id`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let storageInstance: ISecureStorage | null = null;
|
|
26
|
+
|
|
27
|
+
function getStorage(): ISecureStorage {
|
|
28
|
+
if (!storageInstance) {
|
|
29
|
+
storageInstance = createSecureStorage();
|
|
30
|
+
}
|
|
31
|
+
return storageInstance;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load passphraseState + sessionId from keychain and call preloadSessionCache.
|
|
36
|
+
* Non-fatal: returns the loaded passphraseState or undefined.
|
|
37
|
+
*/
|
|
38
|
+
export async function preloadSessionFromKeychain(deviceId: string): Promise<string | undefined> {
|
|
39
|
+
try {
|
|
40
|
+
const storage = getStorage();
|
|
41
|
+
const [psBuf, sidBuf] = await Promise.all([
|
|
42
|
+
storage.get(psKey(deviceId)),
|
|
43
|
+
storage.get(sidKey(deviceId)),
|
|
44
|
+
]);
|
|
45
|
+
if (psBuf && sidBuf) {
|
|
46
|
+
const passphraseState = psBuf.toString('utf-8');
|
|
47
|
+
const sessionId = sidBuf.toString('utf-8');
|
|
48
|
+
|
|
49
|
+
preloadSessionCache(deviceId, passphraseState, sessionId);
|
|
50
|
+
return passphraseState;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Non-fatal — fall through to passphrase prompt
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Save passphraseState + sessionId to keychain for next CLI invocation.
|
|
60
|
+
*/
|
|
61
|
+
export async function saveSessionToKeychain(
|
|
62
|
+
deviceId: string,
|
|
63
|
+
passphraseState: string,
|
|
64
|
+
sessionId: string
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
const storage = getStorage();
|
|
68
|
+
await Promise.all([
|
|
69
|
+
storage.set(psKey(deviceId), Buffer.from(passphraseState, 'utf-8')),
|
|
70
|
+
storage.set(sidKey(deviceId), Buffer.from(sessionId, 'utf-8')),
|
|
71
|
+
]);
|
|
72
|
+
} catch {
|
|
73
|
+
// Non-fatal — session still works in-memory for this invocation
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clear cached session from keychain.
|
|
79
|
+
*/
|
|
80
|
+
export async function clearSessionFromKeychain(deviceId?: string): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
const storage = getStorage();
|
|
83
|
+
if (deviceId) {
|
|
84
|
+
await Promise.allSettled([storage.delete(psKey(deviceId)), storage.delete(sidKey(deviceId))]);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Non-fatal
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import type { IProcessRunner } from './types';
|
|
4
|
+
|
|
5
|
+
export const defaultProcessRunner: IProcessRunner = {
|
|
6
|
+
execFileAsync(cmd, args) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
execFile(cmd, args, (error, stdout, stderr) => {
|
|
9
|
+
if (error) {
|
|
10
|
+
(error as Error & { stderr?: string }).stderr = stderr;
|
|
11
|
+
reject(error);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve({ stdout, stderr });
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
spawnWithStdin(cmd, args, input) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
22
|
+
let stdout = '';
|
|
23
|
+
let stderr = '';
|
|
24
|
+
|
|
25
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
26
|
+
stdout += data.toString();
|
|
27
|
+
});
|
|
28
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
29
|
+
stderr += data.toString();
|
|
30
|
+
});
|
|
31
|
+
child.on('error', reject);
|
|
32
|
+
child.on('close', code => {
|
|
33
|
+
if (code !== 0) {
|
|
34
|
+
const error = new Error(`Process exited with code ${code}`) as Error & {
|
|
35
|
+
code?: number;
|
|
36
|
+
stderr?: string;
|
|
37
|
+
};
|
|
38
|
+
error.code = code ?? 1;
|
|
39
|
+
error.stderr = stderr;
|
|
40
|
+
reject(error);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
resolve({ stdout, stderr });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.stdin.write(`${input}\n`);
|
|
47
|
+
child.stdin.end();
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defaultProcessRunner } from './process-utils';
|
|
2
|
+
|
|
3
|
+
import type { IProcessRunner, ISecureStorage, SecureStorageBackend } from './types';
|
|
4
|
+
|
|
5
|
+
const SERVICE_NAME = 'onekey-hw-cli';
|
|
6
|
+
const SECRET_LABEL = 'OneKey HW CLI Secret';
|
|
7
|
+
|
|
8
|
+
export class LinuxSecureStorage implements ISecureStorage {
|
|
9
|
+
private readonly runner: IProcessRunner;
|
|
10
|
+
|
|
11
|
+
constructor(runner: IProcessRunner = defaultProcessRunner) {
|
|
12
|
+
this.runner = runner;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getBackendType(): SecureStorageBackend {
|
|
16
|
+
return 'linux-secret-service';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async set(key: string, value: Buffer): Promise<void> {
|
|
20
|
+
await this.runner.spawnWithStdin(
|
|
21
|
+
'secret-tool',
|
|
22
|
+
['store', '--label', SECRET_LABEL, 'service', SERVICE_NAME, 'account', key],
|
|
23
|
+
value.toString('hex')
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async get(key: string): Promise<Buffer | null> {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await this.runner.execFileAsync('secret-tool', [
|
|
30
|
+
'lookup',
|
|
31
|
+
'service',
|
|
32
|
+
SERVICE_NAME,
|
|
33
|
+
'account',
|
|
34
|
+
key,
|
|
35
|
+
]);
|
|
36
|
+
const hex = stdout.trim();
|
|
37
|
+
return hex ? Buffer.from(hex, 'hex') : null;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (this.isItemNotFound(error)) return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async delete(key: string): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
await this.runner.execFileAsync('secret-tool', [
|
|
47
|
+
'clear',
|
|
48
|
+
'service',
|
|
49
|
+
SERVICE_NAME,
|
|
50
|
+
'account',
|
|
51
|
+
key,
|
|
52
|
+
]);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (this.isItemNotFound(error)) return;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private isItemNotFound(error: unknown): boolean {
|
|
60
|
+
const err = error as Error & { stderr?: string };
|
|
61
|
+
const stderr = err.stderr ?? err.message ?? '';
|
|
62
|
+
return (
|
|
63
|
+
stderr.includes('No such secret item') ||
|
|
64
|
+
stderr.includes('Object does not exist') ||
|
|
65
|
+
stderr.includes('could not find')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|