@onekeyfe/hardware-cli 1.1.26-alpha.106 → 1.1.26-alpha.11

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.
Files changed (46) hide show
  1. package/.eslintignore +4 -0
  2. package/dist/chains.d.ts +6 -0
  3. package/dist/chains.js +191 -87
  4. package/dist/cli.js +615 -496
  5. package/dist/index.d.ts +16 -89
  6. package/dist/index.js +1 -2
  7. package/dist/pinentry.d.ts +24 -0
  8. package/dist/pinentry.js +113 -0
  9. package/dist/sdk.d.ts +15 -5
  10. package/dist/sdk.js +175 -131
  11. package/dist/session.d.ts +22 -0
  12. package/dist/session.js +83 -0
  13. package/dist/storage/index.d.ts +2 -0
  14. package/dist/storage/index.js +5 -0
  15. package/dist/storage/process-utils.d.ts +2 -0
  16. package/dist/storage/process-utils.js +44 -0
  17. package/dist/storage/secure-storage.linux.d.ts +11 -0
  18. package/dist/storage/secure-storage.linux.js +59 -0
  19. package/dist/storage/secure-storage.macos.d.ts +11 -0
  20. package/dist/storage/secure-storage.macos.js +65 -0
  21. package/dist/storage/storage-factory.d.ts +3 -0
  22. package/dist/storage/storage-factory.js +14 -0
  23. package/dist/storage/types.d.ts +18 -0
  24. package/dist/storage/types.js +2 -0
  25. package/jest.config.js +4 -0
  26. package/package.json +17 -14
  27. package/src/__tests__/pinentry.test.ts +181 -0
  28. package/src/chains.ts +229 -85
  29. package/src/cli.ts +620 -297
  30. package/src/pinentry.ts +138 -0
  31. package/src/sdk.ts +159 -125
  32. package/src/session.ts +89 -0
  33. package/src/storage/index.ts +2 -0
  34. package/src/storage/process-utils.ts +50 -0
  35. package/src/storage/secure-storage.linux.ts +68 -0
  36. package/src/storage/secure-storage.macos.ts +68 -0
  37. package/src/storage/storage-factory.ts +13 -0
  38. package/src/storage/types.ts +17 -0
  39. package/tsconfig.json +5 -7
  40. package/.claude-plugin/plugin.json +0 -14
  41. package/AGENTS.md +0 -40
  42. package/CLAUDE.md +0 -40
  43. package/README.md +0 -112
  44. package/evals/cases.json +0 -373
  45. package/evals/run-evals.sh +0 -136
  46. package/rollup.config.js +0 -28
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Secure passphrase input via the system pinentry program
3
+ * (pinentry-mac / pinentry / pinentry-gnome3 / pinentry-qt).
4
+ *
5
+ * The passphrase is entered in an OS dialog and returned as a string —
6
+ * it never appears in the terminal, shell history, process arguments,
7
+ * or env vars. Commands are piped over stdin so the dialog configuration
8
+ * (description, prompt) doesn't show up in argv either.
9
+ *
10
+ * Ported from app-monorepo apps/cli/src/utils/pinentry.ts; kept as a
11
+ * standalone module so the pure parser functions can be unit-tested
12
+ * without touching the SDK, child processes, or hardware.
13
+ */
14
+
15
+ import { execFile, execFileSync } from 'node:child_process';
16
+
17
+ const PINENTRY_PROGRAMS = ['pinentry-mac', 'pinentry', 'pinentry-gnome3', 'pinentry-qt'];
18
+
19
+ // Assuan protocol percent-encodes %, CR, and LF in D data lines.
20
+ // Without decoding, a passphrase containing `%` would be silently corrupted
21
+ // (e.g. `a%b` -> `a%25b`), deriving a wrong passphraseState and exposing a
22
+ // different — empty — hidden wallet.
23
+ export function decodeAssuanData(encoded: string): string {
24
+ return encoded.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) =>
25
+ String.fromCharCode(parseInt(hex, 16))
26
+ );
27
+ }
28
+
29
+ // Parse pinentry stdout into either a passphrase or a cancellation signal.
30
+ // Handles two edge cases beyond the basic `D <data>` shape:
31
+ // 1. Multi-line D responses — long passphrases past Assuan's ~1000-byte
32
+ // line limit get split across several `D` lines and must be concatenated
33
+ // *before* percent-decoding (a split inside `%XX` would otherwise corrupt
34
+ // the byte).
35
+ // 2. CRLF line endings — pinentry-mac uses LF, but pinentry-gnome3/qt may
36
+ // emit CRLF; splitting on `\r?\n` strips the trailing CR that would
37
+ // otherwise become a literal trailing byte in the passphrase.
38
+ export function parsePinentryStdout(stdout: string): {
39
+ data?: string;
40
+ cancelled: boolean;
41
+ } {
42
+ // Pinentry error code 83886179 is the canonical "user cancelled" signal —
43
+ // surfaces either as a non-zero exit or as an ERR line.
44
+ const cancelled = stdout.includes('ERR 83886179') || stdout.includes('Operation cancelled');
45
+
46
+ const dataChunks = stdout
47
+ .split(/\r?\n/)
48
+ .filter(l => l.startsWith('D '))
49
+ .map(l => l.slice(2));
50
+
51
+ if (dataChunks.length > 0) {
52
+ return { data: decodeAssuanData(dataChunks.join('')), cancelled };
53
+ }
54
+ return { cancelled };
55
+ }
56
+
57
+ export function findPinentry(): string | null {
58
+ for (const prog of PINENTRY_PROGRAMS) {
59
+ try {
60
+ const result = execFileSync('which', [prog], {
61
+ encoding: 'utf-8',
62
+ timeout: 2000,
63
+ stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+ if (result.trim()) return prog;
66
+ } catch {
67
+ // Program not found — try the next one.
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ export interface PinentryResult {
74
+ value: string;
75
+ passphraseOnDevice: boolean;
76
+ }
77
+
78
+ // CLI-variant policy differs from app-monorepo: we fall back to on-device
79
+ // entry instead of rejecting, because `onekey-hw` is used by AI agents that
80
+ // can't recover from a thrown error mid-flow. The device screen is always
81
+ // a valid second path.
82
+ export function promptPassphraseViaPinentry(): Promise<PinentryResult> {
83
+ return new Promise((resolve, reject) => {
84
+ const pinentryBin = findPinentry();
85
+ if (!pinentryBin) {
86
+ process.stderr.write('[onekey-hw] No pinentry found, falling back to on-device entry.\n');
87
+ resolve({ value: '', passphraseOnDevice: true });
88
+ return;
89
+ }
90
+
91
+ const commands = [
92
+ 'SETDESC OneKey Hardware Wallet',
93
+ 'SETPROMPT Enter passphrase',
94
+ 'GETPIN',
95
+ 'BYE',
96
+ ].join('\n');
97
+
98
+ const child = execFile(
99
+ pinentryBin,
100
+ [],
101
+ { timeout: 120_000, encoding: 'utf-8' },
102
+ (error, stdout) => {
103
+ const { data, cancelled } = parsePinentryStdout(stdout ?? '');
104
+
105
+ if (error) {
106
+ if (error.killed || cancelled) {
107
+ process.stderr.write(
108
+ '[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n'
109
+ );
110
+ resolve({ value: '', passphraseOnDevice: true });
111
+ return;
112
+ }
113
+ reject(error);
114
+ return;
115
+ }
116
+
117
+ if (data !== undefined) {
118
+ resolve({ value: data, passphraseOnDevice: false });
119
+ return;
120
+ }
121
+
122
+ if (cancelled) {
123
+ process.stderr.write(
124
+ '[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n'
125
+ );
126
+ resolve({ value: '', passphraseOnDevice: true });
127
+ return;
128
+ }
129
+
130
+ // Empty passphrase (OK pressed with no input) — on-device fallback.
131
+ resolve({ value: '', passphraseOnDevice: true });
132
+ }
133
+ );
134
+
135
+ child.stdin?.write(commands);
136
+ child.stdin?.end();
137
+ });
138
+ }
package/src/sdk.ts CHANGED
@@ -2,19 +2,21 @@
2
2
  * SDK Factory — creates and initializes the hardware SDK instance
3
3
  * for CLI usage with the appropriate transport.
4
4
  *
5
- * CRITICAL: Must register UI event handlers for PIN, Passphrase, and Button
6
- * confirmation. Without these, the SDK will hang waiting for responses.
7
- *
8
- * Reference: packages/core/src/core/index.ts (event registration pattern)
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
- // @ts-ignore - hd-common-connect-sdk may not have type declarations
12
+ import * as readline from 'node:readline';
12
13
  import HardwareSDK from '@onekeyfe/hd-common-connect-sdk';
13
14
  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';
15
+
16
+ import { promptPassphraseViaPinentry } from './pinentry';
16
17
 
17
18
  import type { ConnectSettings } from '@onekeyfe/hd-core';
19
+ import type { PinentryResult } from './pinentry';
18
20
 
19
21
  export interface SDKOptions {
20
22
  connectId?: string;
@@ -23,133 +25,134 @@ export interface SDKOptions {
23
25
  }
24
26
 
25
27
  /**
26
- * Prompt user for input in the terminal (hidden for PIN).
27
- * Falls back to empty string in non-TTY (piped) mode.
28
+ * Current per-invocation CLI options. Event handlers read from this object
29
+ * so that invoking createSDK() with different opts never results in stale
30
+ * closure captures — the SDK is a singleton, but opts can change per call.
31
+ */
32
+ let currentOpts: SDKOptions = {};
33
+
34
+ /**
35
+ * Promise-singleton that resolves to the initialised SDK. Set on first call,
36
+ * reused by all subsequent (and concurrent) callers.
37
+ *
38
+ * Using a Promise instead of a boolean flag eliminates the race where two
39
+ * concurrent createSDK() calls could both pass the "initialised?" check
40
+ * before the flag was set, then both run init() — each call replaces the
41
+ * internal Core/Connector at the hd-core layer and leaks USB handles.
42
+ *
43
+ * Mirrors app-monorepo's apps/cli/src/commands/device/hardware-sdk.ts
44
+ * (sdkReadyPromise). Cleared by disposeSDK() so REPL/test harnesses can
45
+ * re-init cleanly after an explicit tear-down.
46
+ */
47
+ let sdkReadyPromise: Promise<typeof HardwareSDK> | null = null;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Interactive prompts
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Prompt user to select wallet type (aligns with app-monorepo flow):
55
+ * 1. Standard wallet (no passphrase)
56
+ * 2. Hidden wallet — enter passphrase via pinentry (secure OS dialog)
57
+ * 3. Hidden wallet — enter passphrase on device screen
28
58
  */
29
- function promptUser(question: string, hidden = false): Promise<string> {
59
+ function resolvePassphraseByChoice(choice: '1' | '2' | '3'): Promise<PinentryResult> {
60
+ if (choice === '1') return Promise.resolve({ value: '', passphraseOnDevice: false });
61
+ if (choice === '2') return promptPassphraseViaPinentry();
62
+ return Promise.resolve({ value: '', passphraseOnDevice: true });
63
+ }
64
+
65
+ function promptPassphraseMode(): Promise<PinentryResult> {
30
66
  if (!process.stdin.isTTY) {
31
- // Non-interactive mode: return empty (agent should handle via uiResponse)
32
- return Promise.resolve('');
67
+ return Promise.resolve({ value: '', passphraseOnDevice: true });
33
68
  }
34
69
 
35
70
  return new Promise(resolve => {
36
71
  const rl = readline.createInterface({
37
72
  input: process.stdin,
38
- output: process.stderr, // Use stderr so JSON stdout stays clean
73
+ output: process.stderr,
74
+ terminal: true,
39
75
  });
40
76
 
41
- if (hidden) {
42
- // Mute output for PIN entry
43
- process.stderr.write(question);
44
- const { stdin } = process;
45
- const wasRaw = stdin.isRaw;
46
- if (stdin.setRawMode) stdin.setRawMode(true);
47
-
48
- let input = '';
49
- const onData = (char: Buffer) => {
50
- const c = char.toString('utf8');
51
- if (c === '\n' || c === '\r' || c === '\u0004') {
52
- if (stdin.setRawMode) stdin.setRawMode(wasRaw ?? false);
53
- stdin.removeListener('data', onData);
54
- process.stderr.write('\n');
77
+ const prompt = () => {
78
+ process.stderr.write(
79
+ [
80
+ '[onekey-hw] Select wallet type:',
81
+ ' 1. Standard wallet (no passphrase)',
82
+ ' 2. Hidden wallet — enter passphrase on this computer (pinentry)',
83
+ ' 3. Hidden wallet — enter passphrase on device screen',
84
+ '',
85
+ ].join('\n')
86
+ );
87
+
88
+ rl.question('Enter selection [1/2/3]: ', answer => {
89
+ const n = answer.trim() as '1' | '2' | '3';
90
+ if (n === '1' || n === '2' || n === '3') {
55
91
  rl.close();
56
- resolve(input);
57
- } else if (c === '\u0003') {
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('*');
92
+ resolvePassphraseByChoice(n).then(resolve);
93
+ return;
66
94
  }
67
- };
68
- stdin.on('data', onData);
69
- } else {
70
- rl.question(question, answer => {
71
- rl.close();
72
- resolve(answer);
95
+ process.stderr.write('Invalid selection. Enter 1, 2, or 3.\n');
96
+ prompt();
73
97
  });
74
- }
98
+ };
99
+ prompt();
75
100
  });
76
101
  }
77
102
 
78
- /**
79
- * Register UI event handlers for interactive device operations.
80
- *
81
- * The SDK emits events when the device needs user interaction:
82
- * - PIN entry (entered on device screen for Touch/Pro, or via matrix for Classic)
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 {
103
+ // ---------------------------------------------------------------------------
104
+ // Event handlers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function registerEventHandlers(sdk: typeof HardwareSDK): void {
89
108
  sdk.on(UI_EVENT, (message: any) => {
90
- // PIN Request
91
- // For Touch/Pro devices, PIN is entered on-device (device screen shows numpad).
92
- // For Classic devices, PIN uses a matrix mapping.
93
- // In CLI context, we auto-acknowledge since PIN entry happens on-device.
109
+ // PIN Request — always on-device for CLI security (no terminal echo).
110
+ // The sentinel '@@ONEKEY_INPUT_PIN_IN_DEVICE' makes DeviceCommands send
111
+ // `BixinPinInputOnDevice` so the device switches to on-device PIN entry.
112
+ // Sending an empty string would be treated as a wrong (empty) PIN and
113
+ // would consume a PIN retry on Classic/1S/Mini/Pure.
114
+ // Touch/Pro never emit PinMatrixRequest — they handle PIN on touchscreen
115
+ // before this code path runs, so the sentinel is harmless there.
94
116
  if (message.type === UI_REQUEST.REQUEST_PIN) {
95
- const pinType = message.payload?.type;
96
-
97
- if (pinType === 'ButtonRequest_PinEntry' || pinType === 'ButtonRequest_AttachPin') {
98
- // PIN is entered directly on device screen (Touch/Pro)
99
- process.stderr.write('[onekey-hw] Please enter PIN on your device screen...\n');
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
- }
117
+ process.stderr.write('[onekey-hw] Please enter PIN on your device screen...\n');
118
+ sdk.uiResponse({
119
+ type: UI_RESPONSE.RECEIVE_PIN,
120
+ payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE',
121
+ });
112
122
  }
113
123
 
114
124
  // Passphrase Request
115
- // User must provide passphrase for hidden wallet access.
116
- // Passphrase can be entered on-device (Touch/Pro) or via host.
117
125
  if (message.type === UI_REQUEST.REQUEST_PASSPHRASE) {
118
- if (opts.useEmptyPassphrase) {
119
- // Standard wallet (no passphrase)
126
+ // 1. Explicit --use-empty-passphrase: auto-respond
127
+ if (currentOpts.useEmptyPassphrase) {
120
128
  sdk.uiResponse({
121
129
  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
- }
130
+ payload: { value: '', passphraseOnDevice: false, save: false },
151
131
  });
132
+ return;
152
133
  }
134
+
135
+ // 2. Interactive: 1/2/3 selection
136
+ promptPassphraseMode()
137
+ .then(result => {
138
+ sdk.uiResponse({
139
+ type: UI_RESPONSE.RECEIVE_PASSPHRASE,
140
+ payload: {
141
+ value: result.value,
142
+ passphraseOnDevice: result.passphraseOnDevice,
143
+ save: false,
144
+ },
145
+ });
146
+ })
147
+ .catch(() => {
148
+ process.stderr.write(
149
+ '[onekey-hw] Passphrase prompt failed, falling back to on-device.\n'
150
+ );
151
+ sdk.uiResponse({
152
+ type: UI_RESPONSE.RECEIVE_PASSPHRASE,
153
+ payload: { value: '', passphraseOnDevice: true, save: false },
154
+ });
155
+ });
153
156
  }
154
157
 
155
158
  // Passphrase On Device
@@ -158,42 +161,73 @@ function registerEventHandlers(sdk: typeof HardwareSDK, opts: SDKOptions): void
158
161
  }
159
162
 
160
163
  // Button Confirmation
161
- // User must physically press confirm/reject on the device.
162
164
  if (message.type === UI_REQUEST.REQUEST_BUTTON) {
163
165
  process.stderr.write('[onekey-hw] Please confirm the action on your device...\n');
164
166
  }
165
167
  });
166
168
 
167
- // Device connection events — only show when device has a known name
168
169
  sdk.on(DEVICE.CONNECT, (device: any) => {
169
170
  const name = device?.label || device?.name;
170
- if (name) {
171
- process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
172
- }
171
+ if (name) process.stderr.write(`[onekey-hw] Device connected: ${name}\n`);
173
172
  });
174
173
 
175
174
  sdk.on(DEVICE.DISCONNECT, (device: any) => {
176
175
  const name = device?.label || device?.name;
177
- if (name) {
178
- process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
179
- }
176
+ if (name) process.stderr.write(`[onekey-hw] Device disconnected: ${name}\n`);
180
177
  });
181
178
  }
182
179
 
183
- export async function createSDK(opts: SDKOptions) {
180
+ // ---------------------------------------------------------------------------
181
+ // SDK Factory
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function initSDK(): Promise<typeof HardwareSDK> {
184
185
  const settings: Partial<ConnectSettings> = {
185
186
  debug: false,
186
187
  fetchConfig: true,
188
+ env: 'node-usb',
187
189
  };
190
+ await HardwareSDK.init(settings);
188
191
 
189
- // Direct USB via libusb works on macOS, Linux, Windows
190
- settings.env = 'lowlevel';
191
- const plugin = UsbPlugin;
192
+ // Defensive: strip any stale listeners (e.g. left over from a previous
193
+ // dispose/init cycle in a long-running process) before wiring ours.
194
+ // Mirrors app-monorepo's cleanupHardwareSDKInstance() which removes
195
+ // listeners for ALL events at once.
196
+ // @ts-expect-error CoreApi types require an event name; passing none
197
+ // removes listeners for ALL events (Node.js EventEmitter semantics).
198
+ HardwareSDK.removeAllListeners();
199
+ registerEventHandlers(HardwareSDK);
192
200
 
193
- await HardwareSDK.init(settings, undefined, plugin);
201
+ return HardwareSDK;
202
+ }
194
203
 
195
- // Register event handlers AFTER init
196
- registerEventHandlers(HardwareSDK, opts);
204
+ export async function createSDK(opts: SDKOptions): Promise<typeof HardwareSDK> {
205
+ // Always refresh opts — event handlers read from the module-level ref,
206
+ // so subsequent createSDK() calls pick up new opts without re-registering.
207
+ currentOpts = opts;
197
208
 
198
- return HardwareSDK;
209
+ if (!sdkReadyPromise) {
210
+ sdkReadyPromise = initSDK();
211
+ }
212
+ return sdkReadyPromise;
213
+ }
214
+
215
+ /**
216
+ * Release the SDK and clear the singleton. Must be called before the CLI
217
+ * process exits — the USB transport holds open handles (event listeners,
218
+ * polling timers) that otherwise keep Node.js alive for ~26s.
219
+ *
220
+ * Safe to call multiple times (no-op after the first successful call).
221
+ */
222
+ export async function disposeSDK(): Promise<void> {
223
+ if (!sdkReadyPromise) return;
224
+ try {
225
+ const sdk = await sdkReadyPromise;
226
+ sdk.dispose();
227
+ } catch {
228
+ // ignore errors during cleanup
229
+ } finally {
230
+ sdkReadyPromise = null;
231
+ currentOpts = {};
232
+ }
199
233
  }
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,2 @@
1
+ export { createSecureStorage } from './storage-factory';
2
+ export type { ISecureStorage, SecureStorageBackend } from './types';
@@ -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
+ };