127 lines
3.8 KiB
Python
127 lines
3.8 KiB
Python
"""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
|