Skip to content

Require biometric auth and interactive TTY for wallet export - #6

Merged
joemarct merged 3 commits into
masterfrom
feat/wallet-export-biometric-guard
Sep 14, 2026
Merged

joemarct merged 3 commits into
masterfrom
feat/wallet-export-biometric-guard

Conversation

@joemarct

Copy link
Copy Markdown
Member

Summary

paytaca wallet export previously printed the stored seed phrase unconditionally. Any non-interactive caller — including AI agents and background processes — could run it and silently capture the seed. This change gates the command behind layered human-presence checks so the seed can only be obtained by a live person at an interactive terminal.

Threat model

An automated caller typically executes commands without a TTY and can read stdout. Even when it allocates a pseudo-terminal (e.g. expect, Python pty), a terminal-only challenge is scriptable. The only robust confirmation is one the caller's process cannot drive: an OS biometric prompt with a physical presence requirement.

Changes

src/commands/wallet.ts only (plus version bump).

  1. Interactive TTY gate (requireInteractiveTerminal)
    Refuses unless both process.stdin.isTTY and process.stdout.isTTY are set. Blocks piping (echo ... | paytaca), output redirection (> file), command substitution ($(...)), and all non-interactive agent/plugin execution.

  2. OS biometric authentication (verifyBiometric), when available:

    • macOS — Touch ID via LocalAuthentication, driven through the system osascript JXA/ObjC bridge. No native addon or node-gyp.
    • Linuxfprintd-verify (fingerprint reader over D-Bus). Availability confirmed via fprintd-list enrollment check so un-enrolled users are not locked out.
    • Windows — Windows Hello via WinRT UserConsentVerifier, invoked through Windows PowerShell.
  3. Typed-code fallback (promptChallenge)
    When biometrics are unavailable, a random one-time code must be typed back within 120 seconds. No --yes/--json bypass exists.

Biometric availability is detected per platform. A denied/cancelled biometric prompt is fail-closed (no fallback); the code challenge is only offered when biometrics are genuinely unavailable.

Testing

  • npm run build (type-check) passes.
  • npm test — 104/104 tests pass.
  • Verified non-interactive invocation is refused with exit code 1 (no TTY).
  • Verified the macOS JXA → LocalAuthentication bridge end-to-end: async Touch ID callback fires and resolves on success.

Notes / caveats

  • The Windows Hello path is unverified — no Windows host was available. It is best-effort and fails safe to the code challenge on any error.
  • On Linux, fprintd-verify cannot distinguish "reader absent" from "wrong finger"; both are treated as denied once enrollment is confirmed.
  • On macOS, a denied/cancelled Touch ID aborts the command with no challenge fallback. This is intentional for seed material; happy to add a retry if preferred.
  • No new dependencies were added.

Version

0.5.20.6.0.

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.
@github-actions

Copy link
Copy Markdown

Review: Require biometric auth and interactive TTY for wallet export

Overall Assessment

Approve with minor suggestions. This is a well-scoped, security-focused change that meaningfully reduces the attack surface for automated seed-phrase exfiltration. The design is fail-closed, introduces no new npm dependencies, and the code is clean and idiomatic for the repo.


What changed

src/commands/wallet.ts was the only meaningful change (plus a version bump to 0.6.0). The wallet export command went from unconditionally printing the stored mnemonic to:

  1. requireInteractiveTerminal() — hard-blocks unless both stdin and stdout are TTYs.
  2. promptHumanVerification() — attempts OS biometric auth (Touch ID / fprintd / Windows Hello); if biometrics are unavailable, falls back to a typed random challenge.
  3. generateChallenge() — uses crypto.randomInt and an alphabet that omits visually-confusing characters.

Security Review

Finding Severity Status
Seed is only emitted after both TTY and human-verification gates pass. ✅ Correct
Biometric “denied” is fail-closed (no challenge fallback on cancel/wrong finger). ✅ Correct
spawnSync is used for all OS bridge calls — no async race windows. ✅ Correct
No user-controlled strings are interpolated into shell commands. fprintd-* receive the username via spawnSync array args (not shell concatenation). JXA / PowerShell scripts are hard-coded templates. ✅ Correct
randomInt from node:crypto drives the fallback challenge. ✅ Correct
process.exit(1) is used on all failure paths — appropriate for a CLI tool, but makes unit testing impossible without monkey-patching. Low ⚠️ See suggestion below.

