From a73ef9750807db36ac63d967e704989a711c4b8a Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 22 Sep 2026 16:43:42 +0200 Subject: [PATCH 1/6] Replace stdiomask with built-in masked input 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 Co-Authored-By: Claude Opus 5 --- .pylintrc | 1 - CHANGELOG.rst | 7 +++ Pipfile | 1 - cryptnox_cli/command/helper/security.py | 57 +++++++++++++++++++++-- cryptnox_cli/command/user_keys/piv/piv.py | 6 ++- dev-requirements.txt | 1 - docs/conf.py | 1 - requirements.txt | 1 - setup.cfg | 1 - 9 files changed, 65 insertions(+), 11 deletions(-) diff --git a/.pylintrc b/.pylintrc index 844d4cc..d336a42 100644 --- a/.pylintrc +++ b/.pylintrc @@ -157,7 +157,6 @@ ignored-modules= botocore, cryptography, winsdk, - stdiomask, smartcard, Cryptodome # List of classes names for which member attributes should not be checked diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6e10f95..257cd08 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,13 @@ Changelog ========= +Unreleased +---------- + +Changed +^^^^^^^ +- Masked PIN, PUK and passphrase input on Linux and macOS is handled by the CLI itself; the ``stdiomask`` dependency is removed + `1.0.5 `_ ------------------------------------------------------------------------------------------------ diff --git a/Pipfile b/Pipfile index acdefdf..8adf885 100644 --- a/Pipfile +++ b/Pipfile @@ -23,7 +23,6 @@ pytz = "*" requests = ">=2.33.0" solders = ">=0.27.0" tabulate = "*" -stdiomask = "*" web3 = ">=7.15.0" winsdk = {version = "*", sys_platform = "== 'win32'"} diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index 58724b5..1ab827e 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -2,6 +2,9 @@ """ Module dealing with security of the application. """ +import codecs +import os +import sys import typing from time import sleep from typing import List, Dict @@ -11,13 +14,62 @@ from .. import user_keys +def _getpass_posix(prompt, mask): + """ + Masked input for POSIX terminals. + The terminal is switched to cbreak mode: keys arrive one at a time without + echo, while Ctrl+C still raises KeyboardInterrupt. Bytes are decoded + incrementally, so a multi-byte UTF-8 character counts as one entry. + When stdin is not a terminal there is nothing to mask and the line is read as is. + """ + import termios + import tty + + try: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + except (AttributeError, ValueError, termios.error): + return input(prompt) + + decoder = codecs.getincrementaldecoder(sys.stdin.encoding or 'utf-8')(errors='ignore') + entered = [] + sys.stdout.write(prompt) + sys.stdout.flush() + try: + tty.setcbreak(fd) + while True: + data = os.read(fd, 1) + if not data: + raise EOFError + char = decoder.decode(data) + if not char: # Incomplete multi-byte character + continue + if char in ('\n', '\r'): # Enter + return ''.join(entered) + if char in ('\b', '\x7f'): # Backspace/Del + if entered: + sys.stdout.write('\b \b') + sys.stdout.flush() + entered.pop() + elif ord(char) > 31: + # Only ASCII control characters are left out. Separators and format + # characters (ideographic space, no-break space, zero width joiner) + # are part of a passphrase and change the wallet it derives. + entered.append(char) + sys.stdout.write(mask) + sys.stdout.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + sys.stdout.write('\n') + sys.stdout.flush() + + def _getpass(prompt='Password: ', mask='*'): """ Cross-platform getpass that raises KeyboardInterrupt on Ctrl+C. On Windows, getch() returns 0x03 for Ctrl+C in raw mode, so we catch it directly without polling. """ - import sys if sys.platform == 'win32': from msvcrt import getch entered = [] @@ -43,8 +95,7 @@ def _getpass(prompt='Password: ', mask='*'): sys.stdout.write(mask) sys.stdout.flush() else: - from stdiomask import getpass - return getpass(prompt, mask) + return _getpass_posix(prompt, mask) class ExitException(Exception): diff --git a/cryptnox_cli/command/user_keys/piv/piv.py b/cryptnox_cli/command/user_keys/piv/piv.py index 06b00cb..afcd56d 100644 --- a/cryptnox_cli/command/user_keys/piv/piv.py +++ b/cryptnox_cli/command/user_keys/piv/piv.py @@ -7,7 +7,6 @@ import cryptnox_sdk_py from cryptography import x509 from cryptography.hazmat.primitives.serialization import PublicFormat, Encoding -from stdiomask import getpass from . import piv_card from .. import user_key_base @@ -58,8 +57,11 @@ def _verify_piv_pin(card: piv_card.PIVcard): print("\nThis PIV is locked. Use an external tool to unlock it.\n") raise user_key_base.ProcessingException + # Imported here: helper.security imports user_keys, which loads this module + from ...helper.security import _getpass + while True: - pin_code = getpass(prompt="PIV PIN code: ") + pin_code = _getpass(prompt="PIV PIN code: ") if pin_code.lower() == "exit": raise user_key_base.ExitException("The request was canceled by the user") diff --git a/dev-requirements.txt b/dev-requirements.txt index d511bba..5dd0168 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -74,7 +74,6 @@ regex==2024.11.6; python_version >= '3.8' requests==2.33.0; python_version >= '3.8' rlp==4.1.0; python_version >= '3.8' and python_version < '5' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' -stdiomask==0.0.6 tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' translationstring==1.4 diff --git a/docs/conf.py b/docs/conf.py index 03722ad..dc991ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,7 +66,6 @@ # Cryptnox SDK (PyPI package: cryptnox-sdk-py) 'cryptnox_sdk_py', # Input/Output - 'stdiomask', 'tabulate', # Web3 and Ethereum 'web3', diff --git a/requirements.txt b/requirements.txt index 63ec026..79e660e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,6 @@ requests==2.33.0; python_version >= '3.8' rlp==4.1.0; python_version >= '3.8' and python_version < '5' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' solders>=0.27.0; python_version >= '3.7' -stdiomask==0.0.6 tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' translationstring==1.4 diff --git a/setup.cfg b/setup.cfg index 33351e0..fa98640 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,7 +50,6 @@ install_requires = requests>=2.33.0 solders>=0.27.0 tabulate - stdiomask web3>=7.15.0 winsdk;platform_system=='Windows' From f9c42fcf1674c1276ac2c9ce82ce0aeade3dfff7 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Tue, 22 Sep 2026 18:46:03 +0200 Subject: [PATCH 2/6] Load Ethereum support only where it is used 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 --- .pylintrc | 1 - CHANGELOG.rst | 1 + Pipfile | 1 - cryptnox_cli/command/card/info.py | 5 ++- cryptnox_cli/command/erc_token/contract.py | 6 +++- cryptnox_cli/command/erc_token/info.py | 7 +++- cryptnox_cli/command/eth.py | 13 +++++++- cryptnox_cli/command/helper/notification.py | 3 +- cryptnox_cli/main.py | 9 +----- cryptnox_cli/wallet/eth/api.py | 36 +++++++++++++++------ dev-requirements.txt | 1 - requirements.txt | 1 - setup.cfg | 1 - setup.spec | 9 +----- 14 files changed, 59 insertions(+), 35 deletions(-) diff --git a/.pylintrc b/.pylintrc index d336a42..d541663 100644 --- a/.pylintrc +++ b/.pylintrc @@ -143,7 +143,6 @@ overgeneral-exceptions=builtins.BaseException,builtins.Exception # Ignore missing members accessed in mocking ignored-modules= cryptnox_sdk_py, - lazy_import, appdirs, tabulate, ecdsa, diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 257cd08..24fad00 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ Unreleased Changed ^^^^^^^ - Masked PIN, PUK and passphrase input on Linux and macOS is handled by the CLI itself; the ``stdiomask`` dependency is removed +- Ethereum support is loaded only by the commands that use it, so commands such as ``history`` and ``info`` start noticeably faster; the ``lazy-import`` dependency is removed `1.0.5 `_ ------------------------------------------------------------------------------------------------ diff --git a/Pipfile b/Pipfile index 8adf885..7aacfb2 100644 --- a/Pipfile +++ b/Pipfile @@ -18,7 +18,6 @@ boto3 = "*" ecdsa = ">=0.19.2" colander = "*" cryptnox-sdk-py = ">=1.0.5" -lazy-import = "*" pytz = "*" requests = ">=2.33.0" solders = ">=0.27.0" diff --git a/cryptnox_cli/command/card/info.py b/cryptnox_cli/command/card/info.py index 877a8f0..255f3e1 100644 --- a/cryptnox_cli/command/card/info.py +++ b/cryptnox_cli/command/card/info.py @@ -7,7 +7,6 @@ from typing import List, Dict import cryptnox_sdk_py -import web3 from argparse import Namespace from tabulate import tabulate @@ -160,6 +159,8 @@ def _get_btc_info(config, pubkey) -> dict: @staticmethod def _get_eth_info(config, public_key) -> dict: + import web3 + if public_key is None: return {"name": "ETH", "address": "Bad derivation type", "network": ""} network = enums.EthNetwork[config.get("network", "infura").upper()] @@ -240,6 +241,8 @@ def _get_solana_info(config, pubkey) -> dict: @staticmethod def _get_bnb_info(public_key) -> dict: + import web3 + # BNB on Binance Smart Chain (BSC) is EVM-compatible: same secp256k1 # curve, same derivation path, and same address format as Ethereum. if public_key is None: diff --git a/cryptnox_cli/command/erc_token/contract.py b/cryptnox_cli/command/erc_token/contract.py index ced31a5..01ec562 100644 --- a/cryptnox_cli/command/erc_token/contract.py +++ b/cryptnox_cli/command/erc_token/contract.py @@ -13,7 +13,6 @@ import cryptnox_sdk_py import requests -import web3 from tabulate import tabulate from ..helper.helper_methods import sign @@ -41,6 +40,8 @@ def abi(value: int): def gas(gas_price: int, set_price: int, set_limit: int, default_limit: int = LIMIT["transfer"]) -> Tuple[int, int]: + import web3 + if set_price: price = set_price else: @@ -65,6 +66,7 @@ def gas(gas_price: int, set_price: int, set_limit: int, def transfer(card, endpoint, network, api_key, contract_address: str, to: str, amount: float, price: int, limit: int, derivation: cryptnox_sdk_py.Derivation = cryptnox_sdk_py.Derivation.CURRENT_KEY) -> int: + import web3 try: endpoint = wallet.Api(endpoint, network, api_key) @@ -159,6 +161,8 @@ def transfer(card, endpoint, network, api_key, contract_address: str, to: str, a def _confirm_token_sending(contract: str, address: str, to: str, token_balance: float, symbol: str, value: Union[Decimal, int], balance: int, price: int, limit: float): + import web3 + gas_price = web3.Web3.from_wei(price, "ether") total_gas = Decimal(gas_price * limit) balance = web3.Web3.from_wei(balance, "ether") diff --git a/cryptnox_cli/command/erc_token/info.py b/cryptnox_cli/command/erc_token/info.py index 204dd2b..dbea4ba 100644 --- a/cryptnox_cli/command/erc_token/info.py +++ b/cryptnox_cli/command/erc_token/info.py @@ -17,7 +17,6 @@ from cryptography.hazmat.primitives.asymmetric import ec from eth_typing import ChecksumAddress from tabulate import tabulate -from web3 import Web3 from ..cards import Cards from ..helper.cards import CardManager @@ -158,6 +157,8 @@ def execute(self, card: cryptnox_sdk_py.Card) -> int: @staticmethod def _balance(endpoint, address): + from web3 import Web3 + try: w3 = Web3(Web3.HTTPProvider(endpoint)) print(f"Balance: {Web3.from_wei(w3.eth.get_balance(address), 'ether')} ETH") @@ -212,6 +213,8 @@ def _owner( abi: str, account: str, token_id: int) -> None: + from web3 import Web3 + print(f"Checking owner on contract: {contract_address}...") w3 = Web3(Web3.HTTPProvider(endpoint)) try: @@ -232,6 +235,8 @@ def _owner( @staticmethod def _token_balance(endpoint: str, contract_address: ChecksumAddress, abi: str, account: str) -> None: + from web3 import Web3 + print(f"Checking token on contract: {contract_address}...") w3 = Web3(Web3.HTTPProvider(endpoint)) try: diff --git a/cryptnox_cli/command/eth.py b/cryptnox_cli/command/eth.py index d436608..6e2d98e 100644 --- a/cryptnox_cli/command/eth.py +++ b/cryptnox_cli/command/eth.py @@ -10,7 +10,6 @@ import cryptnox_sdk_py import requests -import web3 from argparse import Namespace from tabulate import tabulate @@ -118,6 +117,8 @@ def list(self) -> int: return 0 def logs(self): + import web3 + try: config_contract = get_configuration(self.card)["hidden"]["eth"]["contract"][self.data.alias] except KeyError: @@ -275,6 +276,8 @@ def _get_endpoint(self, card): return wallet.Api(config["eth"]["endpoint"], network, config["eth"]["api_key"]) def _add(self, card): + import web3 + abi = _get_processed_json_argument(self.data.abi) try: @@ -365,6 +368,8 @@ def _functions(self, card): return None def _call(self, card): + import web3 + config = get_configuration(card) try: config = config["hidden"]["eth"]["contract"][self.data.alias] @@ -409,6 +414,8 @@ def _call(self, card): return 0 def _transact(self, card): + import web3 + self._check(card) config = get_configuration(card) @@ -505,6 +512,8 @@ def _transact(self, card): @staticmethod def _confirm(public_key, address, balance, value, price, limit): + import web3 + gas_price = web3.Web3.from_wei(price, "ether") gas = Decimal(gas_price * limit) balance = web3.Web3.from_wei(balance, "ether") @@ -595,6 +604,8 @@ def _send(self, card) -> int: @staticmethod def _send_funds(card, derivation, endpoint, address, amount, price, limit): + import web3 + path = b"" if derivation == cryptnox_sdk_py.Derivation.CURRENT_KEY else wallet.Api.PATH public_key = card.get_public_key(derivation, path=path, compressed=False) diff --git a/cryptnox_cli/command/helper/notification.py b/cryptnox_cli/command/helper/notification.py index f814535..64a0cb7 100644 --- a/cryptnox_cli/command/helper/notification.py +++ b/cryptnox_cli/command/helper/notification.py @@ -10,7 +10,6 @@ from typing import Dict, Any import requests as requests -import web3 try: from config import ( @@ -57,6 +56,8 @@ def __init__(self): @staticmethod def _check(data): + import web3 + try: w3 = wallet.Web3Api(None, wallet.endpoint_factory(data["endpoint"], data["network"], diff --git a/cryptnox_cli/main.py b/cryptnox_cli/main.py index a7ca923..fa6c649 100644 --- a/cryptnox_cli/main.py +++ b/cryptnox_cli/main.py @@ -3,13 +3,13 @@ """ Command line interface for Cryptnox Cards """ +import re import sys import traceback from os import makedirs from pathlib import Path import argparse -import lazy_import from appdirs import user_log_dir if getattr(sys, 'frozen', False): @@ -35,13 +35,6 @@ options ) -cryptnox_sdk_py = lazy_import.lazy_module("cryptnox_sdk_py") -json = lazy_import.lazy_module("json") -re = lazy_import.lazy_module("re") -tabulate = lazy_import.lazy_callable("tabulate.tabulate") -requests = lazy_import.lazy_module("requests") -web3 = lazy_import.lazy_module("web3") - APPLICATION_NAME = "Cryptnox CLI" diff --git a/cryptnox_cli/wallet/eth/api.py b/cryptnox_cli/wallet/eth/api.py index 6f85483..2b9c339 100644 --- a/cryptnox_cli/wallet/eth/api.py +++ b/cryptnox_cli/wallet/eth/api.py @@ -4,22 +4,18 @@ """ from typing import ( Any, - Dict, Union + Dict, TYPE_CHECKING, Union ) import ecdsa from cryptnox_sdk_py import Derivation -from eth_account._utils.legacy_transactions import ( - encode_transaction, - serializable_unsigned_transaction_from_dict -) -from eth_utils.curried import keccak -from hexbytes import HexBytes -from web3 import Web3 from . import endpoint as ep from .. import validators +if TYPE_CHECKING: + from web3 import Web3 + try: from lib import cryptos import enums @@ -29,10 +25,14 @@ def address(public_key: str) -> str: + from eth_utils.curried import keccak + return keccak(hexstr=("0x" + public_key[2:]))[-20:].hex() def checksum_address(public_key: str) -> str: + from web3 import Web3 + return Web3.to_checksum_address(address(public_key)) @@ -62,9 +62,13 @@ def contract(self, address="", abi=""): return self._web3.eth.contract(address=address, abi=abi) def get_transaction_count(self, address: str, blocks: str = None) -> int: + from web3 import Web3 + return self._web3.eth.get_transaction_count(Web3.to_checksum_address(address), blocks) def get_balance(self, address: str) -> int: + from web3 import Web3 + return self._web3.eth.get_balance(Web3.to_checksum_address(address)) @property @@ -76,6 +80,12 @@ def network(self): return self.endpoint.network def transaction_hash(self, transaction: Dict[str, Any], vrs: bool = False): + from eth_account._utils.legacy_transactions import ( + encode_transaction, + serializable_unsigned_transaction_from_dict + ) + from eth_utils.curried import keccak + try: del transaction["maxFeePerGas"] del transaction["maxPriorityFeePerGas"] @@ -87,6 +97,12 @@ def transaction_hash(self, transaction: Dict[str, Any], vrs: bool = False): return keccak(encoded_transaction) def push(self, transaction, signature, public_key): + from eth_account._utils.legacy_transactions import ( + encode_transaction, + serializable_unsigned_transaction_from_dict + ) + from hexbytes import HexBytes + unsigned_transaction = serializable_unsigned_transaction_from_dict(transaction) var_v, var_r, var_s = Api._decode_vrs(signature, self._chain_id, self.transaction_hash(transaction), @@ -133,7 +149,9 @@ def _provider(self) -> str: return self.endpoint.provider @property - def _web3(self) -> Web3: + def _web3(self) -> "Web3": + from web3 import Web3 + return Web3(Web3.HTTPProvider(self._provider)) diff --git a/dev-requirements.txt b/dev-requirements.txt index 5dd0168..3f33f35 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -57,7 +57,6 @@ frozenlist==1.5.0; python_version >= '3.8' hexbytes==1.2.1; python_version >= '3.8' and python_version < '4' idna==3.15; python_version >= '3.6' iso8601==2.1.0; python_version >= '3.7' and python_version < '4.0' -lazy-import==0.2.2 multidict==6.1.0; python_version >= '3.8' parsimonious==0.10.0 propcache==0.2.0; python_version >= '3.8' diff --git a/requirements.txt b/requirements.txt index 79e660e..d585848 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,6 @@ frozenlist==1.5.0; python_version >= '3.8' hexbytes==1.2.1; python_version >= '3.8' and python_version < '4' idna==3.15; python_version >= '3.6' iso8601==2.1.0; python_version >= '3.7' and python_version < '4.0' -lazy-import==0.2.2 multidict==6.1.0; python_version >= '3.8' parsimonious==0.10.0 propcache==0.2.0; python_version >= '3.8' diff --git a/setup.cfg b/setup.cfg index fa98640..119c27b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,7 +45,6 @@ install_requires = ecdsa>=0.19.2 colander cryptnox-sdk-py>=1.0.5 - lazy-import pytz requests>=2.33.0 solders>=0.27.0 diff --git a/setup.spec b/setup.spec index 2298d79..9c365b2 100644 --- a/setup.spec +++ b/setup.spec @@ -1,13 +1,8 @@ # -*- mode: python ; coding: utf-8 -*- -import os import sys from PyInstaller.utils.hooks import collect_all -# Find lazy_import VERSION file -import lazy_import -lazy_import_version = os.path.join(os.path.dirname(lazy_import.__file__), 'VERSION') - # Collect all submodules, data files, and binaries from cryptnox_sdk_py sdk_datas, sdk_binaries, sdk_hiddenimports = collect_all('cryptnox_sdk_py') @@ -18,11 +13,9 @@ a = Analysis( datas=[ # Only include non-Python data files ('cryptnox_cli\\contract_abi\\*.json', 'cryptnox_cli\\contract_abi'), - ('cryptnox_cli\\lib\\cryptos\\english.txt', 'cryptnox_cli\\lib\\cryptos'), - (lazy_import_version, 'lazy_import') + ('cryptnox_cli\\lib\\cryptos\\english.txt', 'cryptnox_cli\\lib\\cryptos') ] + sdk_datas, hiddenimports=sdk_hiddenimports + [ - 'lazy_import', 'six', 'winsdk', 'winsdk.windows', From 1095ef35375add70b35c7a4ebf56040e600d4b04 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 25 Sep 2026 17:30:15 +0200 Subject: [PATCH 3/6] Accept the full Unicode range in Windows PIN and passphrase entry 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 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.rst | 4 + cryptnox_cli/command/helper/masked_input.py | 222 ++++++++++++++++++++ cryptnox_cli/command/helper/security.py | 90 +------- cryptnox_cli/command/helper/ui.py | 2 +- cryptnox_cli/command/user_keys/piv/piv.py | 6 +- docs/cryptnox_cli.command.helper.rst | 8 + 6 files changed, 239 insertions(+), 93 deletions(-) create mode 100644 cryptnox_cli/command/helper/masked_input.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 24fad00..d13d59c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,10 @@ Changed - Masked PIN, PUK and passphrase input on Linux and macOS is handled by the CLI itself; the ``stdiomask`` dependency is removed - Ethereum support is loaded only by the commands that use it, so commands such as ``history`` and ``info`` start noticeably faster; the ``lazy-import`` dependency is removed +Fixed +^^^^^ +- Windows: PIN, PUK and BIP39 passphrase entry now accepts the full Unicode range. Characters outside ASCII were previously discarded as they were typed, so a passphrase containing them was not the one the user entered. Arrow and function keys are ignored instead of adding characters to the entry, and Ctrl+C cancels the prompt again + `1.0.5 `_ ------------------------------------------------------------------------------------------------ diff --git a/cryptnox_cli/command/helper/masked_input.py b/cryptnox_cli/command/helper/masked_input.py new file mode 100644 index 0000000..c7cc2b6 --- /dev/null +++ b/cryptnox_cli/command/helper/masked_input.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +""" +Masked entry of PINs, PUKs and passphrases: one mask character is echoed per +character entered, on Windows consoles and POSIX terminals alike. +""" +import codecs +import os +import sys + +# UTF-16 surrogate ranges. A character outside the Basic Multilingual Plane, +# an emoji for instance, reaches a Windows console as two of these halves. +_HIGH_SURROGATE = range(0xD800, 0xDC00) +_LOW_SURROGATE = range(0xDC00, 0xE000) + + +def _masked_input(read_char, prompt, mask): + """ + Read a secret, echoing one mask character per character entered. + + :param read_char: Callable returning the next UTF-16 code unit as a + one-character string, an empty string for a key that + carries no character, such as an arrow or function key, + or raising EOFError when the input ends. + :param str prompt: Text to show before the entry + :param str mask: Character to echo for each character entered + + :return: The entered text + :rtype: str + + An astral character arrives as a surrogate pair and is joined back into one + entry, so it echoes one mask character and one backspace removes all of it. + Only ASCII control characters are left out. Separators and format + characters, such as the ideographic space a Japanese keyboard produces, are + part of a passphrase and change the wallet it derives. + """ + entered = [] + pending = '' + sys.stdout.write(prompt) + sys.stdout.flush() + while True: + char = read_char() + if not char: + continue + if ord(char) in _HIGH_SURROGATE: + pending = char + continue + if pending: + if ord(char) in _LOW_SURROGATE: + # Rebuild the character the pair stands for. Joining the halves + # as text would leave two unusable ones that cannot be encoded. + char = chr(0x10000 + (ord(pending) - 0xD800) * 0x400 + + (ord(char) - 0xDC00)) + pending = '' + elif ord(char) in _LOW_SURROGATE: + continue # Half of a character without its partner, not text + + if char in ('\r', '\n'): # Enter + return ''.join(entered) + if char == '\x03': # Ctrl+C + raise KeyboardInterrupt + if char in ('\b', '\x7f'): # Backspace/Del + if entered: + sys.stdout.write('\b \b') + sys.stdout.flush() + entered.pop() + elif ord(char) > 31: + entered.append(char) + sys.stdout.write(mask) + sys.stdout.flush() + + +def _console_reader(handle, kernel32): + """ + Build a read_char callable over a Windows console input handle. + + Keys are taken from ReadConsoleInputW rather than msvcrt, because the + console records say whether a key produced a character. Through msvcrt an + arrow key is announced by a lead byte that cannot be told apart from a + typed 'a with grave accent', which would either swallow the next character + or add the arrow's scan code to the secret. + """ + import ctypes + from ctypes import wintypes + + class CharUnion(ctypes.Union): + """Character of a key event, as text or as a byte.""" + _fields_ = [("UnicodeChar", ctypes.c_wchar), ("AsciiChar", ctypes.c_char)] + + class KeyEvent(ctypes.Structure): + """KEY_EVENT_RECORD of the Windows console API.""" + _fields_ = [("bKeyDown", wintypes.BOOL), ("wRepeatCount", wintypes.WORD), + ("wVirtualKeyCode", wintypes.WORD), ("wVirtualScanCode", wintypes.WORD), + ("uChar", CharUnion), ("dwControlKeyState", wintypes.DWORD)] + + class EventUnion(ctypes.Union): + """Event of an input record. Only key events are read.""" + _fields_ = [("KeyEvent", KeyEvent), ("padding", ctypes.c_byte * 16)] + + class InputRecord(ctypes.Structure): + """INPUT_RECORD of the Windows console API.""" + _fields_ = [("EventType", wintypes.WORD), ("Event", EventUnion)] + + key_event = 0x0001 + vk_menu = 0x12 # Alt + record = InputRecord() + read = wintypes.DWORD() + buffered = [] + + def read_char(): + while not buffered: + if not kernel32.ReadConsoleInputW(handle, ctypes.byref(record), 1, + ctypes.byref(read)) or not read.value: + raise EOFError + if record.EventType != key_event: + continue + # A key delivers its character on both key-down and key-up, so only + # key-down is taken. The exception is Alt: a character composed with + # Alt and the numeric keypad arrives on the Alt key-up alone, and the + # classic console's paste delivers each half of a character outside + # the Basic Multilingual Plane, an emoji for instance, the same way. + if (not record.Event.KeyEvent.bKeyDown + and record.Event.KeyEvent.wVirtualKeyCode != vk_menu): + continue + char = record.Event.KeyEvent.uChar.UnicodeChar + if char == '\x00': + continue # A key that produces no character, such as an arrow + buffered.extend([char] * max(1, record.Event.KeyEvent.wRepeatCount)) + return buffered.pop(0) + + return read_char + + +def _getpass_windows(prompt, mask): + """ + Masked input for a Windows console. + + Line editing, echo and Ctrl+C handling are switched off for the duration, + so every keypress is delivered as it is typed and Ctrl+C reaches the loop + as a character. The console mode is restored afterwards. When standard + input is not a console there is nothing to mask and the line is read as is. + """ + import ctypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + # A HANDLE is pointer sized; left untyped, ctypes would cut it to 32 bits + kernel32.GetStdHandle.restype = ctypes.c_void_p + std_input_handle = -10 + handle = ctypes.c_void_p(kernel32.GetStdHandle(std_input_handle)) + mode = ctypes.c_ulong() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return input(prompt) + + # ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT + # | ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT + line_editing_and_echo = 0x0001 | 0x0002 | 0x0004 | 0x0008 | 0x0200 + try: + kernel32.SetConsoleMode(handle, mode.value & ~line_editing_and_echo) + return _masked_input(_console_reader(handle, kernel32), prompt, mask) + finally: + kernel32.SetConsoleMode(handle, mode.value) + sys.stdout.write('\n') + sys.stdout.flush() + + +def _getpass_posix(prompt, mask): + """ + Masked input for POSIX terminals. + The terminal is switched to cbreak mode: keys arrive one at a time without + echo, while Ctrl+C still raises KeyboardInterrupt. Bytes are decoded + incrementally, so a multi-byte UTF-8 character counts as one entry. + When stdin is not a terminal there is nothing to mask and the line is read as is. + """ + import termios + import tty + + try: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + except (AttributeError, ValueError, termios.error): + return input(prompt) + + decoder = codecs.getincrementaldecoder(sys.stdin.encoding or 'utf-8')(errors='ignore') + entered = [] + sys.stdout.write(prompt) + sys.stdout.flush() + try: + tty.setcbreak(fd) + while True: + data = os.read(fd, 1) + if not data: + raise EOFError + char = decoder.decode(data) + if not char: # Incomplete multi-byte character + continue + if char in ('\n', '\r'): # Enter + return ''.join(entered) + if char in ('\b', '\x7f'): # Backspace/Del + if entered: + sys.stdout.write('\b \b') + sys.stdout.flush() + entered.pop() + elif ord(char) > 31: + # Only ASCII control characters are left out. Separators and format + # characters (ideographic space, no-break space, zero width joiner) + # are part of a passphrase and change the wallet it derives. + entered.append(char) + sys.stdout.write(mask) + sys.stdout.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + sys.stdout.write('\n') + sys.stdout.flush() + + +def getpass(prompt='Password: ', mask='*'): + """ + Cross-platform getpass that raises KeyboardInterrupt on Ctrl+C. + """ + if sys.platform == 'win32': + return _getpass_windows(prompt, mask) + + return _getpass_posix(prompt, mask) diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index 1ab827e..7e5f4dd 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -2,9 +2,6 @@ """ Module dealing with security of the application. """ -import codecs -import os -import sys import typing from time import sleep from typing import List, Dict @@ -12,90 +9,7 @@ import cryptnox_sdk_py from .. import user_keys - - -def _getpass_posix(prompt, mask): - """ - Masked input for POSIX terminals. - The terminal is switched to cbreak mode: keys arrive one at a time without - echo, while Ctrl+C still raises KeyboardInterrupt. Bytes are decoded - incrementally, so a multi-byte UTF-8 character counts as one entry. - When stdin is not a terminal there is nothing to mask and the line is read as is. - """ - import termios - import tty - - try: - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - except (AttributeError, ValueError, termios.error): - return input(prompt) - - decoder = codecs.getincrementaldecoder(sys.stdin.encoding or 'utf-8')(errors='ignore') - entered = [] - sys.stdout.write(prompt) - sys.stdout.flush() - try: - tty.setcbreak(fd) - while True: - data = os.read(fd, 1) - if not data: - raise EOFError - char = decoder.decode(data) - if not char: # Incomplete multi-byte character - continue - if char in ('\n', '\r'): # Enter - return ''.join(entered) - if char in ('\b', '\x7f'): # Backspace/Del - if entered: - sys.stdout.write('\b \b') - sys.stdout.flush() - entered.pop() - elif ord(char) > 31: - # Only ASCII control characters are left out. Separators and format - # characters (ideographic space, no-break space, zero width joiner) - # are part of a passphrase and change the wallet it derives. - entered.append(char) - sys.stdout.write(mask) - sys.stdout.flush() - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - sys.stdout.write('\n') - sys.stdout.flush() - - -def _getpass(prompt='Password: ', mask='*'): - """ - Cross-platform getpass that raises KeyboardInterrupt on Ctrl+C. - On Windows, getch() returns 0x03 for Ctrl+C in raw mode, - so we catch it directly without polling. - """ - if sys.platform == 'win32': - from msvcrt import getch - entered = [] - sys.stdout.write(prompt) - sys.stdout.flush() - while True: - key = ord(getch()) - if key == 3: # Ctrl+C - sys.stdout.write('\n') - sys.stdout.flush() - raise KeyboardInterrupt - elif key == 13: # Enter - sys.stdout.write('\n') - sys.stdout.flush() - return ''.join(entered) - elif key in (8, 127): # Backspace/Del - if entered: - sys.stdout.write('\b \b') - sys.stdout.flush() - entered.pop() - elif 32 <= key <= 126: # Printable ASCII - entered.append(chr(key)) - sys.stdout.write(mask) - sys.stdout.flush() - else: - return _getpass_posix(prompt, mask) +from .masked_input import getpass class ExitException(Exception): @@ -146,7 +60,7 @@ def _secret_with_exit(text, required=True): """ while True: try: - value = _getpass(text).strip() + value = getpass(text).strip() except KeyboardInterrupt: raise ExitException() if required and not value: diff --git a/cryptnox_cli/command/helper/ui.py b/cryptnox_cli/command/helper/ui.py index 2867a71..1042216 100644 --- a/cryptnox_cli/command/helper/ui.py +++ b/cryptnox_cli/command/helper/ui.py @@ -10,7 +10,7 @@ from typing import List, Union, Dict import cryptnox_sdk_py -from .security import _getpass as getpass +from .masked_input import getpass from tabulate import tabulate from . import security diff --git a/cryptnox_cli/command/user_keys/piv/piv.py b/cryptnox_cli/command/user_keys/piv/piv.py index afcd56d..b969fbf 100644 --- a/cryptnox_cli/command/user_keys/piv/piv.py +++ b/cryptnox_cli/command/user_keys/piv/piv.py @@ -10,6 +10,7 @@ from . import piv_card from .. import user_key_base +from ...helper.masked_input import getpass class Piv(user_key_base.UserKey): @@ -57,11 +58,8 @@ def _verify_piv_pin(card: piv_card.PIVcard): print("\nThis PIV is locked. Use an external tool to unlock it.\n") raise user_key_base.ProcessingException - # Imported here: helper.security imports user_keys, which loads this module - from ...helper.security import _getpass - while True: - pin_code = _getpass(prompt="PIV PIN code: ") + pin_code = getpass(prompt="PIV PIN code: ") if pin_code.lower() == "exit": raise user_key_base.ExitException("The request was canceled by the user") diff --git a/docs/cryptnox_cli.command.helper.rst b/docs/cryptnox_cli.command.helper.rst index e3bc023..1a6df7b 100644 --- a/docs/cryptnox_cli.command.helper.rst +++ b/docs/cryptnox_cli.command.helper.rst @@ -28,6 +28,14 @@ cryptnox_cli.command.helper.helper\_methods module :undoc-members: :show-inheritance: +cryptnox_cli.command.helper.masked_input module +----------------------------------------------- + +.. automodule:: cryptnox_cli.command.helper.masked_input + :members: + :undoc-members: + :show-inheritance: + cryptnox_cli.command.helper.notification module ---------------------------------------------- From c1eec037b94a0b291f94d166c35d6a91b6ad8da0 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Thu, 8 Oct 2026 19:19:33 +0200 Subject: [PATCH 4/6] Pack the solana command module into the frozen build 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 --- setup.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.spec b/setup.spec index 9c365b2..06112ca 100644 --- a/setup.spec +++ b/setup.spec @@ -25,7 +25,6 @@ a = Analysis( 'smartcard', 'multiprocessing', 'tabulate', - 'coincurve', 'eth_keys', 'requests', 'urllib3', @@ -42,6 +41,7 @@ a = Analysis( 'cryptnox_cli.command.info', 'cryptnox_cli.command.initialize', 'cryptnox_cli.command.seed', + 'cryptnox_cli.command.solana', 'cryptnox_cli.command.cards', 'cryptnox_cli.command.server', 'cryptnox_cli.command.reset', From 19724d587c2ac264b9fc7bbd96c2e68079047a2f Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 9 Oct 2026 11:38:06 +0200 Subject: [PATCH 5/6] Align the declared dependencies with what the code imports 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 --- .pylintrc | 2 -- Pipfile | 7 +++---- dev-requirements.txt | 4 ---- requirements.txt | 4 ---- setup.cfg | 6 +++--- 5 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.pylintrc b/.pylintrc index d541663..514a0f1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -152,8 +152,6 @@ ignored-modules= hexbytes, web3, requests, - boto3, - botocore, cryptography, winsdk, smartcard, diff --git a/Pipfile b/Pipfile index 7aacfb2..4fb3917 100644 --- a/Pipfile +++ b/Pipfile @@ -11,14 +11,13 @@ pytest-pylint = "*" pytest = "*" [packages] -argparse = "*" appdirs = "*" base58 = "*" -boto3 = "*" ecdsa = ">=0.19.2" -colander = "*" cryptnox-sdk-py = ">=1.0.5" -pytz = "*" +eth-account = "*" +eth-utils = "*" +hexbytes = "*" requests = ">=2.33.0" solders = ">=0.27.0" tabulate = "*" diff --git a/dev-requirements.txt b/dev-requirements.txt index 3f33f35..4876751 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -32,7 +32,6 @@ aiohttp==3.14.1; python_version >= '3.9' aiosignal==1.4.0; python_version >= '3.7' annotated-types==0.7.0; python_version >= '3.8' appdirs==1.4.4 -argparse==1.4.0 attrs==25.1.0; python_version >= '3.7' base58==2.1.1; python_version >= '3.5' bitarray==3.0.0 @@ -40,7 +39,6 @@ certifi==2025.11.12; python_version >= '3.6' cffi==2.0.0; platform_python_implementation != 'PyPy' charset-normalizer==3.4.0; python_full_version >= '3.7.0' ckzg==2.0.1 -colander==2.0; python_version >= '3.7' cryptnox-sdk-py==1.0.2 cryptography==48.0.1; python_version >= '3.7' cytoolz==1.0.0; implementation_name == 'cpython' @@ -66,7 +64,6 @@ pydantic==2.10.1; python_version >= '3.8' pydantic-core==2.27.1; python_version >= '3.8' pyscard==2.2.0; python_version >= '3.9' python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' -pytz==2024.2 pyunormalize==16.0.0; python_version >= '3.6' pywin32==308; platform_system == 'Windows' regex==2024.11.6; python_version >= '3.8' @@ -75,7 +72,6 @@ rlp==4.1.0; python_version >= '3.8' and python_version < '5' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' -translationstring==1.4 types-requests==2.32.0.20241016; python_version >= '3.8' urllib3==2.7.0; python_version >= '3.8' web3==7.15.0; python_version >= '3.8' and python_version < '4' diff --git a/requirements.txt b/requirements.txt index d585848..773290a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ aiohttp==3.14.1; python_version >= '3.9' aiosignal==1.4.0; python_version >= '3.7' annotated-types==0.7.0; python_version >= '3.8' appdirs==1.4.4 -argparse==1.4.0 attrs==25.1.0; python_version >= '3.7' base58==2.1.1; python_version >= '3.5' bitarray==3.0.0 @@ -12,7 +11,6 @@ certifi==2025.11.12; python_version >= '3.6' cffi==2.0.0; platform_python_implementation != 'PyPy' charset-normalizer==3.4.0; python_full_version >= '3.7.0' ckzg==2.0.1 -colander==2.0; python_version >= '3.7' cryptnox-sdk-py>=1.0.5; python_version >= '3.11' and python_version <= '3.14' cryptography==48.0.1; python_version >= '3.7' cytoolz==1.0.0; implementation_name == 'cpython' @@ -38,7 +36,6 @@ pydantic==2.10.1; python_version >= '3.8' pydantic-core==2.27.1; python_version >= '3.8' pyscard==2.2.0; python_version >= '3.9' python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' -pytz==2024.2 pyunormalize==16.0.0; python_version >= '3.6' pywin32==308; platform_system == 'Windows' regex==2024.11.6; python_version >= '3.8' @@ -48,7 +45,6 @@ six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' solders>=0.27.0; python_version >= '3.7' tabulate==0.9.0; python_version >= '3.7' toolz==1.1.0; python_version >= '3.8' -translationstring==1.4 types-requests==2.32.0.20241016; python_version >= '3.8' typing-extensions==4.13.2; python_version >= '3.8' urllib3==2.7.0; python_version >= '3.8' diff --git a/setup.cfg b/setup.cfg index 119c27b..576fa4c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,13 +39,13 @@ packages_dir = cryptnox_cli zip_safe = False include_package_data = True install_requires = - argparse appdirs base58 ecdsa>=0.19.2 - colander cryptnox-sdk-py>=1.0.5 - pytz + eth-account + eth-utils + hexbytes requests>=2.33.0 solders>=0.27.0 tabulate From c59ffbc05e2b428884a9e84e1228be8bfe9b8f4a Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 9 Oct 2026 12:18:32 +0200 Subject: [PATCH 6/6] Import ctypes.wintypes with an import statement 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 --- cryptnox_cli/command/helper/masked_input.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cryptnox_cli/command/helper/masked_input.py b/cryptnox_cli/command/helper/masked_input.py index c7cc2b6..b14c017 100644 --- a/cryptnox_cli/command/helper/masked_input.py +++ b/cryptnox_cli/command/helper/masked_input.py @@ -80,7 +80,7 @@ def _console_reader(handle, kernel32): or add the arrow's scan code to the secret. """ import ctypes - from ctypes import wintypes + import ctypes.wintypes class CharUnion(ctypes.Union): """Character of a key event, as text or as a byte.""" @@ -88,9 +88,9 @@ class CharUnion(ctypes.Union): class KeyEvent(ctypes.Structure): """KEY_EVENT_RECORD of the Windows console API.""" - _fields_ = [("bKeyDown", wintypes.BOOL), ("wRepeatCount", wintypes.WORD), - ("wVirtualKeyCode", wintypes.WORD), ("wVirtualScanCode", wintypes.WORD), - ("uChar", CharUnion), ("dwControlKeyState", wintypes.DWORD)] + _fields_ = [("bKeyDown", ctypes.wintypes.BOOL), ("wRepeatCount", ctypes.wintypes.WORD), + ("wVirtualKeyCode", ctypes.wintypes.WORD), ("wVirtualScanCode", ctypes.wintypes.WORD), + ("uChar", CharUnion), ("dwControlKeyState", ctypes.wintypes.DWORD)] class EventUnion(ctypes.Union): """Event of an input record. Only key events are read.""" @@ -98,12 +98,12 @@ class EventUnion(ctypes.Union): class InputRecord(ctypes.Structure): """INPUT_RECORD of the Windows console API.""" - _fields_ = [("EventType", wintypes.WORD), ("Event", EventUnion)] + _fields_ = [("EventType", ctypes.wintypes.WORD), ("Event", EventUnion)] key_event = 0x0001 vk_menu = 0x12 # Alt record = InputRecord() - read = wintypes.DWORD() + read = ctypes.wintypes.DWORD() buffered = [] def read_char():