Files
proxmark3/client/pyscripts/dc34.py
T
Henry Gabryjelski 075f353f98 More fixes based on CoPilot suggestions
* add a check to prevent multiple log handler additions.  Still a hack, but ok for this purpose.
* GuessKey_Statistics.__init__() now validates is_solved using same class validator as unpack()
* fix exception text to reflect allowed types for Hangman_State
* Also in Hangman_State, fix validation when input is bytes / bytearray
* In dc34_PlayHangman_validate_user_input(), strip the input before checking length.
2026-08-23 01:10:49 -07:00

996 lines
43 KiB
Python

#!/usr/bin/env python3
# See https://dc34.rfid.wtf/
import subprocess
import time
import sys
import os
import re
from enum import Enum
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
required_version = (3, 10)
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.")
sys.exit(1)
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
if len(LOG.handlers) < 1:
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)
self.is_solved = self._validate_bool(is_solved)
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 int, 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="little")
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.encode("ascii"),
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 = "<B8sBIB"
_SIZE = struct.calcsize(_FORMAT)
# B s s s s s s s s B I I I I b ____
# B s s s s s s s s B I I I I b____
# <<< 005f5f5f5f5f5f00000100400000009000 == 00 40 00 00 Letters: O
# <<< 015f5f5f5f455f00000110400000009000 == 10 40 00 00 Letters: O, E(4)
# ABCD EFGH IJKL MNOP QRST UVWX YZ.. ....
# 0000 0000 0000 0010 0000 0000 0000 0000 == 00 02 00 00 == WRONG
# 0000 1000 0000 0010 0000 0000 0000 0000 == 08 02 00 00 == WRONG
#
# .... ..ZY XWVU TSRQ PONM LKJI HGFE DCBA
# 0000 0000 0000 0000 0100 0000 0000 0000 == 00 00 40 00 == stored little-endian
# 0000 0000 0000 0000 0100 0000 0001 0000 == 00 00 40 10 == stored little-endian
@classmethod
def _validate_was_guess_correct(cls, value: int | bool) -> 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.encode("ascii"),
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<cmd>(([A-Fa-f0-9][A-Fa-f0-9])+))" )
_REGEX_DATA = re.compile( r"<<< (?P<data>(([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
TCP_PORT = 4444
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 : Optional[str] = None
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"
global CACHED_PROXMARK_DEVICE
if CACHED_PROXMARK_DEVICE is None:
CACHED_PROXMARK_DEVICE = detect_proxmark_device()
host_device = CACHED_PROXMARK_DEVICE
process = subprocess.Popen(
["./pm3", "-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 exiting 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]:
user_input = user_input.strip().upper()
if len(user_input) != 1:
return None
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 exiting 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()