From 364e878e66465c469a7e61f8bc2cc131e6553ef1 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 14 Sep 2026 15:29:57 +0800 Subject: [PATCH 1/3] feat: require biometric auth and interactive TTY for wallet export The wallet export command previously printed the seed phrase unconditionally, allowing non-interactive callers (including AI agents) to silently read it. Guard the command with layered human-presence checks: - Refuse to run unless stdin and stdout are real TTYs, blocking piping, redirection, command substitution, and non-interactive execution. - Require OS biometric authentication when available: Touch ID via LocalAuthentication (macOS), fprintd fingerprint verify (Linux), and Windows Hello via UserConsentVerifier (Windows). - Fall back to a random one-time code typed back within a time limit when biometrics are unavailable. Biometric availability is detected per platform; enrolling-less users fall back to the code challenge rather than being locked out. --- src/commands/wallet.ts | 266 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 264 insertions(+), 2 deletions(-) diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index 336a578..aacc313 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -10,6 +10,9 @@ import { Command } from 'commander' import chalk from 'chalk' import * as readline from 'readline' +import * as os from 'node:os' +import { randomInt } from 'node:crypto' +import { spawnSync } from 'node:child_process' import { generateMnemonic, importMnemonic, @@ -177,8 +180,10 @@ export function registerWalletCommands(program: Command): void { // ── wallet export ─────────────────────────────────────────────────── wallet .command('export') - .description('Display the stored seed phrase') - .action(() => { + .description('Display the stored seed phrase (interactive terminal only)') + .action(async () => { + requireInteractiveTerminal() + const data = loadMnemonic() if (!data) { console.log( @@ -189,6 +194,12 @@ export function registerWalletCommands(program: Command): void { process.exit(1) } + const verified = await promptHumanVerification() + if (!verified) { + console.log(chalk.red('\n Verification failed. Seed phrase not shown.\n')) + process.exit(1) + } + console.log( chalk.yellow.bold( '\n WARNING: Do not share your seed phrase with anyone.' @@ -210,3 +221,254 @@ export function registerWalletCommands(program: Command): void { console.log() }) } + +/** + * Refuse to reveal the seed phrase unless the command is attached to a real + * interactive terminal. This blocks piping, redirection, `echo ... | paytaca`, + * command substitution, and non-interactive agent execution. + */ +function requireInteractiveTerminal(): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log( + chalk.red( + '\nRefusing to export seed phrase: this command must be run directly in an interactive terminal.\n' + + 'It cannot be piped, redirected, or executed by a non-interactive process.\n' + ) + ) + process.exit(1) + } +} + +/** + * Require a live human presence before revealing the seed phrase. Where the OS + * exposes biometric authentication (macOS Touch ID, Linux fprintd, Windows + * Hello), that prompt is used and cannot be driven or dismissed by an automated + * caller. Where biometrics are unavailable, fall back to typing a random + * one-time code, which non-interactive callers cannot answer. + */ +async function promptHumanVerification(): Promise { + const biometric = verifyBiometric() + + if (biometric === 'ok') { + console.log(chalk.green('\n Identity confirmed via biometric authentication.\n')) + return true + } + + if (biometric === 'denied') { + console.log(chalk.red('\n Biometric authentication failed or was cancelled.\n')) + return false + } + + console.log( + chalk.dim(' Biometric authentication not available — falling back to code entry.\n') + ) + + return promptChallenge() +} + +function verifyBiometric(): BiometricResult { + switch (process.platform) { + case 'darwin': + return verifyTouchId() + case 'linux': + return verifyFprintd() + case 'win32': + return verifyWindowsHello() + default: + return 'unavailable' + } +} + +/** + * Random one-time code the operator must type back. Automated callers cannot + * read and answer this prompt, even when a pseudo-terminal is allocated. + */ +async function promptChallenge(): Promise { + const challenge = generateChallenge() + const timeoutMs = 120_000 + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + console.log( + chalk.yellow.bold('\n Human verification required before the seed phrase is revealed.') + ) + console.log(chalk.dim(' Type the code below exactly as shown to confirm you are at the terminal.\n')) + console.log(chalk.cyan.bold(` ${challenge}\n`)) + + const answer = await new Promise((resolve) => { + const timer = setTimeout(() => { + rl.close() + resolve(null) + }, timeoutMs) + + rl.question(' Code: ', (input) => { + clearTimeout(timer) + rl.close() + resolve(input) + }) + }) + + return answer !== null && answer.trim().toUpperCase() === challenge +} + +function generateChallenge(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const group = () => + Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join('') + return `${group()}-${group()}` +} + +type BiometricResult = 'ok' | 'denied' | 'unavailable' | 'error' + +const BIO_OK = 'PAYTACA_BIO_OK' +const BIO_FAIL = 'PAYTACA_BIO_FAIL' +const BIO_UNAVAILABLE = 'PAYTACA_BIO_UNAVAILABLE' + +function classifyBiometricOutput(output: string): BiometricResult { + if (output.includes(BIO_UNAVAILABLE)) return 'unavailable' + if (output.includes(BIO_OK)) return 'ok' + if (output.includes(BIO_FAIL)) return 'denied' + return 'error' +} + +function commandExists(command: string): boolean { + const res = spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }) + return res.status === 0 +} + +// ── macOS: Touch ID via LocalAuthentication ──────────────────────────── + +/** + * JXA script executed by the system `osascript`: bridges to the macOS + * LocalAuthentication framework and blocks on the native Touch ID prompt. + * JXA `console.log` writes to stderr (captured below). + */ +const TOUCH_ID_JXA = ` +ObjC.import('LocalAuthentication') +ObjC.import('Foundation') + +const policy = $.LAPolicyDeviceOwnerAuthenticationWithBiometrics +const ctx = $.LAContext.alloc.init +const err = $() + +if (!ctx.canEvaluatePolicyError(policy, err)) { + console.log('${BIO_UNAVAILABLE}') +} else { + let done = false + let ok = false + ctx.evaluatePolicyLocalizedReasonReply( + policy, + $('Confirm it is you to reveal your Paytaca seed phrase'), + (success) => { + ok = Boolean(success) + done = true + } + ) + const deadline = Date.now() + 120000 + while (!done && Date.now() < deadline) { + $.NSRunLoop.currentRunLoop.runModeBeforeDate( + $.NSDefaultRunLoopMode, + $.NSDate.dateWithTimeIntervalSinceNow(0.25) + ) + } + console.log(done && ok ? '${BIO_OK}' : '${BIO_FAIL}') +} +` + +function verifyTouchId(): BiometricResult { + const res = spawnSync('osascript', ['-l', 'JavaScript', '-e', TOUCH_ID_JXA], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + timeout: 130_000, + }) + + return classifyBiometricOutput(`${res.stderr ?? ''}${res.stdout ?? ''}`) +} + +// ── Linux: fingerprint verification via fprintd ──────────────────────── + +/** + * `fprintd-verify` reads the physical fingerprint reader over D-Bus and exits + * 0 only on a match, so an automated caller cannot satisfy it. Enrollment is + * confirmed first via `fprintd-list` so un-enrolled users fall back to the code + * challenge rather than being locked out. + */ +function verifyFprintd(): BiometricResult { + if (!commandExists('fprintd-verify')) return 'unavailable' + if (!commandExists('fprintd-list')) return 'unavailable' + + const user = os.userInfo().username + + const enrolled = spawnSync('fprintd-list', [user], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + timeout: 15_000, + }) + if (enrolled.error) return 'unavailable' + + const listed = enrolled.stdout ?? '' + if (!listed.trim() || /no fingerprints enrolled/i.test(listed)) return 'unavailable' + + const verify = spawnSync('fprintd-verify', [user], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + timeout: 60_000, + }) + if (verify.status === 0) return 'ok' + return 'denied' +} + +// ── Windows: Windows Hello via UserConsentVerifier ───────────────────── + +/** + * Calls the WinRT UserConsentVerifier through Windows PowerShell, which shows + * the native Windows Hello prompt (face / fingerprint / PIN). The async WinRT + * operation is awaited via System.Runtime.WindowsRuntime. + */ +const WINDOWS_HELLO_PS = ` +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +try { + Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null + $asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { + $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' + })[0] + function Await($op, $type) { + $m = $asTaskGeneric.MakeGenericMethod($type) + $t = $m.Invoke($null, @($op)) + $t.Wait(-1) | Out-Null + $t.Result + } + $Ucv = [Windows.Security.Credentials.UI.UserConsentVerifier, Windows.Security.Credentials.UI, ContentType = WindowsRuntime] + $avail = Await ($Ucv::CheckAvailabilityAsync()) ([Windows.Security.Credentials.UI.UserConsentVerifierAvailability]) + if ($avail -ne [Windows.Security.Credentials.UI.UserConsentVerifierAvailability]::Available) { + Write-Output '${BIO_UNAVAILABLE}' + } else { + $res = Await ($Ucv::RequestVerificationAsync('Confirm it is you to reveal your Paytaca seed phrase')) ([Windows.Security.Credentials.UI.UserConsentVerificationResult]) + if ($res -eq [Windows.Security.Credentials.UI.UserConsentVerificationResult]::Verified) { + Write-Output '${BIO_OK}' + } else { + Write-Output '${BIO_FAIL}' + } + } +} catch { + Write-Output '${BIO_UNAVAILABLE}' +} +` + +function verifyWindowsHello(): BiometricResult { + const res = spawnSync( + 'powershell.exe', + ['-NoProfile', '-Command', WINDOWS_HELLO_PS], + { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + timeout: 130_000, + } + ) + + return classifyBiometricOutput(`${res.stdout ?? ''}${res.stderr ?? ''}`) +} From d4c286df76cc517746f18f5e43c1f70104231647 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 14 Sep 2026 15:31:26 +0800 Subject: [PATCH 2/3] chore: bump version to v0.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6616091..1f4157a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paytaca-cli", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paytaca-cli", - "version": "0.5.2", + "version": "0.6.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", diff --git a/package.json b/package.json index 021d8ca..0fe448a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paytaca-cli", - "version": "0.5.2", + "version": "0.6.0", "description": "Command-line interface for the Paytaca Bitcoin Cash wallet", "type": "module", "main": "dist/index.js", From 3442b8883492a8b7ef54c1f6f145a1051a8ed061 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 14 Sep 2026 15:58:32 +0800 Subject: [PATCH 3/3] refactor: make wallet export guards testable and document requirement Address PR review feedback: - Wrap os.userInfo() in verifyFprintd in try/catch so systems without a passwd entry (containers, CI, minimal systemd units) fall back to the typed challenge instead of crashing. - Extract pure, testable helpers (assertInteractiveTerminal, classifyBiometricOutput, generateChallenge, isChallengeAnswerCorrect) and have the TTY gate throw instead of calling process.exit; the action handler catches and exits, preserving CLI behavior. - Check the biometric failure token before the success token so a failure always wins on a mixed stream. - Document in README that wallet export requires an interactive terminal and biometric authentication, so it cannot be used from scripts or pipes. - Add unit tests covering the security-critical helpers. --- README.md | 6 ++- src/commands/wallet.test.ts | 99 +++++++++++++++++++++++++++++++++++++ src/commands/wallet.ts | 50 +++++++++++++------ 3 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 src/commands/wallet.test.ts diff --git a/README.md b/README.md index 3fca91b..bb24ac2 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,13 @@ paytaca wallet create # Generate a new 12-word seed phrase paytaca wallet create --chipnet # Create on chipnet (testnet) paytaca wallet import # Import an existing seed phrase paytaca wallet info # Show wallet hash, address, and balance -paytaca wallet export # Display the stored seed phrase +paytaca wallet export # Display the stored seed phrase (interactive terminal + biometrics) ``` +> `wallet export` requires an interactive terminal and prompts for biometric +> authentication (Touch ID, fingerprint, or Windows Hello) when available. It +> cannot be used from scripts, pipes, or other non-interactive processes. + ### Balance ```bash diff --git a/src/commands/wallet.test.ts b/src/commands/wallet.test.ts new file mode 100644 index 0000000..d87bd42 --- /dev/null +++ b/src/commands/wallet.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { + assertInteractiveTerminal, + NonInteractiveTerminalError, + classifyBiometricOutput, + generateChallenge, + isChallengeAnswerCorrect, +} from './wallet.js' + +describe('assertInteractiveTerminal', () => { + it('allows execution when both stdin and stdout are TTYs', () => { + expect(() => assertInteractiveTerminal(true, true)).not.toThrow() + }) + + it('refuses when stdin is not a TTY', () => { + expect(() => assertInteractiveTerminal(undefined, true)).toThrow( + NonInteractiveTerminalError + ) + }) + + it('refuses when stdout is not a TTY', () => { + expect(() => assertInteractiveTerminal(true, undefined)).toThrow( + NonInteractiveTerminalError + ) + }) + + it('refuses when neither stream is a TTY', () => { + expect(() => assertInteractiveTerminal(false, false)).toThrow( + NonInteractiveTerminalError + ) + }) +}) + +describe('classifyBiometricOutput', () => { + it('maps the unavailable token', () => { + expect(classifyBiometricOutput('PAYTACA_BIO_UNAVAILABLE')).toBe('unavailable') + }) + + it('maps the success token', () => { + expect(classifyBiometricOutput('PAYTACA_BIO_OK')).toBe('ok') + }) + + it('maps the failure token', () => { + expect(classifyBiometricOutput('PAYTACA_BIO_FAIL')).toBe('denied') + }) + + it('treats unrecognized output as an error', () => { + expect(classifyBiometricOutput('')).toBe('error') + expect(classifyBiometricOutput('some unrelated output')).toBe('error') + }) + + it('lets a failure token win if both fail and ok appear', () => { + expect( + classifyBiometricOutput('PAYTACA_BIO_FAIL\nPAYTACA_BIO_OK') + ).toBe('denied') + }) + + it('does not confuse the unavailable token with success', () => { + expect(classifyBiometricOutput('PAYTACA_BIO_UNAVAILABLE')).not.toBe('ok') + }) +}) + +describe('generateChallenge', () => { + it('produces two 4-character groups from an unambiguous alphabet', () => { + for (let i = 0; i < 50; i++) { + const challenge = generateChallenge() + expect(challenge).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/) + } + }) + + it('generates different codes across calls', () => { + const codes = new Set(Array.from({ length: 50 }, () => generateChallenge())) + expect(codes.size).toBeGreaterThan(1) + }) +}) + +describe('isChallengeAnswerCorrect', () => { + const challenge = 'ABCD-2345' + + it('accepts an exact match', () => { + expect(isChallengeAnswerCorrect(challenge, challenge)).toBe(true) + }) + + it('is case-insensitive and trims surrounding whitespace', () => { + expect(isChallengeAnswerCorrect(challenge, ' abcd-2345 ')).toBe(true) + }) + + it('rejects a wrong answer', () => { + expect(isChallengeAnswerCorrect(challenge, 'WXYZ-9876')).toBe(false) + }) + + it('rejects a missing answer (timeout)', () => { + expect(isChallengeAnswerCorrect(challenge, null)).toBe(false) + }) + + it('rejects an empty answer', () => { + expect(isChallengeAnswerCorrect(challenge, '')).toBe(false) + }) +}) diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index aacc313..eeaf155 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -182,7 +182,12 @@ export function registerWalletCommands(program: Command): void { .command('export') .description('Display the stored seed phrase (interactive terminal only)') .action(async () => { - requireInteractiveTerminal() + try { + assertInteractiveTerminal(process.stdin.isTTY, process.stdout.isTTY) + } catch (err) { + console.log(chalk.red(`\n${(err as Error).message}\n`)) + process.exit(1) + } const data = loadMnemonic() if (!data) { @@ -222,20 +227,23 @@ export function registerWalletCommands(program: Command): void { }) } +export class NonInteractiveTerminalError extends Error {} + /** * Refuse to reveal the seed phrase unless the command is attached to a real * interactive terminal. This blocks piping, redirection, `echo ... | paytaca`, - * command substitution, and non-interactive agent execution. + * command substitution, and non-interactive agent execution. Throws (rather + * than exiting) so callers and tests can handle the refusal. */ -function requireInteractiveTerminal(): void { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - console.log( - chalk.red( - '\nRefusing to export seed phrase: this command must be run directly in an interactive terminal.\n' + - 'It cannot be piped, redirected, or executed by a non-interactive process.\n' - ) +export function assertInteractiveTerminal( + stdinIsTTY: boolean | undefined, + stdoutIsTTY: boolean | undefined +): void { + if (!stdinIsTTY || !stdoutIsTTY) { + throw new NonInteractiveTerminalError( + 'Refusing to export seed phrase: this command must be run directly in an interactive terminal.\n' + + 'It cannot be piped, redirected, or executed by a non-interactive process.' ) - process.exit(1) } } @@ -311,10 +319,10 @@ async function promptChallenge(): Promise { }) }) - return answer !== null && answer.trim().toUpperCase() === challenge + return isChallengeAnswerCorrect(challenge, answer) } -function generateChallenge(): string { +export function generateChallenge(): string { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const group = () => Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join('') @@ -327,10 +335,17 @@ const BIO_OK = 'PAYTACA_BIO_OK' const BIO_FAIL = 'PAYTACA_BIO_FAIL' const BIO_UNAVAILABLE = 'PAYTACA_BIO_UNAVAILABLE' -function classifyBiometricOutput(output: string): BiometricResult { +export function isChallengeAnswerCorrect( + challenge: string, + answer: string | null +): boolean { + return answer !== null && answer.trim().toUpperCase() === challenge +} + +export function classifyBiometricOutput(output: string): BiometricResult { if (output.includes(BIO_UNAVAILABLE)) return 'unavailable' - if (output.includes(BIO_OK)) return 'ok' if (output.includes(BIO_FAIL)) return 'denied' + if (output.includes(BIO_OK)) return 'ok' return 'error' } @@ -400,7 +415,12 @@ function verifyFprintd(): BiometricResult { if (!commandExists('fprintd-verify')) return 'unavailable' if (!commandExists('fprintd-list')) return 'unavailable' - const user = os.userInfo().username + let user: string + try { + user = os.userInfo().username + } catch { + return 'unavailable' + } const enrolled = spawnSync('fprintd-list', [user], { stdio: ['ignore', 'pipe', 'pipe'],