@onekeyfe/hardware-cli 1.1.25 → 1.1.26-alpha.10
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/pinentry.d.ts +24 -0
- package/dist/pinentry.js +118 -0
- package/dist/sdk.d.ts +15 -5
- package/dist/sdk.js +175 -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 +17 -14
- package/src/__tests__/pinentry.test.ts +185 -0
- package/src/chains.ts +229 -85
- package/src/cli.ts +620 -297
- package/src/pinentry.ts +146 -0
- package/src/sdk.ts +161 -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
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
* Load passphraseState + sessionId from keychain and call preloadSessionCache.
|
|
12
|
+
* Non-fatal: returns the loaded passphraseState or undefined.
|
|
13
|
+
*/
|
|
14
|
+
export declare function preloadSessionFromKeychain(deviceId: string): Promise<string | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* Save passphraseState + sessionId to keychain for next CLI invocation.
|
|
17
|
+
*/
|
|
18
|
+
export declare function saveSessionToKeychain(deviceId: string, passphraseState: string, sessionId: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Clear cached session from keychain.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clearSessionFromKeychain(deviceId?: string): Promise<void>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Passphrase session management for hd-cli.
|
|
4
|
+
*
|
|
5
|
+
* Aligns with app-monorepo's CLI pattern:
|
|
6
|
+
* Login: getPassphraseState → passphraseState + sessionId → keychain
|
|
7
|
+
* Command: keychain → preloadSessionCache → SDK call (no passphrase prompt)
|
|
8
|
+
* Stale: error 112 → clear keychain → re-prompt → retry
|
|
9
|
+
* Logout: keychain delete
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.clearSessionFromKeychain = exports.saveSessionToKeychain = exports.preloadSessionFromKeychain = void 0;
|
|
13
|
+
const hd_core_1 = require("@onekeyfe/hd-core");
|
|
14
|
+
const storage_1 = require("./storage");
|
|
15
|
+
// Keychain key format: scoped by deviceId for multi-device support
|
|
16
|
+
function psKey(deviceId) {
|
|
17
|
+
return `onekey-hw:${deviceId}/passphrase-state`;
|
|
18
|
+
}
|
|
19
|
+
function sidKey(deviceId) {
|
|
20
|
+
return `onekey-hw:${deviceId}/session-id`;
|
|
21
|
+
}
|
|
22
|
+
let storageInstance = null;
|
|
23
|
+
function getStorage() {
|
|
24
|
+
if (!storageInstance) {
|
|
25
|
+
storageInstance = (0, storage_1.createSecureStorage)();
|
|
26
|
+
}
|
|
27
|
+
return storageInstance;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load passphraseState + sessionId from keychain and call preloadSessionCache.
|
|
31
|
+
* Non-fatal: returns the loaded passphraseState or undefined.
|
|
32
|
+
*/
|
|
33
|
+
async function preloadSessionFromKeychain(deviceId) {
|
|
34
|
+
try {
|
|
35
|
+
const storage = getStorage();
|
|
36
|
+
const [psBuf, sidBuf] = await Promise.all([
|
|
37
|
+
storage.get(psKey(deviceId)),
|
|
38
|
+
storage.get(sidKey(deviceId)),
|
|
39
|
+
]);
|
|
40
|
+
if (psBuf && sidBuf) {
|
|
41
|
+
const passphraseState = psBuf.toString('utf-8');
|
|
42
|
+
const sessionId = sidBuf.toString('utf-8');
|
|
43
|
+
(0, hd_core_1.preloadSessionCache)(deviceId, passphraseState, sessionId);
|
|
44
|
+
return passphraseState;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Non-fatal — fall through to passphrase prompt
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
exports.preloadSessionFromKeychain = preloadSessionFromKeychain;
|
|
53
|
+
/**
|
|
54
|
+
* Save passphraseState + sessionId to keychain for next CLI invocation.
|
|
55
|
+
*/
|
|
56
|
+
async function saveSessionToKeychain(deviceId, passphraseState, sessionId) {
|
|
57
|
+
try {
|
|
58
|
+
const storage = getStorage();
|
|
59
|
+
await Promise.all([
|
|
60
|
+
storage.set(psKey(deviceId), Buffer.from(passphraseState, 'utf-8')),
|
|
61
|
+
storage.set(sidKey(deviceId), Buffer.from(sessionId, 'utf-8')),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Non-fatal — session still works in-memory for this invocation
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.saveSessionToKeychain = saveSessionToKeychain;
|
|
69
|
+
/**
|
|
70
|
+
* Clear cached session from keychain.
|
|
71
|
+
*/
|
|
72
|
+
async function clearSessionFromKeychain(deviceId) {
|
|
73
|
+
try {
|
|
74
|
+
const storage = getStorage();
|
|
75
|
+
if (deviceId) {
|
|
76
|
+
await Promise.allSettled([storage.delete(psKey(deviceId)), storage.delete(sidKey(deviceId))]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Non-fatal
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.clearSessionFromKeychain = clearSessionFromKeychain;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSecureStorage = void 0;
|
|
4
|
+
var storage_factory_1 = require("./storage-factory");
|
|
5
|
+
Object.defineProperty(exports, "createSecureStorage", { enumerable: true, get: function () { return storage_factory_1.createSecureStorage; } });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultProcessRunner = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
exports.defaultProcessRunner = {
|
|
6
|
+
execFileAsync(cmd, args) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
(0, node_child_process_1.execFile)(cmd, args, (error, stdout, stderr) => {
|
|
9
|
+
if (error) {
|
|
10
|
+
error.stderr = stderr;
|
|
11
|
+
reject(error);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve({ stdout, stderr });
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
spawnWithStdin(cmd, args, input) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
child.stdout.on('data', (data) => {
|
|
24
|
+
stdout += data.toString();
|
|
25
|
+
});
|
|
26
|
+
child.stderr.on('data', (data) => {
|
|
27
|
+
stderr += data.toString();
|
|
28
|
+
});
|
|
29
|
+
child.on('error', reject);
|
|
30
|
+
child.on('close', code => {
|
|
31
|
+
if (code !== 0) {
|
|
32
|
+
const error = new Error(`Process exited with code ${code}`);
|
|
33
|
+
error.code = code ?? 1;
|
|
34
|
+
error.stderr = stderr;
|
|
35
|
+
reject(error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve({ stdout, stderr });
|
|
39
|
+
});
|
|
40
|
+
child.stdin.write(`${input}\n`);
|
|
41
|
+
child.stdin.end();
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { IProcessRunner, ISecureStorage, SecureStorageBackend } from './types';
|
|
3
|
+
export declare class LinuxSecureStorage implements ISecureStorage {
|
|
4
|
+
private readonly runner;
|
|
5
|
+
constructor(runner?: IProcessRunner);
|
|
6
|
+
getBackendType(): SecureStorageBackend;
|
|
7
|
+
set(key: string, value: Buffer): Promise<void>;
|
|
8
|
+
get(key: string): Promise<Buffer | null>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
private isItemNotFound;
|
|
11
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LinuxSecureStorage = void 0;
|
|
4
|
+
const process_utils_1 = require("./process-utils");
|
|
5
|
+
const SERVICE_NAME = 'onekey-hw-cli';
|
|
6
|
+
const SECRET_LABEL = 'OneKey HW CLI Secret';
|
|
7
|
+
class LinuxSecureStorage {
|
|
8
|
+
constructor(runner = process_utils_1.defaultProcessRunner) {
|
|
9
|
+
this.runner = runner;
|
|
10
|
+
}
|
|
11
|
+
getBackendType() {
|
|
12
|
+
return 'linux-secret-service';
|
|
13
|
+
}
|
|
14
|
+
async set(key, value) {
|
|
15
|
+
await this.runner.spawnWithStdin('secret-tool', ['store', '--label', SECRET_LABEL, 'service', SERVICE_NAME, 'account', key], value.toString('hex'));
|
|
16
|
+
}
|
|
17
|
+
async get(key) {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await this.runner.execFileAsync('secret-tool', [
|
|
20
|
+
'lookup',
|
|
21
|
+
'service',
|
|
22
|
+
SERVICE_NAME,
|
|
23
|
+
'account',
|
|
24
|
+
key,
|
|
25
|
+
]);
|
|
26
|
+
const hex = stdout.trim();
|
|
27
|
+
return hex ? Buffer.from(hex, 'hex') : null;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (this.isItemNotFound(error))
|
|
31
|
+
return null;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async delete(key) {
|
|
36
|
+
try {
|
|
37
|
+
await this.runner.execFileAsync('secret-tool', [
|
|
38
|
+
'clear',
|
|
39
|
+
'service',
|
|
40
|
+
SERVICE_NAME,
|
|
41
|
+
'account',
|
|
42
|
+
key,
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (this.isItemNotFound(error))
|
|
47
|
+
return;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
isItemNotFound(error) {
|
|
52
|
+
const err = error;
|
|
53
|
+
const stderr = err.stderr ?? err.message ?? '';
|
|
54
|
+
return (stderr.includes('No such secret item') ||
|
|
55
|
+
stderr.includes('Object does not exist') ||
|
|
56
|
+
stderr.includes('could not find'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.LinuxSecureStorage = LinuxSecureStorage;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { IProcessRunner, ISecureStorage, SecureStorageBackend } from './types';
|
|
3
|
+
export declare class MacOSSecureStorage implements ISecureStorage {
|
|
4
|
+
private readonly runner;
|
|
5
|
+
constructor(runner?: IProcessRunner);
|
|
6
|
+
getBackendType(): SecureStorageBackend;
|
|
7
|
+
set(key: string, value: Buffer): Promise<void>;
|
|
8
|
+
get(key: string): Promise<Buffer | null>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
private isItemNotFound;
|
|
11
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MacOSSecureStorage = void 0;
|
|
4
|
+
const process_utils_1 = require("./process-utils");
|
|
5
|
+
const SERVICE_NAME = 'onekey-hw-cli';
|
|
6
|
+
class MacOSSecureStorage {
|
|
7
|
+
constructor(runner = process_utils_1.defaultProcessRunner) {
|
|
8
|
+
this.runner = runner;
|
|
9
|
+
}
|
|
10
|
+
getBackendType() {
|
|
11
|
+
return 'macos-keychain';
|
|
12
|
+
}
|
|
13
|
+
async set(key, value) {
|
|
14
|
+
const hex = value.toString('hex');
|
|
15
|
+
// Use `security -i` (interactive/batch mode): the tool parses commands
|
|
16
|
+
// from stdin internally instead of re-spawning a sub-process per command,
|
|
17
|
+
// so the password argument never appears in /proc or `ps aux` output.
|
|
18
|
+
//
|
|
19
|
+
// Keys are of the form `onekey-hw:<deviceId>/<slot>` and hex values only
|
|
20
|
+
// contain 0-9a-f, so neither contains shell metacharacters that would
|
|
21
|
+
// break the simple quoting security's parser expects.
|
|
22
|
+
const cmd = `add-generic-password -s "${SERVICE_NAME}" -a "${key}" -w "${hex}" -U`;
|
|
23
|
+
await this.runner.spawnWithStdin('security', ['-i'], cmd);
|
|
24
|
+
}
|
|
25
|
+
async get(key) {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await this.runner.execFileAsync('security', [
|
|
28
|
+
'find-generic-password',
|
|
29
|
+
'-s',
|
|
30
|
+
SERVICE_NAME,
|
|
31
|
+
'-a',
|
|
32
|
+
key,
|
|
33
|
+
'-w',
|
|
34
|
+
]);
|
|
35
|
+
const hex = stdout.trim();
|
|
36
|
+
return hex ? Buffer.from(hex, 'hex') : null;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (this.isItemNotFound(error))
|
|
40
|
+
return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async delete(key) {
|
|
45
|
+
try {
|
|
46
|
+
await this.runner.execFileAsync('security', [
|
|
47
|
+
'delete-generic-password',
|
|
48
|
+
'-s',
|
|
49
|
+
SERVICE_NAME,
|
|
50
|
+
'-a',
|
|
51
|
+
key,
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (this.isItemNotFound(error))
|
|
56
|
+
return;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
isItemNotFound(error) {
|
|
61
|
+
const err = error;
|
|
62
|
+
return err.code === 44 || err.stderr?.includes('could not be found') === true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.MacOSSecureStorage = MacOSSecureStorage;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSecureStorage = void 0;
|
|
4
|
+
const secure_storage_linux_1 = require("./secure-storage.linux");
|
|
5
|
+
const secure_storage_macos_1 = require("./secure-storage.macos");
|
|
6
|
+
function createSecureStorage(platform = process.platform) {
|
|
7
|
+
if (platform === 'darwin')
|
|
8
|
+
return new secure_storage_macos_1.MacOSSecureStorage();
|
|
9
|
+
if (platform === 'linux')
|
|
10
|
+
return new secure_storage_linux_1.LinuxSecureStorage();
|
|
11
|
+
throw new Error(`Secure storage is not supported on platform "${platform}". ` +
|
|
12
|
+
'Use macOS Keychain or Linux Secret Service.');
|
|
13
|
+
}
|
|
14
|
+
exports.createSecureStorage = createSecureStorage;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
export type SecureStorageBackend = 'macos-keychain' | 'linux-secret-service';
|
|
3
|
+
export interface ISecureStorage {
|
|
4
|
+
getBackendType(): SecureStorageBackend;
|
|
5
|
+
get(key: string): Promise<Buffer | null>;
|
|
6
|
+
set(key: string, value: Buffer): Promise<void>;
|
|
7
|
+
delete(key: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface IProcessRunner {
|
|
10
|
+
execFileAsync(cmd: string, args: string[]): Promise<{
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}>;
|
|
14
|
+
spawnWithStdin(cmd: string, args: string[], input: string): Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/hardware-cli",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "OneKey hardware wallet CLI for
|
|
3
|
+
"version": "1.1.26-alpha.10",
|
|
4
|
+
"description": "OneKey hardware wallet CLI for testing device communication",
|
|
5
5
|
"author": "OneKey",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
|
|
@@ -18,20 +18,23 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"build": "tsc && node -e \"const f='dist/cli.js';const c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\"",
|
|
22
|
+
"prestart": "tsc",
|
|
23
|
+
"start": "node dist/cli.js",
|
|
24
|
+
"search": "node dist/cli.js search",
|
|
25
|
+
"get-features": "node dist/cli.js get-features",
|
|
26
|
+
"get-address": "node dist/cli.js get-address",
|
|
27
|
+
"ping": "node dist/cli.js ping",
|
|
23
28
|
"lint": "eslint .",
|
|
24
|
-
"lint:fix": "eslint . --fix"
|
|
29
|
+
"lint:fix": "eslint . --fix",
|
|
30
|
+
"test": "jest"
|
|
25
31
|
},
|
|
26
32
|
"dependencies": {
|
|
27
|
-
"@onekeyfe/hd-common-connect-sdk": "1.1.
|
|
28
|
-
"@onekeyfe/hd-core": "1.1.
|
|
29
|
-
"@onekeyfe/hd-shared": "1.1.
|
|
30
|
-
"@onekeyfe/hd-transport-usb": "1.1.
|
|
31
|
-
"commander": "^12.0.0"
|
|
32
|
-
"eventemitter2": "^6.4.9",
|
|
33
|
-
"lodash": "^4.17.21",
|
|
34
|
-
"tslib": "^2.6.0"
|
|
33
|
+
"@onekeyfe/hd-common-connect-sdk": "1.1.26-alpha.10",
|
|
34
|
+
"@onekeyfe/hd-core": "1.1.26-alpha.10",
|
|
35
|
+
"@onekeyfe/hd-shared": "1.1.26-alpha.10",
|
|
36
|
+
"@onekeyfe/hd-transport-usb": "1.1.26-alpha.10",
|
|
37
|
+
"commander": "^12.0.0"
|
|
35
38
|
},
|
|
36
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "4cab4ba97dee894aa87145ced1e629c06f0ab8b7"
|
|
37
40
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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(
|
|
27
|
+
'line1\rline2\nline3'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('handles trailing percent encoding', () => {
|
|
32
|
+
expect(decodeAssuanData('end%25')).toBe('end%');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles uppercase and lowercase hex', () => {
|
|
36
|
+
expect(decodeAssuanData('%2a%2A')).toBe('**');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('leaves a bare % (not followed by 2 hex chars) untouched', () => {
|
|
40
|
+
// pinentry never produces this, but the decoder must not corrupt it.
|
|
41
|
+
expect(decodeAssuanData('100%')).toBe('100%');
|
|
42
|
+
expect(decodeAssuanData('%2')).toBe('%2');
|
|
43
|
+
expect(decodeAssuanData('%zz')).toBe('%zz');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('leaves an empty string alone', () => {
|
|
47
|
+
expect(decodeAssuanData('')).toBe('');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Assuan only percent-encodes %, CR, and LF. Every other byte — including
|
|
51
|
+
// multi-byte UTF-8 sequences, extended ASCII, spaces, symbols — goes over
|
|
52
|
+
// the D line untouched. These tests mirror the real passphrases exercised
|
|
53
|
+
// by expo-example/TestSpecialPassphraseWallet, so a decoder change that
|
|
54
|
+
// accidentally mangles non-ASCII input fails here instead of only in a
|
|
55
|
+
// hardware-required integration run.
|
|
56
|
+
it('passes UTF-8 multi-byte scripts through unchanged', () => {
|
|
57
|
+
expect(decodeAssuanData('你好passphrase')).toBe('你好passphrase');
|
|
58
|
+
expect(decodeAssuanData('私のパスワード')).toBe('私のパスワード');
|
|
59
|
+
expect(decodeAssuanData('myسياسةpassphrase')).toBe('myسياسةpassphrase');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('passes extended ASCII and accented chars unchanged', () => {
|
|
63
|
+
expect(decodeAssuanData('¥Øÿ')).toBe('¥Øÿ');
|
|
64
|
+
expect(decodeAssuanData('P@sswôrd€')).toBe('P@sswôrd€');
|
|
65
|
+
expect(decodeAssuanData('mi política de frase de contraseña')).toBe(
|
|
66
|
+
'mi política de frase de contraseña'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves leading and trailing spaces', () => {
|
|
71
|
+
// Regression guard: a .trim() added "defensively" anywhere in the pipe
|
|
72
|
+
// would derive the wrong passphraseState for Wallet-4 and silently
|
|
73
|
+
// unlock a different hidden wallet. expo-example Wallet-4 exercises
|
|
74
|
+
// exactly this passphrase.
|
|
75
|
+
expect(decodeAssuanData(' My Passphrase ')).toBe(' My Passphrase ');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('passes a multi-script mixed passphrase through unchanged', () => {
|
|
79
|
+
// Matches expo-example Wallet-1 — the "everything all at once" case.
|
|
80
|
+
const mixed = 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵';
|
|
81
|
+
expect(decodeAssuanData(mixed)).toBe(mixed);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('parsePinentryStdout', () => {
|
|
86
|
+
it('parses a real pinentry-mac success response', () => {
|
|
87
|
+
const stdout =
|
|
88
|
+
'OK Pleased to meet you, process 38946\nOK\nOK\nD a%25b%25c\nOK\n';
|
|
89
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
90
|
+
data: 'a%b%c',
|
|
91
|
+
cancelled: false,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns no data and cancelled=false when user clicks OK on empty', () => {
|
|
96
|
+
// Pinentry omits the D line entirely when the input is empty.
|
|
97
|
+
const stdout = 'OK Pleased to meet you\nOK\nOK\nOK\n';
|
|
98
|
+
expect(parsePinentryStdout(stdout)).toEqual({ cancelled: false });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flags cancellation via ERR 83886179', () => {
|
|
102
|
+
const stdout =
|
|
103
|
+
'OK Pleased to meet you\nOK\nOK\nERR 83886179 Operation cancelled\n';
|
|
104
|
+
expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('concatenates multi-line D responses before decoding', () => {
|
|
108
|
+
// User typed `first-half-with-%-end` (literal `%`). Pinentry encoded
|
|
109
|
+
// `%` as `%25` and the line-length limit happened to split right inside
|
|
110
|
+
// that triple — `%2` ends line 1, `5` starts line 2. We must concat
|
|
111
|
+
// raw chunks *first*, then decode — otherwise `%2` alone is not a
|
|
112
|
+
// valid `%XX` triple and the `%` would be permanently lost.
|
|
113
|
+
const stdout = ['OK', 'D first-half-with-%2', 'D 5-end', 'OK'].join('\n');
|
|
114
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
115
|
+
data: 'first-half-with-%-end',
|
|
116
|
+
cancelled: false,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('also concatenates D chunks that do not split inside %XX', () => {
|
|
121
|
+
const stdout = ['OK', 'D part-one-', 'D part-two', 'OK'].join('\n');
|
|
122
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
123
|
+
data: 'part-one-part-two',
|
|
124
|
+
cancelled: false,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles CRLF line endings (pinentry-gnome3/qt)', () => {
|
|
129
|
+
const stdout = 'OK Pleased\r\nOK\r\nOK\r\nD secret%25here\r\nOK\r\n';
|
|
130
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
131
|
+
data: 'secret%here',
|
|
132
|
+
cancelled: false,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('decodes CR/LF inside the passphrase (theoretical)', () => {
|
|
137
|
+
const stdout = 'OK\nD line1%0Dline2%0Aline3\nOK\n';
|
|
138
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
139
|
+
data: 'line1\rline2\nline3',
|
|
140
|
+
cancelled: false,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('flags cancellation via "Operation cancelled" message', () => {
|
|
145
|
+
// Some pinentry variants surface cancellation without the canonical
|
|
146
|
+
// error code — just the human-readable string.
|
|
147
|
+
const stdout = 'OK\nOK\nERR Operation cancelled\n';
|
|
148
|
+
expect(parsePinentryStdout(stdout)).toEqual({ cancelled: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles empty stdout without throwing', () => {
|
|
152
|
+
expect(parsePinentryStdout('')).toEqual({ cancelled: false });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// End-to-end sanity over a full stdout frame carrying the real-world
|
|
156
|
+
// passphrases exercised by expo-example. Guards the `D ` prefix strip
|
|
157
|
+
// (`.slice(2)`) against any future refactor that would drop a byte from
|
|
158
|
+
// the leading space (e.g. switching to `.slice(1).trimStart()`).
|
|
159
|
+
it('parses a stdout frame carrying a UTF-8 passphrase', () => {
|
|
160
|
+
const stdout = 'OK Pleased to meet you\nOK\nOK\nD 你好passphrase\nOK\n';
|
|
161
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
162
|
+
data: '你好passphrase',
|
|
163
|
+
cancelled: false,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('parses a stdout frame with surrounding spaces in the passphrase', () => {
|
|
168
|
+
const stdout = 'OK\nOK\nOK\nD My Passphrase \nOK\n';
|
|
169
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
170
|
+
data: ' My Passphrase ',
|
|
171
|
+
cancelled: false,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('parses a stdout frame with a mixed-script passphrase', () => {
|
|
176
|
+
// expo-example Wallet-1 — guards that neither split nor slice corrupts
|
|
177
|
+
// surrogate pairs (e.g. `鼵` U+9E35 fits in a single UTF-16 unit, but
|
|
178
|
+
// `맪` U+B9AA also does; kept together here as a smoke test).
|
|
179
|
+
const stdout = 'OK\nD Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵\nOK\n';
|
|
180
|
+
expect(parsePinentryStdout(stdout)).toEqual({
|
|
181
|
+
data: 'Aa0!)_+맪Ӎ¬}¨¥ϸΔѭЧゞく6鼵',
|
|
182
|
+
cancelled: false,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|