"""C#-compatible wafer license file parsing. License files are AES-128-CBC encrypted `.bin` blobs named ``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the 5-digit serial number, and ``00`` the 2-digit license level. The blob is ``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is a fixed-offset record:: [0:6] serial e.g. "A00001" [7:15] mfg date 8 hex chars [16:18] level 2 decimal digits ("00" basic, "01"+ adds replay) [19:27] license date 8 hex chars The key is hardcoded to match the C# application exactly """ import logging import re from dataclasses import dataclass from pathlib import Path from pygui.backend.crypto.crypto_helper import CryptoHelper log = logging.getLogger(__name__) LICENSE_KEY = b"ThisIsA16ByteKey" LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$") _MIN_TEXT_LEN = 27 # ===== License Record ===== @dataclass(frozen=True) class License: """One parsed license file (all fields as stored in the file).""" serial: str # "A00001" level: str # "00".."03" mfg_date_hex: str # 8 hex chars lic_date_hex: str # 8 hex chars @property def level_int(self) -> int: """Level as int; -1 if unparseable.""" try: return int(self.level, 10) except ValueError: return -1 @property def mfg_date_decimal(self) -> str: """Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16)).""" return _hex_to_decimal(self.mfg_date_hex) @property def lic_date_decimal(self) -> str: """License date shown as decimal, same conversion as the C# grid.""" return _hex_to_decimal(self.lic_date_hex) def _hex_to_decimal(hex_str: str) -> str: try: return str(int(hex_str, 16)) except ValueError: return "" # ===== Blob Parsing ===== def split_data_iv(blob: bytes) -> tuple[bytes, bytes]: """Split a license blob into (ciphertext, iv) — IV is the last 16 bytes.""" if len(blob) < 32: raise ValueError("License blob too short") return blob[:-16], blob[-16:] def parse_license_blob(blob: bytes) -> License | None: """Decrypt and parse one license blob. None if malformed.""" try: data, iv = split_data_iv(blob) message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv) text = message.decode("utf-8") except (ValueError, UnicodeDecodeError) as exc: log.warning("Undecryptable license blob: %s", exc) return None if len(text) < _MIN_TEXT_LEN: log.warning("License text too short (%d chars)", len(text)) return None return License( serial=text[0:6], mfg_date_hex=text[7:15], level=text[16:18], lic_date_hex=text[19:27], ) # ===== Directory Scan ===== def read_license_files(licenses_dir: str | Path) -> list[License]: """Parse every valid license .bin in the directory. A file counts only if its name matches ``X00000-00.bin`` AND equals ``{serial}-{level}.bin`` from its own decrypted payload — same tamper check as the C# app. """ directory = Path(licenses_dir) if not directory.is_dir(): return [] licenses: list[License] = [] for path in sorted(directory.glob("*.bin")): if not LICENSE_FILENAME_RE.match(path.name): continue try: blob = path.read_bytes() except OSError as exc: log.warning("Cannot read license file %s: %s", path, exc) continue lic = parse_license_blob(blob) if lic is None: continue if path.name != f"{lic.serial}-{lic.level}.bin": log.warning("License filename mismatch: %s", path.name) continue licenses.append(lic) return licenses