Suggestions (non-blocking)

  1. Wrap os.userInfo() in verifyFprintd()
    os.userInfo() can throw SystemError on Linux systems where the effective UID has no passwd entry (common in containers, CI, or minimal systemd units). This would crash the CLI instead of falling back to the typed challenge.

    let user: string
    try {
      user = os.userInfo().username
    } catch {
      return 'unavailable'
    }
  2. Consider extracting testable pure functions
    generateChallenge(), classifyBiometricOutput(), requireInteractiveTerminal(), and promptChallenge() are all defined as module-local closures. Because requireInteractiveTerminal() calls process.exit(1), there are currently no tests for this new security-critical flow. Extracting the pure helpers and making the gate functions throw instead of process.exit would allow unit tests without changing the CLI behavior (the action handler can catch and exit).

  3. Update README.md
    The README still describes the command as simply “Display the stored seed phrase.” Users will be surprised when scripts or non-interactive usage break. A one-line note about the interactive/biometric requirement would prevent support noise.

  4. Potential minor readability improvement in classifyBiometricOutput
    The order of string checks is fine because the scripts are mutually exclusive, but BIO_OK and BIO_FAIL could theoretically appear together in a mixed stdout/stderr stream. If you want to be paranoid, check BIO_FAIL before BIO_OK so a “fail” token always wins, or split the streams before classifying.


Build / Tests

I was unable to run npm run build or npm test in this review environment because node_modules is missing (pre-existing, unrelated to the PR). However, the new imports (node:os, node:crypto, node:child_process) are valid for the declared engine >=20.0.0, and the added code contains no TypeScript syntax issues. The PR description’s claim that all 104 tests pass is plausible since there are no existing tests for wallet export that would need updating.


Verdict

LGTM — please land after addressing the os.userInfo() robustness issue and updating the README. The testing gap is worth a follow-up PR but should not block this security improvement.

New%20session%20-%202026-09-14T07%3A44%3A07.686Z
opencode session  |  github run

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.
@github-actions

Copy link
Copy Markdown

Approve. This is a well-scoped, security-focused change and the follow-up revision addresses all prior review feedback.

What changed

wallet export now gates seed-phrase disclosure behind two layered human-presence checks:

  1. assertInteractiveTerminal — exported and unit-tested; hard-blocks unless both stdin and stdout are TTYs, preventing piping, redirection, and non-interactive agent execution.
  2. promptHumanVerification — attempts OS biometric auth (macOS Touch ID via JXA → LocalAuthentication, Linux fprintd-verify, Windows Hello via PowerShell/WinRT). Denied/cancelled biometric prompts are fail-closed with no fallback. If biometrics are genuinely unavailable, a typed random challenge is presented.

Pure helper functions (generateChallenge, classifyBiometricOutput, isChallengeAnswerCorrect) are now exported and covered by wallet.test.ts.

Verification

  • npm run build — passes with no type errors.
  • npm test — 121/121 tests pass (up from 104, 17 new tests added).
  • All four suggestions from the previous review are resolved:
    1. os.userInfo() is wrapped in try/catch (line 419).
    2. Testable pure functions are extracted and tested.
    3. README.md is updated with the interactive/biometric requirement.
    4. classifyBiometricOutput checks BIO_FAIL before BIO_OK, so failure always wins in mixed output.

Security notes

  • spawnSync is used for all OS bridge calls — no async race windows.
  • No user-controlled strings reach shell commands. fprintd-* receive the username via spawnSync array arguments. JXA and PowerShell scripts are hard-coded templates.
  • crypto.randomInt drives the fallback challenge alphabet, which omits visually-confusing characters.
  • The command is fail-closed at every branch: no TTY → exit; no wallet → exit; biometric denied → exit; challenge wrong → exit.

Minor observation (non-blocking)

commandExists uses spawnSync('sh', ['-c', \command -v ${command}`]). The command values are hardcoded literals ('fprintd-verify', 'fprintd-list'`), so there is no practical injection surface, but using array args instead of shell interpolation would be a slight hardening improvement.

Verdict: LGTM. Land it.

New%20session%20-%202026-09-14T07%3A58%3A58.525Z
opencode session  |  github run

@joemarct
joemarct merged commit 94fe6ec into master Sep 14, 2026
1 check passed
@joemarct
joemarct deleted the feat/wallet-export-biometric-guard branch September 14, 2026 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant