Skip to content

Built-in masked input, lazy Ethereum imports, dependency and packaging fixes - #61

Open
mmlado wants to merge 6 commits into
mainfrom
chore/drop-stdiomask-lazy-import
Open

mmlado wants to merge 6 commits into
mainfrom
chore/drop-stdiomask-lazy-import

Conversation

@mmlado

@mmlado mmlado commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

What and why

Masked secret entry and dependency loading are handled by the CLI itself, with two packaging fixes alongside.

  • Masked PIN, PUK and passphrase entry is done by the CLI on every platform, in a new standard-library-only module, cryptnox_cli.command.helper.masked_input; stdiomask is removed. On Linux and macOS the terminal is put in cbreak mode and bytes are decoded incrementally, so a multi-byte character counts as one entry. Only ASCII control characters are left out: separators and format characters are part of a passphrase and change the wallet it derives.
  • Windows entry accepts the full Unicode range. It previously kept printable ASCII only. Keys are read with ReadConsoleInputW through ctypes rather than msvcrt: the console records say whether a key produced a character, so arrow and function keys add nothing. Through msvcrt the lead byte announcing an arrow key cannot be told apart from a typed à (U+00E0). A character outside the Basic Multilingual Plane arrives as a surrogate pair and is rebuilt into one character, so it echoes one mask character and one Backspace removes all of it. Only key-down records are taken, with one exception: a character composed with Alt and the numeric keypad arrives on the Alt key-up record alone, and the classic console's right-click paste delivers each half of a character outside the BMP the same way. Ctrl+C cancels the prompt again. Because the module has no project dependencies, the PIV module imports it normally instead of inside a method.
  • Ethereum support is imported inside the functions that use it; lazy-import is removed. The command factory imports every command module on each dispatch, so a module-level web3 import anywhere loads it for every command. history and info now start in about a third of the time. A module-level web3 import added later would silently undo this.
  • setup.spec: the solana command module is added to the hidden imports. The factory imports command modules by name at run time, so PyInstaller only packs the ones listed, and without this entry a frozen build fails at every dispatch. The stale coincurve entry is dropped. Note the spec has no COLLECT step, so the build is one file regardless of onefile=False.
  • Declared dependencies match the imports: argparse (a backport for Python versions without the module), colander and pytz are removed; eth-account, eth-utils and hexbytes are declared, since the Ethereum wallet imports them directly rather than through web3.

Command output is unchanged throughout.

How it was tested

  • Linux: the POSIX loop driven through a pty with 11 cases (UTF-8 split across reads, Backspace over a multi-byte character, separators and format characters, paste, Ctrl+C); 16 unit tests of the shared entry loop (surrogate pairs, Backspace over a pair, arrow keys, control characters, Ctrl+C, end of input).
  • Windows 11 (22631), Python 3.12, Windows Terminal and the classic console, US and Hungarian layouts: typed accented letters, pasted Latin-1, CJK, U+3000 and emoji, arrows, function keys, modifiers, Esc, Tab, Ctrl+Z, Ctrl+C, piped stdin. Every console event was recorded and replayed through the code; the classic console's right-click paste of emoji is covered by the Alt key-up rule above.
  • Frozen build on Windows 11 with PyInstaller 6.11.1: builds without errors; --version, every --help and the dispatching commands run. With a Cryptnox card (Basic, applet 2.0.0) in a Microsoft USB CCID reader: list works, and PIN entry at the frozen executable's prompt masks the digits, accepts Backspace and leaves the console in its normal state. info, history and get_xpub were not run to completion because the test card has no seed.
  • flake8, pylint and the Sphinx docs build pass. Output of --help, --version, history, info and the per-command help is byte-identical before and after the lazy-loading change.

Third-party and generated code

No third-party code. Substantial parts of this pull request were produced with Claude Code.

🤖 Generated with Claude Code

mmlado and others added 5 commits September 22, 2026 16:43
stdiomask was only used for masked input on Linux and macOS; the
Windows path already had its own mask loop. Handle the POSIX side in
the CLI as well and drop the dependency.

Add _getpass_posix to helper/security.py: the terminal is switched to
cbreak mode, typed characters are echoed as the mask character, and
Ctrl+C still raises KeyboardInterrupt. Input is decoded incrementally,
so multi-byte UTF-8 characters in a BIP39 passphrase are kept and count
as one entry for backspace. Only ASCII control characters are left out;
separators and format characters such as the ideographic space an input
method produces are part of a passphrase and change the wallet it
derives. When stdin is not a terminal the line is read with input().

The PIV PIN prompt now uses the same helper instead of importing
stdiomask directly. The import is local to the method because
helper.security imports user_keys, which loads the PIV module.

Drop stdiomask from setup.cfg, Pipfile, requirements.txt,
dev-requirements.txt, the Sphinx autodoc mock list and .pylintrc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main.py declared six modules through lazy-import. Five of them were
already imported by the time those lines ran, so only web3 was ever
deferred, and the deferral was lost as soon as a command was dispatched:
the command factory imports every command module, and several of them
imported web3 at the top. Commands with nothing to do with Ethereum paid
for it anyway.

Import web3 inside the functions that use it instead, along with
eth_account, eth_utils and hexbytes in the Ethereum wallet API, and drop
the dependency. In api.py the Web3 annotation becomes a string under
TYPE_CHECKING so the module still type checks without importing it.

Measured here, no card attached: history and info go from about 1.1
seconds to about 0.3, and neither loads web3 at all where before both
loaded all 98 of its modules. Output is unchanged for --help, --version,
history, info, cards and the per-command help.

Remove the lazy_import VERSION data file and hidden import from
setup.spec, along with the import of os that only served them. The six
hidden import stays: ecdsa needs it as well.

Drop lazy-import from setup.cfg, Pipfile, requirements.txt,
dev-requirements.txt and .pylintrc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On Windows the masked prompt read keys with msvcrt.getch and kept only
printable ASCII, so every other character was discarded as it was typed.
The BIP39 passphrase prompt accepts any UTF-8 text, and the confirmation
prompt could not catch the difference because it dropped the same
characters. A passphrase containing anything outside ASCII therefore
derived a wallet from text the user had not entered.

Read the keys with ReadConsoleInputW instead. The console records say
whether a key produced a character, so arrow and function keys are
ignored rather than adding their scan codes to the entry. Through msvcrt
that distinction is not available: the lead byte announcing an arrow key
cannot be told apart from a typed 'a with grave accent'.

A character outside the Basic Multilingual Plane arrives as a surrogate
pair and is rebuilt into the character it stands for, so it echoes one
mask character and one backspace removes all of it.

Only key-down records are taken, since a key delivers its character on
both key-down and key-up. Alt is the exception: a character composed with
Alt and the numeric keypad arrives on the Alt key-up alone, and the
classic console's right-click paste delivers each half of a character
outside the Basic Multilingual Plane the same way. Alt pressed on its own
carries no character on its key-up, so nothing is doubled.

Line editing, echo and Ctrl+C handling are switched off while the prompt
is open, which brings back the Ctrl+C cancellation the prompt offers, and
the console mode is restored afterwards. When standard input is not a
console the line is read as is instead of reading the console anyway.

The masked prompt, both platforms and the shared entry loop, moves out of
helper.security into its own module, helper.masked_input, with getpass()
as its one public function. It has nothing to do with card security, and
it needs nothing beyond the standard library, so importing it no longer
pulls in user_keys. That dependency was what forced the PIV module to
import the prompt inside a method; it imports it normally now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The command factory imports its modules by name at run time, so
PyInstaller only packs the ones listed as hidden imports. The solana
module was never added to that list. In a frozen build the factory then
fails to import it and, since it treats a missing command module as
fatal, every command fails at dispatch, not only solana.

Drop coincurve from the hidden imports: nothing in the project uses it
and PyInstaller reports it as not found on every build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Remove four packages nothing imports. argparse from PyPI is the 2015
backport for Python versions without the module in the standard
library; this project requires 3.11 or newer, so the standard library
copy was always the one imported. colander and pytz have been declared
since the first commit and are not used anywhere. boto3 stayed in the
Pipfile and in pylint's ignored modules after the AWS backup feature was
removed.

Declare three packages the Ethereum wallet code imports directly:
eth-account, eth-utils and hexbytes. Until now they were installed only
as dependencies of web3, so a web3 release that stopped depending on one
of them would have broken the wallet with no warning at install time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread cryptnox_cli/command/helper/masked_input.py Fixed
Comment thread cryptnox_cli/command/helper/masked_input.py Fixed
masked_input imported ctypes with both an import statement and an import
from statement, which code scanning flags as inconsistent. Both imports
now use the same form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

3 participants