@onekeyfe/hardware-cli 1.1.26-alpha.2 → 1.1.26-alpha.20

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.
@@ -0,0 +1,24 @@
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
+ export declare function decodeAssuanData(encoded: string): string;
15
+ export declare function parsePinentryStdout(stdout: string): {
16
+ data?: string;
17
+ cancelled: boolean;
18
+ };
19
+ export declare function findPinentry(): string | null;
20
+ export interface PinentryResult {
21
+ value: string;
22
+ passphraseOnDevice: boolean;
23
+ }
24
+ export declare function promptPassphraseViaPinentry(): Promise<PinentryResult>;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Secure passphrase input via the system pinentry program
4
+ * (pinentry-mac / pinentry / pinentry-gnome3 / pinentry-qt).
5
+ *
6
+ * The passphrase is entered in an OS dialog and returned as a string —
7
+ * it never appears in the terminal, shell history, process arguments,
8
+ * or env vars. Commands are piped over stdin so the dialog configuration
9
+ * (description, prompt) doesn't show up in argv either.
10
+ *
11
+ * Ported from app-monorepo apps/cli/src/utils/pinentry.ts; kept as a
12
+ * standalone module so the pure parser functions can be unit-tested
13
+ * without touching the SDK, child processes, or hardware.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.promptPassphraseViaPinentry = exports.findPinentry = exports.parsePinentryStdout = exports.decodeAssuanData = void 0;
17
+ const node_child_process_1 = require("node:child_process");
18
+ const PINENTRY_PROGRAMS = ['pinentry-mac', 'pinentry', 'pinentry-gnome3', 'pinentry-qt'];
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
+ function decodeAssuanData(encoded) {
24
+ return encoded.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
25
+ }
26
+ exports.decodeAssuanData = decodeAssuanData;
27
+ // Parse pinentry stdout into either a passphrase or a cancellation signal.
28
+ // Handles two edge cases beyond the basic `D <data>` shape:
29
+ // 1. Multi-line D responses — long passphrases past Assuan's ~1000-byte
30
+ // line limit get split across several `D` lines and must be concatenated
31
+ // *before* percent-decoding (a split inside `%XX` would otherwise corrupt
32
+ // the byte).
33
+ // 2. CRLF line endings — pinentry-mac uses LF, but pinentry-gnome3/qt may
34
+ // emit CRLF; splitting on `\r?\n` strips the trailing CR that would
35
+ // otherwise become a literal trailing byte in the passphrase.
36
+ function parsePinentryStdout(stdout) {
37
+ // Pinentry error code 83886179 is the canonical "user cancelled" signal —
38
+ // surfaces either as a non-zero exit or as an ERR line.
39
+ const cancelled = stdout.includes('ERR 83886179') || stdout.includes('Operation cancelled');
40
+ const dataChunks = stdout
41
+ .split(/\r?\n/)
42
+ .filter(l => l.startsWith('D '))
43
+ .map(l => l.slice(2));
44
+ if (dataChunks.length > 0) {
45
+ return { data: decodeAssuanData(dataChunks.join('')), cancelled };
46
+ }
47
+ return { cancelled };
48
+ }
49
+ exports.parsePinentryStdout = parsePinentryStdout;
50
+ function findPinentry() {
51
+ for (const prog of PINENTRY_PROGRAMS) {
52
+ try {
53
+ const result = (0, node_child_process_1.execFileSync)('which', [prog], {
54
+ encoding: 'utf-8',
55
+ timeout: 2000,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ });
58
+ if (result.trim())
59
+ return prog;
60
+ }
61
+ catch {
62
+ // Program not found — try the next one.
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ exports.findPinentry = findPinentry;
68
+ // CLI-variant policy differs from app-monorepo: we fall back to on-device
69
+ // entry instead of rejecting, because `onekey-hw` is used by AI agents that
70
+ // can't recover from a thrown error mid-flow. The device screen is always
71
+ // a valid second path.
72
+ function promptPassphraseViaPinentry() {
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
+ const commands = [
81
+ 'SETDESC OneKey Hardware Wallet',
82
+ 'SETPROMPT Enter passphrase',
83
+ 'GETPIN',
84
+ 'BYE',
85
+ ].join('\n');
86
+ const child = (0, node_child_process_1.execFile)(pinentryBin, [], { timeout: 120000, encoding: 'utf-8' }, (error, stdout) => {
87
+ const { data, cancelled } = parsePinentryStdout(stdout ?? '');
88
+ if (error) {
89
+ if (error.killed || cancelled) {
90
+ process.stderr.write('[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n');
91
+ resolve({ value: '', passphraseOnDevice: true });
92
+ return;
93
+ }
94
+ reject(error);
95
+ return;
96
+ }
97
+ if (data !== undefined) {
98
+ resolve({ value: data, passphraseOnDevice: false });
99
+ return;
100
+ }
101
+ if (cancelled) {
102
+ process.stderr.write('[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n');
103
+ resolve({ value: '', passphraseOnDevice: true });
104
+ return;
105
+ }
106
+ // Empty passphrase (OK pressed with no input) — on-device fallback.
107
+ resolve({ value: '', passphraseOnDevice: true });
108
+ });
109
+ child.stdin?.write(commands);
110
+ child.stdin?.end();
111
+ });
112
+ }
113
+ exports.promptPassphraseViaPinentry = promptPassphraseViaPinentry;
package/dist/sdk.js CHANGED
@@ -37,10 +37,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.disposeSDK = exports.createSDK = void 0;
40
- const node_child_process_1 = require("node:child_process");
41
40
  const readline = __importStar(require("node:readline"));
42
41
  const hd_common_connect_sdk_1 = __importDefault(require("@onekeyfe/hd-common-connect-sdk"));
43
42
  const hd_core_1 = require("@onekeyfe/hd-core");
43
+ const pinentry_1 = require("./pinentry");
44
44
  /**
45
45
  * Current per-invocation CLI options. Event handlers read from this object
46
46
  * so that invoking createSDK() with different opts never results in stale
@@ -62,68 +62,6 @@ let currentOpts = {};
62
62
  */
63
63
  let sdkReadyPromise = null;
64
64
  // ---------------------------------------------------------------------------
65
- // Pinentry — secure passphrase input via native OS dialog
66
- // ---------------------------------------------------------------------------
67
- const PINENTRY_PROGRAMS = ['pinentry-mac', 'pinentry', 'pinentry-gnome3', 'pinentry-qt'];
68
- function findPinentry() {
69
- for (const prog of PINENTRY_PROGRAMS) {
70
- try {
71
- const result = (0, node_child_process_1.execFileSync)('which', [prog], {
72
- encoding: 'utf-8',
73
- timeout: 2000,
74
- stdio: ['pipe', 'pipe', 'pipe'],
75
- });
76
- if (result.trim())
77
- return prog;
78
- }
79
- catch {
80
- // not found
81
- }
82
- }
83
- return null;
84
- }
85
- function promptPassphraseViaPinentry() {
86
- return new Promise((resolve, reject) => {
87
- const pinentryBin = findPinentry();
88
- if (!pinentryBin) {
89
- process.stderr.write('[onekey-hw] No pinentry found, falling back to on-device entry.\n');
90
- resolve({ value: '', passphraseOnDevice: true });
91
- return;
92
- }
93
- const commands = [
94
- 'SETDESC OneKey Hardware Wallet',
95
- 'SETPROMPT Enter passphrase',
96
- 'GETPIN',
97
- 'BYE',
98
- ].join('\n');
99
- const child = (0, node_child_process_1.execFile)(pinentryBin, [], { timeout: 120000, encoding: 'utf-8' }, (error, stdout) => {
100
- if (error) {
101
- if (error.killed || (stdout && stdout.includes('ERR 83886179'))) {
102
- process.stderr.write('[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n');
103
- resolve({ value: '', passphraseOnDevice: true });
104
- return;
105
- }
106
- reject(error);
107
- return;
108
- }
109
- const dataLine = stdout.split('\n').find(l => l.startsWith('D '));
110
- if (dataLine) {
111
- resolve({ value: dataLine.slice(2), passphraseOnDevice: false });
112
- return;
113
- }
114
- if (stdout.includes('ERR 83886179') || stdout.includes('Operation cancelled')) {
115
- process.stderr.write('[onekey-hw] Passphrase entry cancelled, falling back to on-device.\n');
116
- resolve({ value: '', passphraseOnDevice: true });
117
- return;
118
- }
119
- // Empty passphrase — on-device
120
- resolve({ value: '', passphraseOnDevice: true });
121
- });
122
- child.stdin?.write(commands);
123
- child.stdin?.end();
124
- });
125
- }
126
- // ---------------------------------------------------------------------------
127
65
  // Interactive prompts
128
66
  // ---------------------------------------------------------------------------
129
67
  /**
@@ -136,7 +74,7 @@ function resolvePassphraseByChoice(choice) {
136
74
  if (choice === '1')
137
75
  return Promise.resolve({ value: '', passphraseOnDevice: false });
138
76
  if (choice === '2')
139
- return promptPassphraseViaPinentry();
77
+ return (0, pinentry_1.promptPassphraseViaPinentry)();
140
78
  return Promise.resolve({ value: '', passphraseOnDevice: true });
141
79
  }
142
80
  function promptPassphraseMode() {
package/jest.config.js ADDED
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ preset: '../../jest.config.js',
3
+ testEnvironment: 'node',
4
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hardware-cli",
3
- "version": "1.1.26-alpha.2",
3
+ "version": "1.1.26-alpha.20",
4
4
  "description": "OneKey hardware wallet CLI for testing device communication",
5
5
  "author": "OneKey",
6
6
  "license": "Apache-2.0",
@@ -26,14 +26,15 @@
26
26
  "get-address": "node dist/cli.js get-address",
27
27
  "ping": "node dist/cli.js ping",
28
28
  "lint": "eslint .",
29
- "lint:fix": "eslint . --fix"
29
+ "lint:fix": "eslint . --fix",
30
+ "test": "jest"
30
31
  },
31
32
  "dependencies": {
32
- "@onekeyfe/hd-common-connect-sdk": "1.1.26-alpha.2",
33
- "@onekeyfe/hd-core": "1.1.26-alpha.2",
34
- "@onekeyfe/hd-shared": "1.1.26-alpha.2",
35
- "@onekeyfe/hd-transport-usb": "1.1.26-alpha.2",
33
+ "@onekeyfe/hd-common-connect-sdk": "1.1.26-alpha.20",
34
+ "@onekeyfe/hd-core": "1.1.26-alpha.20",
35
+ "@onekeyfe/hd-shared": "1.1.26-alpha.20",
36
+ "@onekeyfe/hd-transport-usb": "1.1.26-alpha.20",
36
37
  "commander": "^12.0.0"
37
38
  },
38
- "gitHead": "ca94380e4128cb7d3495d3b7b8bfed40a50d4646"
39
+ "gitHead": "074a756ea3d21168b625a0cdbca886c0cc21fe61"
39
40
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Unit tests for pinentry stdout parsing.
3
+ *
4
+ * These tests exercise the pure Assuan-protocol parser — no hardware,
5
+ * no child process, no keychain. They catch the class of bugs that
6
+ * silently corrupt a user's passphrase (e.g. losing `%` characters,
7
+ * mis-splitting multi-line responses, trailing CR from CRLF shells).
8
+ *
9
+ * Mirrors app-monorepo/apps/cli/src/__tests__/pinentry.test.ts so both
10
+ * CLIs stay aligned on the same decoding semantics.
11
+ */
12
+
13
+ import { decodeAssuanData, parsePinentryStdout } from '../pinentry';
14
+
15
+ describe('decodeAssuanData', () => {
16
+ it('passes plain ASCII through unchanged', () => {
17
+ expect(decodeAssuanData('hello-world')).toBe('hello-world');
18
+ });
19
+
20
+ it('decodes a literal percent (%25 -> %)', () => {
21
+ // pinentry sends `D a%25b%25c` for the user input `a%b%c`
22
+ expect(decodeAssuanData('a%25b%25c')).toBe('a%b%c');
23
+ });
24
+
25
+ it('decodes CR (%0D) and LF (%0A)', () => {
26
+ expect(decodeAssuanData('line1%0Dline2%0Aline3')).toBe('line1\rline2\nline3');
27
+ });
28
+
29
+ it('handles trailing percent encoding', () => {
30
+ expect(decodeAssuanData('end%25')).toBe('end%');
31
+ });
32
+
33
+ it('handles uppercase and lowercase hex', () => {
34
+ expect(decodeAssuanData('%2a%2A')).toBe('**');
35
+ });
36
+
37
+ it('leaves a bare % (not followed by 2 hex chars) untouched', () => {
38
+ // pinentry never produces this, but the decoder must not corrupt it.
39
+ expect(decodeAssuanData('100%')).toBe('100%');
40
+ expect(decodeAssuanData('%2')).toBe('%2');
41
+ expect(decodeAssuanData('%zz')).toBe('%zz');
42
+ });
43
+
44
+ it('leaves an empty string alone', () => {
45
+ expect(decodeAssuanData('')).toBe('');
46
+ });
47
+
48
+ // Assuan only percent-encodes %, CR, and LF. Every other byte — including
49
+ // multi-byte UTF-8 sequences, extended ASCII, spaces, symbols — goes over
50
+ // the D line untouched. These tests mirror the real passphrases exercised
51
+ // by expo-example/TestSpecialPassphraseWallet, so a decoder change that
52
+ // accidentally mangles non-ASCII input fails here instead of only in a
53
+ // hardware-required integration run.
54
+ it('passes UTF-8 multi-byte scripts through unchanged', () => {
55
+ expect(decodeAssuanData('你好passphrase')).toBe('你好passphrase');
56
+ expect(decodeAssuanData('私のパスワード')).toBe('私のパスワード');
57
+ expect(decodeAssuanData('myسياسةpassphrase')).toBe('myسياسةpassphrase');
58
+ });
59
+
60
+ it('passes extended ASCII and accented chars unchanged', () => {
61
+ expect(decodeAssuanData('¥Øÿ')).toBe('¥Øÿ');
62
+ expect(decodeAssuanData('P@sswôrd€')).toBe('P@sswôrd€');
63
+ expect(decodeAssuanData('mi política de frase de contraseña')).toBe(
64
+ 'mi política de frase de contraseña'
65
+ );
66
+ });
67
+
68
+ it('preserves leading and trailing spaces', () => {
69
+ // Regression guard: a .trim() added "defensively" anywhere in the pipe
70
+ // would derive the wrong passphraseState for Wallet-4 and silently
71
+ // unlock a different hidden wallet. expo-example Wallet-4 exercises
72
+ // exactly this passphrase.
73
+ expect(decodeAssuanData(' My Passphrase ')).toBe(' My Passphrase ');
74
+ });
75
+
76
+ it('passes a multi-script mixed passphrase through unchanged', () => {
77
+ // Matches expo-example Wallet-1 — the "everything all at once" case.
78
+ const mixed = 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵';
79
+ expect(decodeAssuanData(mixed)).toBe(mixed);
80
+ });
81
+ });
82
+
83
+ describe('parsePinentryStdout', () => {
84
+ it('parses a real pinentry-mac success response', () => {
85
+ const stdout = 'OK Pleased to meet you, process 38946\nOK\nOK\nD a%25b%25c\nOK\n';
86
+ expect(parsePinentryStdout(stdout)).toEqual({
87
+ data: 'a%b%c',
88
+ cancelled: false,
89
+ });
90
+ });
91
+
92
+ it('returns no data and cancelled=false when user clicks OK on empty', () => {
93
+ // Pinentry omits the D line entirely when the input is empty.
94
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nOK\n';
95
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: false });
96
+ });
97
+
98
+ it('flags cancellation via ERR 83886179', () => {
99
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nERR 83886179 Operation cancelled\n';
100
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
101
+ });
102
+
103
+ it('concatenates multi-line D responses before decoding', () => {
104
+ // User typed `first-half-with-%-end` (literal `%`). Pinentry encoded
105
+ // `%` as `%25` and the line-length limit happened to split right inside
106
+ // that triple — `%2` ends line 1, `5` starts line 2. We must concat
107
+ // raw chunks *first*, then decode — otherwise `%2` alone is not a
108
+ // valid `%XX` triple and the `%` would be permanently lost.
109
+ const stdout = ['OK', 'D first-half-with-%2', 'D 5-end', 'OK'].join('\n');
110
+ expect(parsePinentryStdout(stdout)).toEqual({
111
+ data: 'first-half-with-%-end',
112
+ cancelled: false,
113
+ });
114
+ });
115
+
116
+ it('also concatenates D chunks that do not split inside %XX', () => {
117
+ const stdout = ['OK', 'D part-one-', 'D part-two', 'OK'].join('\n');
118
+ expect(parsePinentryStdout(stdout)).toEqual({
119
+ data: 'part-one-part-two',
120
+ cancelled: false,
121
+ });
122
+ });
123
+
124
+ it('handles CRLF line endings (pinentry-gnome3/qt)', () => {
125
+ const stdout = 'OK Pleased\r\nOK\r\nOK\r\nD secret%25here\r\nOK\r\n';
126
+ expect(parsePinentryStdout(stdout)).toEqual({
127
+ data: 'secret%here',
128
+ cancelled: false,
129
+ });
130
+ });
131
+
132
+ it('decodes CR/LF inside the passphrase (theoretical)', () => {
133
+ const stdout = 'OK\nD line1%0Dline2%0Aline3\nOK\n';
134
+ expect(parsePinentryStdout(stdout)).toEqual({
135
+ data: 'line1\rline2\nline3',
136
+ cancelled: false,
137
+ });
138
+ });
139
+
140
+ it('flags cancellation via "Operation cancelled" message', () => {
141
+ // Some pinentry variants surface cancellation without the canonical
142
+ // error code — just the human-readable string.
143
+ const stdout = 'OK\nOK\nERR Operation cancelled\n';
144
+ expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
145
+ });
146
+
147
+ it('handles empty stdout without throwing', () => {
148
+ expect(parsePinentryStdout('')).toEqual({ cancelled: false });
149
+ });
150
+
151
+ // End-to-end sanity over a full stdout frame carrying the real-world
152
+ // passphrases exercised by expo-example. Guards the `D ` prefix strip
153
+ // (`.slice(2)`) against any future refactor that would drop a byte from
154
+ // the leading space (e.g. switching to `.slice(1).trimStart()`).
155
+ it('parses a stdout frame carrying a UTF-8 passphrase', () => {
156
+ const stdout = 'OK Pleased to meet you\nOK\nOK\nD 你好passphrase\nOK\n';
157
+ expect(parsePinentryStdout(stdout)).toEqual({
158
+ data: '你好passphrase',
159
+ cancelled: false,
160
+ });
161
+ });
162
+
163
+ it('parses a stdout frame with surrounding spaces in the passphrase', () => {
164
+ const stdout = 'OK\nOK\nOK\nD My Passphrase \nOK\n';
165
+ expect(parsePinentryStdout(stdout)).toEqual({
166
+ data: ' My Passphrase ',
167
+ cancelled: false,
168
+ });
169
+ });
170
+
171
+ it('parses a stdout frame with a mixed-script passphrase', () => {
172
+ // expo-example Wallet-1 — guards that neither split nor slice corrupts
173
+ // surrogate pairs (e.g. `鼵` U+9E35 fits in a single UTF-16 unit, but
174
+ // `맪` U+B9AA also does; kept together here as a smoke test).
175
+ const stdout = 'OK\nD Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵\nOK\n';
176
+ expect(parsePinentryStdout(stdout)).toEqual({
177
+ data: 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵',
178
+ cancelled: false,
179
+ });
180
+ });
181
+ });
@@ -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
@@ -9,12 +9,14 @@
9
9
  * preloaded via preloadSessionCache on next invocation
