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/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", 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 336a578..eeaf155 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,15 @@ 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 () => { + 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) { console.log( @@ -189,6 +199,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 +226,269 @@ export function registerWalletCommands(program: Command): void { console.log() }) } + +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. Throws (rather + * than exiting) so callers and tests can handle the refusal. + */ +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.' + ) + } +} + +/** + * 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 isChallengeAnswerCorrect(challenge, answer) +} + +export 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' + +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_FAIL)) return 'denied' + if (output.includes(BIO_OK)) return 'ok' + 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' + + let user: string + try { + user = os.userInfo().username + } catch { + return 'unavailable' + } + + 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 ?? ''}`) +}