From 3f4eef9991d88d12367d6abc982d5eed2c36b0e0 Mon Sep 17 00:00:00 2001 From: Henry Gabryjelski <740168+henrygab@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:04:21 -0700 Subject: [PATCH] Add script to interact with DefCon34 passport cards Works both from the command-line and from within the PM3 client: * python3 ./client/pyscripts/dc34.py * ./pm3 -c "script run dc34" Supports all three games: * TicTacToe * Hangman * GuessKey See https://dc34.rfid.wtf/ --- client/pyscripts/dc34.py | 996 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 996 insertions(+) create mode 100644 client/pyscripts/dc34.py diff --git a/client/pyscripts/dc34.py b/client/pyscripts/dc34.py new file mode 100644 index 000000000..fb34d3420 --- /dev/null +++ b/client/pyscripts/dc34.py @@ -0,0 +1,996 @@ +#!/usr/bin/env python3 + +# See https://dc34.rfid.wtf/ + +import subprocess +import time +import sys +import os +import re +import types +from enum import Enum, IntFlag +from dataclasses import dataclass +import struct +from collections.abc import Sequence +from typing import List, Optional +import logging + +# This try block allows send_proxmark_command() to work both +# from within the proxmark3 client environment (where pm3.py is available) +# and from outside (where it must call the proxmark3 binary via subprocess). +try: + import pm3 # Used when inside the pm3 environment + PM3_AVAILABLE = True + p = pm3.pm3() +except ImportError: + PM3_AVAILABLE = False # Use subprocess instead +PROXMARK3_FULLY_QUALIFIED_PATH = "/home/henrygab/src/proxmark3/pm3" + + +required_version = (3, 8) +if sys.version_info < required_version: + print(f"Python version: {sys.version}") + print(f"The script needs at least Python v{required_version[0]}.{required_version[1]}. Abort.") + exit() + + + +if True: # Logging helper + LOG = logging.getLogger(__name__) + LOG_HF14 = logging.getLogger("HF14") + LOG_PROXMARK = logging.getLogger("PROXMARK_COMMANDS") + LOG_PM_DEVICE = logging.getLogger("PM_DEVICE") + + LOG_HF14.parent = LOG + LOG_PROXMARK.parent = LOG + LOG_PM_DEVICE.parent = LOG + + LOG.addHandler(logging.StreamHandler(sys.stdout)) + # LOG_HF14.addHandler(logging.StreamHandler(sys.stdout)) + # LOG_PROXMARK.addHandler(logging.StreamHandler(sys.stdout)) + # LOG_PM_DEVICE.addHandler(logging.StreamHandler(sys.stdout)) + LOG.setLevel(logging.DEBUG) + LOG_HF14.setLevel(logging.ERROR) + LOG_PROXMARK.setLevel(logging.ERROR) + LOG_PM_DEVICE.setLevel(logging.ERROR) + +if True: # Enumerations + + class Status_HF14a(Enum): + InvalidDataLength = 0x6700 + InsNotSupported = 0x6D00 + ClaNotSupported = 0x6E00 + Success = 0x9000 + + class Status_Hangman(Enum): + InvalidDataLength = Status_HF14a.InvalidDataLength.value + InsNotSupported = Status_HF14a.InsNotSupported.value + ClaNotSupported = Status_HF14a.ClaNotSupported.value + Success = Status_HF14a.Success.value + NoActiveGame = 0x6985 # No Active Game (send NEW GAME command first) + GameFinished = 0x6986 # Same result, win or lose + InvalidLetterInput = 0x6A80 # Must be in range [A-Z] (0x41..0x5A) + LetterAlreadyGuessed = 0x6A86 + + class Status_TicTacToe(Enum): + InvalidDataLength = Status_HF14a.InvalidDataLength.value + InsNotSupported = Status_HF14a.InsNotSupported.value + ClaNotSupported = Status_HF14a.ClaNotSupported.value + Success = Status_HF14a.Success.value + NoActiveGame = 0x6985 # No Active Game (send NEW GAME command first) + GameFinished = 0x6986 # Game already finished + CellAlreadyOccupied = 0x6A80 # Cell already occupied + InvalidParameters = 0x6A86 # Row must be [0..2], Column must be [0..2] + + class Status_GuessKey(Enum): + InvalidDataLength = Status_HF14a.InvalidDataLength.value + InsNotSupported = Status_HF14a.InsNotSupported.value + ClaNotSupported = Status_HF14a.ClaNotSupported.value + Success = Status_HF14a.Success.value + GameFinished = 0x6986 # This is a one-time game ... can only play once + IncorrectGuess = 0x6A80 # The guess was not the correct key + + class TicTacToe_CellValue(Enum): + _ = 0 + X = 1 + O = 2 + + def __str__(self): + if self == TicTacToe_CellValue._: + return " " + elif self == TicTacToe_CellValue.X: + return "X" + elif self == TicTacToe_CellValue.O: + return "O" + else: + return "?" + + class TicTacToe_GameStatus(Enum): + InProgress = 0 + User_Wins = 1 + Card_Wins = 2 + Draw = 3 + + class Hangman_GameStatus(Enum): + InProgress = 0 + PlayerWon = 1 + PlayerLost = 2 + NoActiveGame = 0xFF + +if True: # Data classes + + @dataclass + class HF14aCommandResult: + # This is simply a way to return both a status code and a data buffer from the HF14a command. + status: int + cmd: bytes + data: bytes + + @property + def IsSuccess(self) -> bool: + return self.status == 0x9000 + + class GuessKey_Statistics: + # Class for serializing / deserializing the GuessKey command response data. + _FORMAT = ">HB" + _SIZE = struct.calcsize(_FORMAT) + + @classmethod + def _validate_uint16(cls, value: int) -> int: + if not isinstance(value, int): + raise ValueError("value must be an integer.") + if not (0 <= value <= 0xFFFF): + raise ValueError(f"value must be in range [0x0000 .. 0xFFFF], got 0x{value:04x}.") + return value + @classmethod + def _validate_bool(cls, value: bool | int) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + raise ValueError("value must be True, False, 0, or 1.") + + def __init__(self, guess_count: int, is_solved: bool): + self.guess_count = self._validate_uint16(guess_count) + if is_solved: + self.is_solved = True + else: + self.is_solved = False + + def pack(self) -> bytes: + return struct.pack( + self._FORMAT, + self.guess_count, + int(self.is_solved) + ) + + @classmethod + def unpack(cls, data: bytes | bytearray) -> "GuessKey_Statistics": + """Deserialize from bytes/bytearray into a Packet object.""" + try: + if len(data) != cls._SIZE: + raise ValueError(f"Invalid data length: expected {cls._SIZE}, got {len(data)}") + raw_guess_count, raw_is_solved = struct.unpack(cls._FORMAT, data) + guess_count = cls._validate_uint16(raw_guess_count) + is_solved = cls._validate_bool(raw_is_solved) + return cls(guess_count = guess_count, is_solved = is_solved) + except struct.error as e: + raise ValueError(f"Deserialization error: {e}") + + def __repr__(self): + return f"{self.__class__.__name__}(guess_count={self.guess_count}, is_solved={self.is_solved})" + + class TicTacToe_BoardState: + # Class for serializing / deserializing the GuessKey command response data. + _FORMAT = ">9BB" + _SIZE = struct.calcsize(_FORMAT) + + # Bytes 0-8: Board cells, row-major order (row0col0, row0col1, ..., row2col2) + # Byte 9: Game status + + @classmethod + def _validate_cell(cls, value: int) -> TicTacToe_CellValue: + if not isinstance(value, int): + raise ValueError("cell value must be an integer.") + result = TicTacToe_CellValue._value2member_map_.get(value) + if result is None: + raise ValueError(f"cell value must be one of {list(TicTacToe_CellValue._value2member_map_.keys())}, got 0x{value:02x}.") + return TicTacToe_CellValue(value) + + @classmethod + def _validate_game_status(cls, value: int) -> TicTacToe_GameStatus: + if not isinstance(value, int): + raise ValueError("game status must be an integer.") + result = TicTacToe_GameStatus._value2member_map_.get(value) + if result is None: + raise ValueError(f"game status must be one of {list(TicTacToe_GameStatus._value2member_map_.keys())}, got 0x{value:02x}.") + return TicTacToe_GameStatus(value) + + def __init__(self, cells : bytes | bytearray, game_status : int): + if len(cells) != 9: + raise ValueError(f"cells must be exactly 9 bytes, got {len(cells)}.") + self.cells = [self._validate_cell(c) for c in cells] + self.game_status = self._validate_game_status(game_status) + + def pack(self) -> bytes: + return struct.pack( + self._FORMAT, + *(c.value for c in self.cells), + self.game_status.value + ) + + @classmethod + def unpack(cls, data: bytes | bytearray) -> "TicTacToe_BoardState": + """Deserialize from bytes/bytearray into a Packet object.""" + try: + if len(data) != cls._SIZE: + raise ValueError(f"Invalid data length: expected {cls._SIZE}, got {len(data)}") + # Pack/Unpack are overkill here, since it's just a 9-element byte array and a single byte + cells = data[:9] + return cls(cells=cells, game_status=data[9]) + except struct.error as e: + raise ValueError(f"Deserialization error: {e}") + + def __repr__(self): + return f"{self.__class__.__name__}(cells={self.cells}, game_status={self.game_status})" + + class Hangman_State: + # Class for serializing / deserializing the GuessKey command response data. + + # Bytes 0-7: Word mask (up to 8 letters; revealed letters as ASCII, hidden as 5F ('_'), padding as 00) + # Byte 8: Wrong guess count (0-6) + # Bytes 9-12: Guessed-letter bitmask (4 bytes, 26 bits for A-Z, byte 9 bits 0-7 == A..H, byte 10 bits 0-7 == I..P, ...) + # Byte 13: Game status + + _FORMAT = "<8sBIB" + + _SIZE = struct.calcsize(_FORMAT) + _GUESSED_LETTER_MASK : int = (1 << 26) - 1 # 0x03FFFFFF + _ALL_AVAILABLE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + @classmethod + def guessed_letter_bitmask_to_remaining_letters(cls, bitmask: int) -> str: + letters = list(cls._ALL_AVAILABLE_LETTERS) + for i in range(26): + if bitmask & (1 << i): + letters[i] = ' ' + return ''.join(letters) + + @classmethod + def guessed_letter_bitmask_to_already_guessed_letters(cls, bitmask: int) -> str: + letters = list(cls._ALL_AVAILABLE_LETTERS) + for i in range(26): + if not (bitmask & (1 << i)): + letters[i] = ' ' + return ''.join(letters) + + @classmethod + def _validate_wordmask(cls, value: bytes | bytearray | str) -> str: + if not isinstance(value, (bytes, bytearray, str)): + raise ValueError("word mask must be bytes, bytearray, or str.") + + if isinstance(value, (bytes, bytearray)): + if len(value) != 8: + raise ValueError(f"word mask must be exactly 8 bytes, got {len(value)}.") + # Remove any trailing null bytes first.... + value = value.rstrip(b'\x00') + # then convert to ASCII string for validation + value = value.decode("ascii") + + # match regex r"^[A-Z_]{0,8}$" or raise exception + if not re.fullmatch(r"^[A-Z_]{0,8}$", value): + raise ValueError(f"word mask string must match regex r'^[A-Z_]{{0,8}}$', got '{value}'.") + return value + + @classmethod + def _validate_guessed_letter_bitmask(cls, value: bytes | bytearray | int) -> int: + if not isinstance(value, (bytes, bytearray, int)): + raise ValueError("guessed letter bitmask must be bytes or bytearray.") + if isinstance(value, int): + result = value + else: + if len(value) != 4: + raise ValueError(f"guessed letter bitmask must be an int or exactly 4 bytes, got {len(value)} bytes.") + result = int.from_bytes(value, byteorder="big") + if (result & cls._GUESSED_LETTER_MASK) != result: + raise ValueError(f"guessed letter bitmask must only have bits 0-25 set, got 0x{result:08x}.") + return result + + @classmethod + def _validate_wrong_guess_count(cls, value: int) -> int: + if not isinstance(value, int): + raise ValueError("wrong guess count must be an integer.") + if not (0 <= value <= 6): + raise ValueError(f"wrong guess count must be in range [0..6], got {value}.") + return value + + @classmethod + def _validate_game_status(cls, value: int | Hangman_GameStatus) -> Hangman_GameStatus: + if isinstance(value, Hangman_GameStatus): + return value + if not isinstance(value, int): + raise ValueError("game status must be an integer or Hangman_GameStatus.") + result = Hangman_GameStatus._value2member_map_.get(value) + if result is None: + raise ValueError(f"game status must be one of {list(Hangman_GameStatus._value2member_map_.keys())}, got 0x{value:02x}.") + return Hangman_GameStatus(value) + + def __init__(self, wordmask : bytes | bytearray | str, wrong_guess_count : int, guessed_letter_bitmask : bytes | bytearray | int, game_status : int): + self.wordmask = self._validate_wordmask(wordmask) + self.wrong_guess_count = self._validate_wrong_guess_count(wrong_guess_count) + self.guessed_letter_bitmask = self._validate_guessed_letter_bitmask(guessed_letter_bitmask) + self.game_status = self._validate_game_status(game_status) + + def pack(self) -> bytes: + return struct.pack( + self._FORMAT, + self.wordmask, + self.wrong_guess_count, + self.guessed_letter_bitmask, + self.game_status.value + ) + + @classmethod + def unpack(cls, data: bytes | bytearray) -> "Hangman_State": + """Deserialize from bytes/bytearray into a Packet object.""" + try: + if len(data) != cls._SIZE: + raise ValueError(f"Invalid data length: expected {cls._SIZE}, got {len(data)}") + wordmask, wrong_guess_count, guessed_letter_bitmask, game_status = struct.unpack(cls._FORMAT, data) + return cls( + wordmask=wordmask, + wrong_guess_count=wrong_guess_count, + guessed_letter_bitmask=guessed_letter_bitmask, + game_status=game_status + ) + except struct.error as e: + raise ValueError(f"Deserialization error: {e}") + + def __repr__(self): + return f"{self.__class__.__name__}(wordmask={self.wordmask}, wrong_guess_count={self.wrong_guess_count}, guessed_letter_bitmask={self.guessed_letter_bitmask}, game_status={self.game_status})" + + class Hangman_GuessResult(Hangman_State): + # This class is represented by a single WasGuessCorrect byte (0x00 == wrong, 0x01 == correct), + # followed by the Hangman_State packed structure. + + _FORMAT = " bool: + if not isinstance(value, (int, bool)): + raise ValueError("WasGuessCorrect must be an integer or boolean.") + if isinstance(value, bool): + return value + if value not in (0, 1): + raise ValueError(f"WasGuessCorrect must be 0 or 1, got {value}.") + return bool(value) + + def __init__(self, was_guess_correct : int | bool, wordmask : bytes | bytearray | str, wrong_guess_count : int, guessed_letter_bitmask : bytes | bytearray | int, game_status : int): + super().__init__(wordmask=wordmask, wrong_guess_count=wrong_guess_count, guessed_letter_bitmask=guessed_letter_bitmask, game_status=game_status) + self.was_guess_correct = self._validate_was_guess_correct(was_guess_correct) + + def pack(self) -> bytes: + return struct.pack( + self._FORMAT, + self.was_guess_correct, + self.wordmask, + self.wrong_guess_count, + self.guessed_letter_bitmask, + self.game_status.value + ) + + @classmethod + def unpack(cls, data: bytes | bytearray) -> "Hangman_GuessResult": + """Deserialize from bytes/bytearray into a Packet object.""" + try: + if len(data) != cls._SIZE: + raise ValueError(f"Invalid data length: expected {cls._SIZE}, got {len(data)}") + was_guess_correct, wordmask, wrong_guess_count, guessed_letter_bitmask, game_status = struct.unpack(cls._FORMAT, data) + return cls( + was_guess_correct=was_guess_correct, + wordmask=wordmask, + wrong_guess_count=wrong_guess_count, + guessed_letter_bitmask=guessed_letter_bitmask, + game_status=game_status + ) + except struct.error as e: + raise ValueError(f"Deserialization error: {e}") + + def __repr__(self): + return f"{self.__class__.__name__}(was_guess_correct={self.was_guess_correct}, wordmask={self.wordmask}, wrong_guess_count={self.wrong_guess_count}, guessed_letter_bitmask={self.guessed_letter_bitmask}, game_status={self.game_status})" + +if True: # Commands ... HF14CommandBase and SelectApplet classes + + class Cmd_DropField: + def __init__(self): + pass + def generate_proxmark_cmd_string(self) -> str: + return "hf 14a reader --drop" + + # Define a base class for the HF 14a commands. + # CLA, INS, P1, P2, optional length of data buffer to send (default = 0, -1 for variable), optional length of data to receive (default = 0, -1 for variable) + class HF14aCommandBase : + + _REGEX_COMMAND = re.compile( r">>> (?P(([A-Fa-f0-9][A-Fa-f0-9])+))" ) + _REGEX_DATA = re.compile( r"<<< (?P(([A-Fa-f0-9][A-Fa-f0-9]){2,}))") + + def __init__(self, cla: int, ins: int, p1: int = 0, p2: int = 0, send_buffer: bytes | bytearray | None = None, expect_data_length: int = 0) -> None: + if not (0 <= cla <= 0xFF): + raise ValueError(f"CLA must be in range [0x00 .. 0xFF], got 0x{cla:02X}.") + if not (0 <= ins <= 0xFF): + raise ValueError(f"INS must be in range [0x00 .. 0xFF], got 0x{ins:02X}.") + if not (0 <= p1 <= 0xFF): + raise ValueError(f"P1 must be in range [0x00 .. 0xFF], got 0x{p1:02X}.") + if not (0 <= p2 <= 0xFF): + raise ValueError(f"P2 must be in range [0x00 .. 0xFF], got 0x{p2:02X}.") + if send_buffer is not None and len(send_buffer) > 255: + raise ValueError(f"extended send buffer length (>0xFF) is not tested/supported, got {len(send_buffer)}.") + if expect_data_length > 255: + raise ValueError(f"extended expected data length (>0xFF) is not tested/supported, got {expect_data_length}.") + + self.cla: int = cla + self.ins: int = ins + self.p1: int = p1 + self.p2: int = p2 + self.send_buffer : bytes | bytearray | None = send_buffer + self.expect_data_length : int = expect_data_length + + def generate_proxmark_cmd_string(self, select_card : bool = False, keep_field : bool = True) -> str: + cmdString : str = "hf 14a apdu " + if select_card: + cmdString += "--select " + if keep_field: + cmdString += "--keep " + cmdString += f"-m {self.cla:02X}{self.ins:02x}{self.p1:02x}{self.p2:02x} " + if self.send_buffer is None or len(self.send_buffer) < 1: + cmdString += "-d \"\" " + else: + cmdString += f"-d {self.send_buffer.hex()} " + if self.expect_data_length > 256: + cmdString += "--extended" + cmdString += f"-l {self.expect_data_length:d}" + return cmdString + + @classmethod + def parse_proxmark_response(cls, multiline_response : Optional[Sequence[str]]) -> HF14aCommandResult: + cmd : bytearray = bytearray() + data : bytearray = bytearray() + if multiline_response is not None and len(multiline_response) > 0: + # First pass: append all the data + for line in multiline_response: + matchC = cls._REGEX_COMMAND.search(line) + if matchC: + raw_cmd = matchC.group("cmd") + if raw_cmd is not None and len(raw_cmd) > 0: + cmd.extend(bytes.fromhex(raw_cmd)) + matchD = cls._REGEX_DATA.search(line) + if matchD: + raw_data = matchD.group("data") + if raw_data is not None and len(raw_data) > 0: + data.extend(bytes.fromhex(raw_data)) + + if len(cmd) < 1: + msg = "!!! Could not find any command." + LOG_HF14.fatal(msg) + raise RuntimeError(msg) + if len(data) < 2: + msg = f"!!! Could not find response to cmd {cmd.hex()}." + LOG_HF14.fatal(msg) + raise RuntimeError(msg) + + LOG_HF14.debug(f">>> {cmd.hex()}") + LOG_HF14.debug(f"<<< {data.hex()}") + + # the last two bytes of data are the status code + status = int.from_bytes(data[-2:], byteorder="big") + data = data[:-2] + return HF14aCommandResult(status = status, cmd = bytes(cmd), data = bytes(data)) + + # Classes to select a different applet on the card. + + class Cmd_SelectApplet_Base(HF14aCommandBase): + def __init__(self, applet_id : bytes | bytearray): + if len(applet_id) < 5 or len(applet_id) > 16: + raise ValueError(f"applet_id must be between 5 and 16 bytes, got {len(applet_id)}.") + super().__init__(cla = 0x00, ins = 0xA4, p1 = 0x04, p2 = 0x00, send_buffer = applet_id, expect_data_length = 0) + + class Cmd_SelectApplet_GuessKey(Cmd_SelectApplet_Base): + def __init__(self): + applet_id : bytes = bytes([0xf0, 0x43, 0x42, 0x47, 0x4B, 0x01]) + super().__init__(applet_id) + + class Cmd_SelectApplet_Hangman(Cmd_SelectApplet_Base): + def __init__(self): + applet_id : bytes = bytes([0xf0, 0x43, 0x42, 0x48, 0x4D, 0x4E, 0x01]) + super().__init__(applet_id) + + class Cmd_SelectApplet_TicTacToe(Cmd_SelectApplet_Base): + def __init__(self): + applet_id : bytes = bytes([0xf0, 0x43, 0x42, 0x54, 0x54, 0x54, 0x01]) + super().__init__(applet_id) + +if True: # GuessKey applet commands + + class Cmd_GuessKey_Base(HF14aCommandBase): + def __init__(self, ins : int, send_buffer : bytes | bytearray | None = None, expect_data_length : int = 0): + super().__init__(cla = 0x80, ins = ins, p1 = 0x00, p2 = 0x00, send_buffer = send_buffer, expect_data_length = expect_data_length) + + class Cmd_GuessKey_Guess(Cmd_GuessKey_Base): + def __init__(self, value : int): + if (value < 0 or value > 0xFFFFFF): + raise ValueError(f"Value must be between 0 and 0xFFFFFF, was {value:06x}.") + to_send = value.to_bytes(3, 'big') # 3 bytes, big-endian + super().__init__(ins = 0x02, send_buffer = to_send) + + class Cmd_GuessKey_GetStatistics(Cmd_GuessKey_Base): + def __init__(self): + super().__init__(ins = 0x04, send_buffer = None, expect_data_length = 3) + +if True: # TicTacToe applet commands + + class Cmd_TicTacToe_Base(HF14aCommandBase): + def __init__(self, ins : int, p1 : int = 0x00, p2 : int = 0x00, expect_data_length : int = 0): + super().__init__(cla = 0x80, ins = ins, p1 = p1, p2 = p2, send_buffer = None, expect_data_length = expect_data_length) + + class Cmd_TicTacToe_NewGame(Cmd_TicTacToe_Base): + def __init__(self, card_moves_first : bool = False): + p1 = 0x01 if card_moves_first else 0x00 + super().__init__(ins = 0x02, p1 = p1, p2 = 0x00, expect_data_length = 10) + + class Cmd_TicTacToe_PlayMove(Cmd_TicTacToe_Base): + def __init__(self, row : int, col : int): + if (row < 0 or row > 2): + raise ValueError(f"Row must be in range [0..2], was {row:02x}.") + if (col < 0 or col > 2): + raise ValueError(f"Column must be in range [0..2], was {col:02x}.") + super().__init__(ins = 0x04, p1 = row, p2 = col, expect_data_length = 10) + + class Cmd_TicTacToe_GetBoard(Cmd_TicTacToe_Base): + def __init__(self): + super().__init__(ins = 0x06, p1 = 0x00, p2 = 0x00, expect_data_length = 10) + + class Cmd_TicTacToe_GetGameCount(Cmd_TicTacToe_Base): + def __init__(self): + super().__init__(ins = 0x08, p1 = 0x00, p2 = 0x00, expect_data_length = 2) + +if True: # Hangman applet commands + + class Cmd_Hangman_Base(HF14aCommandBase): + def __init__(self, ins : int, p1 : int = 0x00, expect_data_length : int = 0): + super().__init__(cla = 0x80, ins = ins, p1 = p1, p2 = 0x00, send_buffer = None, expect_data_length = expect_data_length) + + class Cmd_Hangman_NewGame(Cmd_Hangman_Base): + def __init__(self): + super().__init__(ins = 0x02, expect_data_length = 14) + + class Cmd_Hangman_GuessLetter(Cmd_Hangman_Base): + def __init__(self, letter : str): + if not isinstance(letter, str): + raise ValueError(f"Letter must be a string, got {type(letter).__name__}.") + if len(letter) != 1: + raise ValueError(f"Letter must be a single character, got '{letter}'.") + # require regex match against r"^[A-Z]$" (single uppercase letter) + if not re.match(r"^[A-Z]$", letter): + raise ValueError(f"Letter must be a single uppercase alphabetic character [A-Z], got '{letter}'.") + letter_byte = ord(letter) + super().__init__(ins = 0x04, p1 = letter_byte, expect_data_length = 15) + + class Cmd_Hangman_GetState(Cmd_Hangman_Base): + def __init__(self): + super().__init__(ins = 0x06, expect_data_length = 14) + + class Cmd_Hangman_GetGameCount(Cmd_Hangman_Base): + def __init__(self): + super().__init__(ins = 0x08, expect_data_length = 2) + +# Hangman string UI, index by count of incorrect guesses (0..6). +# Each string is a 6-line ASCII art representation of the hangman state, +# with the gallows and the (partially drawn) hangman figure. +hangman_character_art = ( + + " ____ \n" + " | | \n" + " | \n" + " | \n" + " | \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | \n" + " | \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | | \n" + " | \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | /| \n" + " | \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | /|\\\n" + " | \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | /|\\\n" + " | / \n" + "_|_ \n", + + + " ____ \n" + " | | \n" + " | O \n" + " | /|\\\n" + " | / \\\n" + "_|_ \n", +) + + +if True: # Proxmark3 abstraction layer + def detect_proxmark_device(): + + # Detect the Proxmark3 connection type (USB, Bluetooth, or TCP). + try: + # Try running `pm3 --list` to detect available Proxmark devices + result = subprocess.run(["pm3", "--list"], capture_output=True, text=True, check=True) + lines = result.stdout.splitlines() + + for line in lines: + if ":" in line: # Expected format: "1: /dev/ttyACM0" or "1: bt:xx:xx:xx:xx:xx" + device = line.split(": ", 1)[1].strip() + LOG_PM_DEVICE.info(f"✅ Using Proxmark device: {device}") + return device + except Exception: + LOG_PM_DEVICE.info("⚠️ `pm3 --list` failed, falling back to manual detection.") + + # If `pm3 --list` doesn't work, check manually (Android or fallback mode) + usb_devices = ["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyUSB0", "/dev/ttyUSB1"] + for dev in usb_devices: + if os.path.exists(dev): + LOG_PM_DEVICE.info(f"✅ Using USB device: {dev}") + return dev + + # Default to TCP mode for Android Termux + LOG_PM_DEVICE.info(f"⚠️ No USB or Bluetooth device found, defaulting to TCP (localhost:{TCP_PORT})") + return f"tcp:localhost:{TCP_PORT}" + + CACHED_PROXMARK_DEVICE = detect_proxmark_device() + + def send_proxmark_command(command : str) -> Sequence[str]: + + LOG_PROXMARK.info(f">>> {command}") + + if PM3_AVAILABLE: + p.console(command) + multiline_response = p.grabbed_output.strip() + lines = multiline_response.splitlines() + else: + full_command = f"{command}\n" + host_device = CACHED_PROXMARK_DEVICE + process = subprocess.Popen( + [PROXMARK3_FULLY_QUALIFIED_PATH, "-p", host_device], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + output, error = process.communicate(full_command) + time.sleep(0.2) # Small delay to let Proxmark process fully + # Combine stdout and stderr + response_lines = (output or "").splitlines() + (error or "").splitlines() + # this creates a generator ... not the final sequence + lines : List[str] = [ line.strip() for line in response_lines if "STDIN unexpected end" not in line ] + + if LOG_PROXMARK.isEnabledFor(logging.INFO): + for line in lines: + LOG_PROXMARK.info(f"<<< {line}") + return lines + + def send_HF14a_command(cmd : HF14aCommandBase, select_card : bool = False, keep_field : bool = True) -> HF14aCommandResult: + cmd_string : str = cmd.generate_proxmark_cmd_string(select_card = select_card, keep_field = keep_field) + response_lines : Sequence[str] = send_proxmark_command(cmd_string) + cmdResult : HF14aCommandResult = HF14aCommandBase.parse_proxmark_response(response_lines) + return cmdResult + + def select_game(cmd : Cmd_SelectApplet_Base) -> HF14aCommandResult: + if True: # Drop the field first, to reset the card to a known-good state + # TODO: Selection of game: Turn off field, delay, select applet w/field kept on + dropField : str = Cmd_DropField().generate_proxmark_cmd_string() + send_proxmark_command(dropField) + time.sleep(0.2) + # NOTE: dropping the field has zero output from client + return send_HF14a_command(cmd, select_card = True, keep_field = True) + +if True: # TicTacToe interactive UI + + def dc34_PlayTicTacToe_start_new_game(card_moves_first : bool) -> TicTacToe_BoardState: + # Start a new TicTacToe game + newGameCmd = Cmd_TicTacToe_NewGame(card_moves_first = card_moves_first) + newGameResult = send_HF14a_command(newGameCmd) + if newGameResult is None: + msg = "Error: No HF14a response when starting new TicTacToe game." + LOG.fatal(msg) + raise RuntimeError(msg) + if not newGameResult.IsSuccess: + status = Status_TicTacToe(newGameResult.status) + msg = f"Error: Failed to start new TicTacToe game, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + if len(newGameResult.data) == 0: + msg = "Error: No data returned when starting new TicTacToe game." + LOG.fatal(msg) + raise RuntimeError(msg) + board : TicTacToe_BoardState = TicTacToe_BoardState.unpack(newGameResult.data) + return board + + def dc34_PlayTicTacToe_play_move(board_index : int) -> TicTacToe_BoardState: + # Send the move to the card + playMoveCmd = Cmd_TicTacToe_PlayMove(row=board_index // 3, col=board_index % 3) + playMoveResult = send_HF14a_command(playMoveCmd) + if not playMoveResult.IsSuccess: + status = Status_TicTacToe(playMoveResult.status) + msg = f"Error: Failed to play move, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + board = TicTacToe_BoardState.unpack(playMoveResult.data) + return board + + def dc34_PlayTicTacToe_print_board(board : TicTacToe_BoardState): + print(" {0} | {1} | {2} 1 | 2 | 3".format(board.cells[0], board.cells[1], board.cells[2])) + print("---+---+--- ---+---+---") + print(" {0} | {1} | {2} 4 | 5 | 6".format(board.cells[3], board.cells[4], board.cells[5])) + print("---+---+--- ---+---+---") + print(" {0} | {1} | {2} 7 | 8 | 9".format(board.cells[6], board.cells[7], board.cells[8])) + + def dc34_PlayTicTacToe(): + cmdResult = select_game(Cmd_SelectApplet_TicTacToe()) + if not cmdResult.IsSuccess: + status = Status_HF14a(cmdResult.status) + msg = f"Error: Failed to select TicTacToe applet, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + LOG.debug("TicTacToe applet selected successfully.") + + # Ask if the user wants to go first or let the card go first + while True: + first_choice = input("Do you want to go first? (Y/N): ").strip().upper() + if first_choice in ['Y', 'N']: + break + print("Invalid choice. Please enter 'Y' or 'N'.") + card_first = (first_choice == 'N') + + board = dc34_PlayTicTacToe_start_new_game(card_moves_first = card_first) + while True: + # Print the board to the screen + print() + dc34_PlayTicTacToe_print_board(board) + print() + + # Check if game was won/lost + if board.game_status == TicTacToe_GameStatus.User_Wins: + print("Congratulations! You won the game!") + return # End of game, so exit function + if board.game_status == TicTacToe_GameStatus.Card_Wins: + print("The card won the game. Better luck next time!") + return # End of game, so exit function + if board.game_status == TicTacToe_GameStatus.Draw: + print("The game ended in a draw.") + return # End of game, so exit function + if board.game_status != TicTacToe_GameStatus.InProgress: + msg = f"Error: Unknown game status {board.game_status} (0x{board.game_status.value:02X})." + LOG.fatal(msg) + raise RuntimeError(msg) + # Prompt user for their move + user_input = input("Enter your move (1-9): ").strip() + if not user_input.isdigit(): + print("Invalid input. Please enter a number between 1 and 9.") + continue + if not ( 1 <= int(user_input) <= 9 ): + print("Invalid input. Please enter a number between 1 and 9.") + continue + move_index = int(user_input) - 1 + if board.cells[move_index] != TicTacToe_CellValue._: + print("Invalid move: Cell is already occupied. Please choose another cell.") + continue + board = dc34_PlayTicTacToe_play_move(move_index) + + # End of while loop ... don't think this should ever be reached. + LOG.critical("Unexpectedly existing the while loop in dc34_PlayTicTacToe(). This should never happen.") + +if True: # Hangman interactive UI + + def dc34_PlayHangman_start_new_game() -> Hangman_State: + newGameCmd = Cmd_Hangman_NewGame() + newGameResult = send_HF14a_command(newGameCmd) + if not newGameResult.IsSuccess: + status = Status_HF14a(newGameResult.status) + msg = f"Error: Failed to start new Hangman game, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + return Hangman_State.unpack(newGameResult.data) + + def dc34_PlayHangman_guess_letter(letter : str, state : Hangman_State) -> Hangman_GuessResult: + guessCmd = Cmd_Hangman_GuessLetter(letter) + guessResult = send_HF14a_command(guessCmd) + if not guessResult.IsSuccess: + status = Status_HF14a(guessResult.status) + msg = f"Error: Failed to guess letter '{letter}', status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + return Hangman_GuessResult.unpack(guessResult.data) + + def dc34_PlayHangman_print_state(state : Hangman_State) -> None: + print(hangman_character_art[state.wrong_guess_count]) + print() + print(f"Word: {state.wordmask}") + print() + print(f"{Hangman_State.guessed_letter_bitmask_to_remaining_letters(state.guessed_letter_bitmask)}") + + def dc34_PlayHangman_validate_user_input(user_input : str, state : Hangman_State) -> Optional[str]: + if len(user_input) != 1: + return None + user_input = user_input.strip().upper() + if not re.match(r"^[A-Z]$", user_input): + return None + letter_index = ord(user_input) - ord('A') + if (state.guessed_letter_bitmask & (1 << letter_index)) != 0: + return None + return user_input + + def dc34_PlayHangman(): + cmdResult = select_game(Cmd_SelectApplet_Hangman()) + if not cmdResult.IsSuccess: + status = Status_HF14a(cmdResult.status) + msg = f"Error: Failed to select Hangman applet, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + LOG.debug("Hangman applet selected successfully.") + + state : Hangman_State | Hangman_GuessResult = dc34_PlayHangman_start_new_game() + + while True: + print() + dc34_PlayHangman_print_state(state) + print() + if state.game_status == Hangman_GameStatus.PlayerWon: + print("Congratulations! You won the game!") + return # End of game, so exit function + if state.game_status == Hangman_GameStatus.PlayerLost: + print("Sorry, word not guessed. Better luck next time!") + return # End of game, so exit function + if state.game_status != Hangman_GameStatus.InProgress: + msg = f"Error: Unknown game status {state.game_status} (0x{state.game_status.value:02X})." + LOG.fatal(msg) + raise RuntimeError(msg) + + user_input = input("Guess Letter: ") + user_input = dc34_PlayHangman_validate_user_input(user_input, state) + if user_input is not None: + state = dc34_PlayHangman_guess_letter(user_input, state) + else: + print("Invalid input. Please enter a single letter that you haven't guessed yet.") + + # End of while loop ... don't think this should ever be reached. + LOG.critical("Unexpectedly existing the while loop in dc34_PlayHangman(). This should never happen.") + +if True: # GuessKey interactive UI + + def dc34_PlayGuessKey_get_statistics() -> GuessKey_Statistics: + statsResult = send_HF14a_command(Cmd_GuessKey_GetStatistics()) + if not statsResult.IsSuccess: + status = Status_HF14a(statsResult.status) + msg = f"Error: Failed to get GuessKey statistics, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + return GuessKey_Statistics.unpack(statsResult.data) + + def dc34_PlayGuessKey_guess_key(value : int) -> bool: + guessCmd = Cmd_GuessKey_Guess(value) + guessResult = send_HF14a_command(guessCmd) + status = Status_GuessKey(guessResult.status) + if status not in (Status_GuessKey.Success, Status_GuessKey.IncorrectGuess): + msg = f"Error: Failed to send guess '{value:06X}', status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + return status == Status_GuessKey.Success + + def dc34_PlayGuessKey(): + cmdResult = select_game(Cmd_SelectApplet_GuessKey()) + if not cmdResult.IsSuccess: + status = Status_HF14a(cmdResult.status) + msg = f"Error: Failed to select GuessKey applet, status code: {status} (0x{status.value:04X})" + LOG.fatal(msg) + raise RuntimeError(msg) + LOG.debug("GuessKey applet selected successfully.") + + while True: + # Get state (# of guesses already attempted, if already solved, etc.) + stats = dc34_PlayGuessKey_get_statistics() + if stats.is_solved: + print(f"Congratulations! The key was guessed in {stats.guess_count} (0x{stats.guess_count:04X}) attempts.") + return + + print(f"Current Guess Count: {stats.guess_count:5d} (0x{stats.guess_count:04X})") + user_input = input("Enter six hex character guess (000000-FFFFFF) or 'exit' to return to main menu: ").strip() + if user_input.lower() == 'exit': + print("Returning to main menu.") + return + if not (len(user_input) == 6 and all(c in "0123456789ABCDEFabcdef" for c in user_input)): + print("Invalid input. Please enter six hex characters (000000-FFFFFF).") + continue + guess_value = int(user_input, 16) + + if dc34_PlayGuessKey_guess_key(guess_value): + print(f"Correct guess! The key was {guess_value:06X}.") + else: + print(f"Incorrect; Try again.") + +def dc34_main_menu(): + invalid_choice_count : int = 0 + while True: + print() + print("DC34 Proxmark3 Game Applet Client") + print("================================") + print("1 / G. GuessKey") + print("2 / T. TicTacToe") + print("3 / H. Hangman") + print("4 / X. Exit") + choice = input("Select an option [1234GTHXgthx]: ") + + if choice in ['1', 'G', 'g']: + invalid_choice_count = 0 + dc34_PlayGuessKey() + elif choice in ['2', 'T', 't']: + invalid_choice_count = 0 + dc34_PlayTicTacToe() + elif choice in ['3', 'H', 'h']: + invalid_choice_count = 0 + dc34_PlayHangman() + elif choice in ['4', 'X', 'x']: + LOG.info("Exiting...") + break # out of the while loop + elif invalid_choice_count > 3: + # allows five times: 0,1,2,3,4 + LOG.fatal("Repeated invalid choices detected. Exiting.") + break # out of the while loop + else: + invalid_choice_count += 1 + print(f"Invalid choice ({invalid_choice_count}): {choice}") + # End of main menu loop, aka exiting + pass + +if __name__ == '__main__': + dc34_main_menu()