10
10
  */
11
11
 
12
- import { execFile, execFileSync } from 'node:child_process';
13
12
  import * as readline from 'node:readline';
14
13
  import HardwareSDK from '@onekeyfe/hd-common-connect-sdk';
15
14
  import { DEVICE, UI_EVENT, UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core';
16
15
 
16
+ import { promptPassphraseViaPinentry } from './pinentry';
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;
@@ -44,88 +46,6 @@ let currentOpts: SDKOptions = {};
44
46
  */
45
47
  let sdkReadyPromise: Promise<typeof HardwareSDK> | null = null;
46
48
 
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
49
  // ---------------------------------------------------------------------------
130
50
  // Interactive prompts
131
51
  // ---------------------------------------------------------------------------
@@ -136,18 +56,13 @@ function promptPassphraseViaPinentry(): Promise<{
136
56
  * 2. Hidden wallet — enter passphrase via pinentry (secure OS dialog)
137
57
  * 3. Hidden wallet — enter passphrase on device screen
138
58
  */
139
- function resolvePassphraseByChoice(
140
- choice: '1' | '2' | '3'
141
- ): Promise<{ value: string; passphraseOnDevice: boolean }> {
59
+ function resolvePassphraseByChoice(choice: '1' | '2' | '3'): Promise<PinentryResult> {
142
60
  if (choice === '1') return Promise.resolve({ value: '', passphraseOnDevice: false });
143
61
  if (choice === '2') return promptPassphraseViaPinentry();
144
62
  return Promise.resolve({ value: '', passphraseOnDevice: true });
145
63
  }
146
64
 
147
- function promptPassphraseMode(): Promise<{
148
- value: string;
149
- passphraseOnDevice: boolean;
150
- }> {
65
+ function promptPassphraseMode(): Promise<PinentryResult> {
151
66
  if (!process.stdin.isTTY) {
152
67
  return Promise.resolve({ value: '', passphraseOnDevice: true });
153
68
  }
package/tsconfig.json CHANGED
@@ -12,5 +12,5 @@
12
12
  "declaration": true
13
13
  },
14
14
  "include": ["src"],
15
- "exclude": ["node_modules", "dist"]
15
+ "exclude": ["node_modules", "dist", "src/**/__tests__/**", "src/**/*.test.ts"]
16
16
  }