@onekeyfe/hardware-cli 1.1.26-alpha.8 → 1.1.26-patch.1
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/dist/pinentry.d.ts +24 -0
- package/dist/pinentry.js +113 -0
- package/dist/sdk.js +2 -64
- package/jest.config.js +4 -0
- package/package.json +8 -7
- package/src/__tests__/pinentry.test.ts +181 -0
- package/src/pinentry.ts +138 -0
- package/src/sdk.ts +5 -90
- package/tsconfig.json +1 -1
|
@@ -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>;
|
package/dist/pinentry.js
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hardware-cli",
|
|
3
|
-
"version": "1.1.26-
|
|
3
|
+
"version": "1.1.26-patch.1",
|
|
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-
|
|
33
|
-
"@onekeyfe/hd-core": "1.1.26-
|
|
34
|
-
"@onekeyfe/hd-shared": "1.1.26-
|
|
35
|
-
"@onekeyfe/hd-transport-usb": "1.1.26-
|
|
33
|
+
"@onekeyfe/hd-common-connect-sdk": "1.1.26-patch.1",
|
|
34
|
+
"@onekeyfe/hd-core": "1.1.26-patch.1",
|
|
35
|
+
"@onekeyfe/hd-shared": "1.1.26-patch.1",
|
|
36
|
+
"@onekeyfe/hd-transport-usb": "1.1.26-patch.1",
|
|
36
37
|
"commander": "^12.0.0"
|
|
37
38
|
},
|
|
38
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "ff7a6e1c632a9409042abeaf3d36b9a5d980b1bd"
|
|
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
|
+
});
|
package/src/pinentry.ts
ADDED
|
@@ -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