From 8aba8fb35b35d783a802fa1c50574a323a3abd2f Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Fri, 20 Mar 2026 21:29:10 +0700 Subject: [PATCH 1/5] Resolved power cycle requirement --- cryptnox_cli/command/helper/cards.py | 50 +++++++++++++--------- cryptnox_cli/command/helper/security.py | 57 +++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/cryptnox_cli/command/helper/cards.py b/cryptnox_cli/command/helper/cards.py index f048b50..1e35561 100644 --- a/cryptnox_cli/command/helper/cards.py +++ b/cryptnox_cli/command/helper/cards.py @@ -26,8 +26,14 @@ except ImportError: import config -# Global connection cache to preserve connections across CardManager instances +# Global caches preserved across CardManager instances within the same CLI session. +# Reusing the same Connection/card preserves the card's secure channel session, +# which means soft lock state (SW=0x6985) persists until a physical power cycle +# breaks the NFC connection (detected via card.alive returning False). _GLOBAL_CONNECTIONS: Dict[int, cryptnox_sdk_py.Connection] = {} +_GLOBAL_CARDS: Dict[int, cryptnox_sdk_py.Card] = {} +# Maps reader index -> card serial number, used to clear soft lock on reconnect +_GLOBAL_SERIAL_MAP: Dict[int, int] = {} class ExitException(Exception): @@ -185,32 +191,38 @@ def _index(self, serial_number: int) -> int: def _open_card(self, index: int, remote: bool = False) -> cryptnox_sdk_py.Card: - global _GLOBAL_CONNECTIONS # noqa: F824 + global _GLOBAL_CONNECTIONS, _GLOBAL_CARDS, _GLOBAL_SERIAL_MAP # noqa: F824 - if index in _GLOBAL_CONNECTIONS: - connection = _GLOBAL_CONNECTIONS[index] + if index in _GLOBAL_CARDS: + card = _GLOBAL_CARDS[index] try: - test_response = connection._reader.send([0x00, 0xA4, 0x04, 0x00, 0x00]) - if test_response: - if index in self._cards_by_index: - return self._cards_by_index[index] - except (BaseException, cryptnox_sdk_py.exceptions.ConnectionException): - # Connection is stale, remove it and create new one - del _GLOBAL_CONNECTIONS[index] - if index in self._cards_by_index: - self._remove_card(self._cards_by_index[index].serial_number) - - # Create new connection only if needed + if card.alive: + # Reuse card without re-selecting the applet. This preserves the + # secure channel session so the card's soft lock state (SW=0x6985) + # remains visible to verify_pin(None) on the next command. + self._cards[card.serial_number] = self._cards_by_index[index] = card + return card + except Exception: + pass + # card.alive returned False or raised — physical power cycle detected. + # Clear cached state so a fresh connection is created below. + _GLOBAL_CARDS.pop(index, None) + _GLOBAL_CONNECTIONS.pop(index, None) + if index in _GLOBAL_SERIAL_MAP: + security.clear_softlock(_GLOBAL_SERIAL_MAP.pop(index)) + connection = cryptnox_sdk_py.Connection(index, self.debug, config.REMOTE_CONNECTIONS, remote) - _GLOBAL_CONNECTIONS[index] = connection # Cache globally + _GLOBAL_CONNECTIONS[index] = connection card = cryptnox_sdk_py.factory.get_card(connection, self.debug) + _GLOBAL_CARDS[index] = card + _GLOBAL_SERIAL_MAP[index] = card.serial_number self._cards[card.serial_number] = self._cards_by_index[index] = card return card def _remove_card(self, key: int) -> None: - global _GLOBAL_CONNECTIONS # noqa: F824 + global _GLOBAL_CONNECTIONS, _GLOBAL_CARDS # noqa: F824 serial_number = index = key try: @@ -226,8 +238,8 @@ def _remove_card(self, key: int) -> None: del self._cards[serial_number] del self._cards_by_index[index] - if index in _GLOBAL_CONNECTIONS: - del _GLOBAL_CONNECTIONS[index] + _GLOBAL_CONNECTIONS.pop(index, None) + _GLOBAL_CARDS.pop(index, None) @staticmethod def printable_flags(card: cryptnox_sdk_py.Card) -> List[str]: diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index d8cf54b..23090a2 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -2,6 +2,7 @@ """ Module dealing with security of the application. """ +import sys import typing from typing import List, Dict @@ -9,11 +10,52 @@ from .. import user_keys +# Maps card serial number -> remaining attempt count at the time soft lock was detected. +# Cleared when the card is power-cycled (new NFC connection) or PIN is verified successfully. +_SOFT_LOCKED_CARDS: Dict[int, int] = {} + + +def mark_softlock(serial_number: int, remaining: int) -> None: + """Record that a soft lock occurred with the given remaining attempt count.""" + _SOFT_LOCKED_CARDS[serial_number] = remaining + + +def clear_softlock(serial_number: int) -> None: + """Remove soft lock state for a card (called on successful auth or power cycle).""" + _SOFT_LOCKED_CARDS.pop(serial_number, None) + + +def _is_softlocked(serial_number: int, remaining: int) -> bool: + """ + Return True if the card is still in the same soft-locked state. + + Returns False (and clears the state) if the remaining count has changed, + which indicates the card was power-cycled and an attempt was used. + """ + stored = _SOFT_LOCKED_CARDS.get(serial_number) + if stored is None: + return False + if stored == remaining: + return True + # Count changed since soft lock — card was power-cycled and used; clear state. + _SOFT_LOCKED_CARDS.pop(serial_number, None) + return False + class ExitException(Exception): """Raised when user has indicated he want's to exit the command""" +def _prompt_power_cycle_exit() -> None: + """Prompt the user to press Enter, then exit the application for a power cycle.""" + print("Card requires a power cycle.") + try: + input("Press Enter to exit and relaunch the application: ") + except (KeyboardInterrupt, EOFError): + pass + sys.exit(0) + + def _secret_with_exit(text, required=True): """ Local implementation of secret_with_exit to avoid circular import. @@ -92,6 +134,7 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: authorized = False pin_code = "1" easy_mode = is_easy_mode(card.info) + retries = None while not authorized: if easy_mode: @@ -106,15 +149,19 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: raise cryptnox_sdk_py.exceptions.PinBlockedException( "PIN is locked. Use the unlock_pin command to unlock it." ) + if _is_softlocked(card.serial_number, retries): + _prompt_power_cycle_exit() try_str = "attempt" if retries == 1 else "attempts" prompt_text = f"Cryptnox PIN code ({retries} {try_str} remaining): " else: prompt_text = text except cryptnox_sdk_py.exceptions.PinBlockedException: raise - except cryptnox_sdk_py.exceptions.PinException: - print("Card requires a power cycle. Please remove and re-tap the card, then try again.") + except ExitException: raise + except (cryptnox_sdk_py.exceptions.PinException, + cryptnox_sdk_py.exceptions.SoftLock): + _prompt_power_cycle_exit() except Exception: prompt_text = text @@ -124,9 +171,11 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: authorized = _check_pin_code(card, pin_code) except (cryptnox_sdk_py.exceptions.PinAuthenticationException, cryptnox_sdk_py.exceptions.SoftLock): - print("Card requires a power cycle. Please remove and re-tap the card, then try again.") - raise + if retries is not None: + mark_softlock(card.serial_number, retries) + _prompt_power_cycle_exit() + clear_softlock(card.serial_number) return pin_code From 1e17d688e0e86043c1e6f785dfcf5bec86c5ad14 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 24 Mar 2026 21:56:46 +0700 Subject: [PATCH 2/5] Fix CodeQL empty-except alerts blocking PR merge Add explanatory comments inside except-pass blocks in security.py and cards.py to satisfy CodeQL's py/empty-except rule (alerts #132, #133). Co-Authored-By: Claude Sonnet 4.6 --- cryptnox_cli/command/helper/cards.py | 2 +- cryptnox_cli/command/helper/security.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cryptnox_cli/command/helper/cards.py b/cryptnox_cli/command/helper/cards.py index 1e35561..072dee0 100644 --- a/cryptnox_cli/command/helper/cards.py +++ b/cryptnox_cli/command/helper/cards.py @@ -203,7 +203,7 @@ def _open_card(self, index: int, remote: bool = False) -> cryptnox_sdk_py.Card: self._cards[card.serial_number] = self._cards_by_index[index] = card return card except Exception: - pass + pass # card.alive check raised — treat as disconnected and reconnect below # card.alive returned False or raised — physical power cycle detected. # Clear cached state so a fresh connection is created below. _GLOBAL_CARDS.pop(index, None) diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index 23090a2..ed1fc59 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -52,7 +52,7 @@ def _prompt_power_cycle_exit() -> None: try: input("Press Enter to exit and relaunch the application: ") except (KeyboardInterrupt, EOFError): - pass + pass # User interrupted the prompt; proceed to exit anyway sys.exit(0) From ec82ea08b0183bb88cc2bd245b33758e65325f16 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Wed, 25 Mar 2026 19:49:13 +0700 Subject: [PATCH 3/5] Implemented power cycle prompt with card reconnection --- cryptnox_cli/command/helper/cards.py | 50 +++++-------- cryptnox_cli/command/helper/security.py | 96 +++++++++++++------------ 2 files changed, 71 insertions(+), 75 deletions(-) diff --git a/cryptnox_cli/command/helper/cards.py b/cryptnox_cli/command/helper/cards.py index 072dee0..f048b50 100644 --- a/cryptnox_cli/command/helper/cards.py +++ b/cryptnox_cli/command/helper/cards.py @@ -26,14 +26,8 @@ except ImportError: import config -# Global caches preserved across CardManager instances within the same CLI session. -# Reusing the same Connection/card preserves the card's secure channel session, -# which means soft lock state (SW=0x6985) persists until a physical power cycle -# breaks the NFC connection (detected via card.alive returning False). +# Global connection cache to preserve connections across CardManager instances _GLOBAL_CONNECTIONS: Dict[int, cryptnox_sdk_py.Connection] = {} -_GLOBAL_CARDS: Dict[int, cryptnox_sdk_py.Card] = {} -# Maps reader index -> card serial number, used to clear soft lock on reconnect -_GLOBAL_SERIAL_MAP: Dict[int, int] = {} class ExitException(Exception): @@ -191,38 +185,32 @@ def _index(self, serial_number: int) -> int: def _open_card(self, index: int, remote: bool = False) -> cryptnox_sdk_py.Card: - global _GLOBAL_CONNECTIONS, _GLOBAL_CARDS, _GLOBAL_SERIAL_MAP # noqa: F824 + global _GLOBAL_CONNECTIONS # noqa: F824 - if index in _GLOBAL_CARDS: - card = _GLOBAL_CARDS[index] + if index in _GLOBAL_CONNECTIONS: + connection = _GLOBAL_CONNECTIONS[index] try: - if card.alive: - # Reuse card without re-selecting the applet. This preserves the - # secure channel session so the card's soft lock state (SW=0x6985) - # remains visible to verify_pin(None) on the next command. - self._cards[card.serial_number] = self._cards_by_index[index] = card - return card - except Exception: - pass # card.alive check raised — treat as disconnected and reconnect below - # card.alive returned False or raised — physical power cycle detected. - # Clear cached state so a fresh connection is created below. - _GLOBAL_CARDS.pop(index, None) - _GLOBAL_CONNECTIONS.pop(index, None) - if index in _GLOBAL_SERIAL_MAP: - security.clear_softlock(_GLOBAL_SERIAL_MAP.pop(index)) - + test_response = connection._reader.send([0x00, 0xA4, 0x04, 0x00, 0x00]) + if test_response: + if index in self._cards_by_index: + return self._cards_by_index[index] + except (BaseException, cryptnox_sdk_py.exceptions.ConnectionException): + # Connection is stale, remove it and create new one + del _GLOBAL_CONNECTIONS[index] + if index in self._cards_by_index: + self._remove_card(self._cards_by_index[index].serial_number) + + # Create new connection only if needed connection = cryptnox_sdk_py.Connection(index, self.debug, config.REMOTE_CONNECTIONS, remote) - _GLOBAL_CONNECTIONS[index] = connection + _GLOBAL_CONNECTIONS[index] = connection # Cache globally card = cryptnox_sdk_py.factory.get_card(connection, self.debug) - _GLOBAL_CARDS[index] = card - _GLOBAL_SERIAL_MAP[index] = card.serial_number self._cards[card.serial_number] = self._cards_by_index[index] = card return card def _remove_card(self, key: int) -> None: - global _GLOBAL_CONNECTIONS, _GLOBAL_CARDS # noqa: F824 + global _GLOBAL_CONNECTIONS # noqa: F824 serial_number = index = key try: @@ -238,8 +226,8 @@ def _remove_card(self, key: int) -> None: del self._cards[serial_number] del self._cards_by_index[index] - _GLOBAL_CONNECTIONS.pop(index, None) - _GLOBAL_CARDS.pop(index, None) + if index in _GLOBAL_CONNECTIONS: + del _GLOBAL_CONNECTIONS[index] @staticmethod def printable_flags(card: cryptnox_sdk_py.Card) -> List[str]: diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index ed1fc59..64cdbd9 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -2,58 +2,54 @@ """ Module dealing with security of the application. """ -import sys import typing +from time import sleep from typing import List, Dict import cryptnox_sdk_py from .. import user_keys -# Maps card serial number -> remaining attempt count at the time soft lock was detected. -# Cleared when the card is power-cycled (new NFC connection) or PIN is verified successfully. -_SOFT_LOCKED_CARDS: Dict[int, int] = {} - - -def mark_softlock(serial_number: int, remaining: int) -> None: - """Record that a soft lock occurred with the given remaining attempt count.""" - _SOFT_LOCKED_CARDS[serial_number] = remaining - - -def clear_softlock(serial_number: int) -> None: - """Remove soft lock state for a card (called on successful auth or power cycle).""" - _SOFT_LOCKED_CARDS.pop(serial_number, None) +class ExitException(Exception): + """Raised when user has indicated he want's to exit the command""" -def _is_softlocked(serial_number: int, remaining: int) -> bool: - """ - Return True if the card is still in the same soft-locked state. - Returns False (and clears the state) if the remaining count has changed, - which indicates the card was power-cycled and an attempt was used. +def _wait_for_power_cycle(card) -> None: """ - stored = _SOFT_LOCKED_CARDS.get(serial_number) - if stored is None: - return False - if stored == remaining: - return True - # Count changed since soft lock — card was power-cycled and used; clear state. - _SOFT_LOCKED_CARDS.pop(serial_number, None) - return False + Prompt the user to power-cycle the card, then restore the connection. + Automatic removal detection via card.alive is unreliable here: a soft-locked + card returns SW=0x6985 even for the manufacturer-certificate APDU, raising + PinAuthenticationException inside alive — making the card look absent while + it is still physically present. Using input() lets the user confirm removal + explicitly. Reinsertion is then auto-detected by polling Connection(), which + raises CardException until the card is back in the reader. -class ExitException(Exception): - """Raised when user has indicated he want's to exit the command""" - + The existing card object is updated in-place via __dict__ so every caller + reference stays valid without requiring a new card parameter. + """ + index = card.connection.index + debug = card.connection.debug -def _prompt_power_cycle_exit() -> None: - """Prompt the user to press Enter, then exit the application for a power cycle.""" - print("Card requires a power cycle.") try: - input("Press Enter to exit and relaunch the application: ") + input("\nCard requires a power cycle. " + "Remove the card from the reader and press Enter: ") + print("Waiting for card to be re-tapped...") + + while True: + try: + new_connection = cryptnox_sdk_py.Connection(index, debug) + new_card = cryptnox_sdk_py.factory.get_card(new_connection, debug) + card.__class__ = new_card.__class__ + card.__dict__.update(new_card.__dict__) + print("Card detected. Continuing.\n") + return + except Exception: + pass + sleep(0.2) except (KeyboardInterrupt, EOFError): - pass # User interrupted the prompt; proceed to exit anyway - sys.exit(0) + raise ExitException("Aborted.") def _secret_with_exit(text, required=True): @@ -134,7 +130,8 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: authorized = False pin_code = "1" easy_mode = is_easy_mode(card.info) - retries = None + power_cycled = False + had_wrong_pin = False while not authorized: if easy_mode: @@ -142,6 +139,7 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: pin_code = EASY_MODE_PIN authorized = True else: + prompt_text = text try: retries = card.verify_pin(None) if retries is not None: @@ -149,19 +147,28 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: raise cryptnox_sdk_py.exceptions.PinBlockedException( "PIN is locked. Use the unlock_pin command to unlock it." ) - if _is_softlocked(card.serial_number, retries): - _prompt_power_cycle_exit() + # Only prompt for power cycle when the user has already made a wrong + # attempt this session (retries == 3 after a wrong PIN), not on a + # fresh start or after a successful power cycle. + if retries == 3 and had_wrong_pin and not power_cycled: + _wait_for_power_cycle(card) + power_cycled = True + continue + power_cycled = False try_str = "attempt" if retries == 1 else "attempts" prompt_text = f"Cryptnox PIN code ({retries} {try_str} remaining): " else: prompt_text = text + power_cycled = False except cryptnox_sdk_py.exceptions.PinBlockedException: raise except ExitException: raise except (cryptnox_sdk_py.exceptions.PinException, cryptnox_sdk_py.exceptions.SoftLock): - _prompt_power_cycle_exit() + _wait_for_power_cycle(card) + power_cycled = True + continue except Exception: prompt_text = text @@ -169,13 +176,14 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: try: authorized = _check_pin_code(card, pin_code) + power_cycled = False + if not authorized: + had_wrong_pin = True except (cryptnox_sdk_py.exceptions.PinAuthenticationException, cryptnox_sdk_py.exceptions.SoftLock): - if retries is not None: - mark_softlock(card.serial_number, retries) - _prompt_power_cycle_exit() + _wait_for_power_cycle(card) + power_cycled = True - clear_softlock(card.serial_number) return pin_code From 555a02b621976cd5ab5884988ac2487a4077d5e9 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Wed, 25 Mar 2026 23:23:46 +0700 Subject: [PATCH 4/5] Fix CodeQL alerts in security.py blocking PR merge - Add explanatory comment to empty except in _wait_for_power_cycle polling loop - Remove redundant prompt_text assignment that is always overwritten before use Co-Authored-By: Claude Sonnet 4.6 --- cryptnox_cli/command/helper/security.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index 64cdbd9..b36eb33 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -45,7 +45,7 @@ def _wait_for_power_cycle(card) -> None: card.__dict__.update(new_card.__dict__) print("Card detected. Continuing.\n") return - except Exception: + except Exception: # Card not yet present; keep polling pass sleep(0.2) except (KeyboardInterrupt, EOFError): @@ -139,7 +139,6 @@ def check_pin_code(card, text: str = "Cryptnox PIN code: ") -> str: pin_code = EASY_MODE_PIN authorized = True else: - prompt_text = text try: retries = card.verify_pin(None) if retries is not None: From a62841f0d6fb4b9d220ddbb8c1d994fa431478d5 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Thu, 2 Apr 2026 03:23:10 +0700 Subject: [PATCH 5/5] Resolved flake8 error --- cryptnox_cli/command/helper/security.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cryptnox_cli/command/helper/security.py b/cryptnox_cli/command/helper/security.py index d821622..36072d6 100644 --- a/cryptnox_cli/command/helper/security.py +++ b/cryptnox_cli/command/helper/security.py @@ -7,6 +7,7 @@ from typing import List, Dict import cryptnox_sdk_py +from stdiomask import getpass as _getpass from .. import user_keys