# -*- coding: utf-8 -*- """ AzA Aktivierungsschlüssel-System. Erzeugt, prüft und persistiert Aktivierungsschlüssel. Schlüssel-Format ---------------- Kanonisch: AZA-YYYYMMDD- Beispiel: ``AZA-20271231-1a2b3c4d5e6f`` Damit ältere oder von Hand abgetippte Schlüssel nicht unnötig am Format scheitern, akzeptiert der Validator ausserdem: * gross-/kleingeschrieben (``aza-...`` ist gleichwertig zu ``AZA-...``); * Whitespace, Tabulatoren, Zeilenumbrüche im / vor / nach dem Schlüssel; * Unicode-Bindestriche (en-dash, em-dash, geschützter Bindestrich); * Datum mit Trennzeichen (``YYYY-MM-DD``, ``YYYY.MM.DD``, ``YYYY/MM/DD``); * Signatur-Längen 8, 12 oder 16 (frühere Builds). Damit funktioniert ein bereits einmal vergebener Schlüssel auf demselben Computer auch dann weiter, wenn er kopierte Leerzeichen, PDF-Bindestriche oder leichte Schreibvarianten enthält. """ from __future__ import annotations import hmac import hashlib import json import os import re import time import unicodedata from datetime import datetime, date, timedelta from typing import Optional, Tuple from aza_config import ( get_writable_data_dir, ACTIVATION_CONFIG_FILENAME, ACTIVATION_HMAC_SECRET, APP_HARD_EXPIRY, APP_TRIAL_DAYS, ) # ── Pfade ───────────────────────────────────────────────────────────── def _activation_path() -> str: return os.path.join(get_writable_data_dir(), ACTIVATION_CONFIG_FILENAME) # ── Signatur ────────────────────────────────────────────────────────── def _sign(expiry_compact: str, length: int = 12) -> str: """HMAC-Hex-Signatur über das Ablaufdatum (compact ``YYYYMMDD``).""" sig = hmac.new( ACTIVATION_HMAC_SECRET.encode("utf-8"), expiry_compact.encode("utf-8"), hashlib.sha256, ).hexdigest() if length <= 0: return sig return sig[:length] # ── Schlüssel generieren ────────────────────────────────────────────── def generate_key(expiry_date: str) -> str: """Erzeugt einen kanonischen Aktivierungsschlüssel ``AZA-YYYYMMDD-``.""" dt = datetime.strptime(expiry_date, "%Y-%m-%d") tag = dt.strftime("%Y%m%d") return f"AZA-{tag}-{_sign(tag, 12)}" # ── Normalisierung ─────────────────────────────────────────────────── # Alle Zeichen, die wir wie einen ASCII-Bindestrich behandeln. _DASH_CHARS = ( "-", # ASCII "\u2010", # hyphen "\u2011", # non-breaking hyphen "\u2012", # figure dash "\u2013", # en dash "\u2014", # em dash "\u2015", # horizontal bar "\u2212", # minus sign "\uFE58", # small em dash "\uFE63", # small hyphen-minus "\uFF0D", # fullwidth hyphen-minus ) _DASH_TRANSLATE = {ord(c): "-" for c in _DASH_CHARS} _KEY_PREFIX_PATTERN = re.compile(r"^A\W*Z\W*A\b", re.IGNORECASE) _DATE_BLOCK_PATTERN = re.compile( r"(?P\d{4})\s*[-./ ]?\s*(?P\d{2})\s*[-./ ]?\s*(?P\d{2})" ) def _normalize_key(raw: str) -> Optional[str]: """Bringt einen frei eingegebenen Schlüssel in das kanonische Format ``AZA-YYYYMMDD-`` (Signatur lower-case). Liefert ``None`` zurück, wenn schon strukturell nichts brauchbares erkennbar ist – das ruft dann sauber den Format-Fehler aus.""" if not raw or not isinstance(raw, str): return None # Unicode-NFKC, Bindestriche normalisieren, Whitespace komplett raus. s = unicodedata.normalize("NFKC", raw).translate(_DASH_TRANSLATE) s = re.sub(r"\s+", "", s) s = s.upper() if not s: return None # Optionalen "AZA"-Präfix tolerant erkennen (auch z. B. "A-Z-A"). m = _KEY_PREFIX_PATTERN.match(s) if m: body = s[m.end():] else: body = s body = body.lstrip("-") # Datum extrahieren – akzeptiert YYYYMMDD ebenso wie 2027-12-31. md = _DATE_BLOCK_PATTERN.search(body) if not md: return None tag = f"{md.group('y')}{md.group('m')}{md.group('d')}" # Alles ab dem Datum entfernen, Rest = Signatur (nur hex behalten). rest = body[md.end():] sig = re.sub(r"[^0-9A-F]", "", rest).lower() if not sig: # Manchmal sitzt die Signatur direkt vor dem Datum (sehr alte Builds) head = body[:md.start()] sig = re.sub(r"[^0-9A-F]", "", head).lower() if not sig: return None return f"AZA-{tag}-{sig}" def _try_signature_lengths(date_compact: str, sig: str) -> bool: """Prüft die Signatur exakt gegen ihre eigene Länge. Erlaubt frühere Builds mit 8 oder 16 Zeichen Signatur; vermeidet aber bewusst, eine 12-Zeichen-Signatur durch einen 8-Zeichen-Match zu akzeptieren (Manipulationen müssen scheitern).""" sig_len = len(sig) if sig_len not in (8, 12, 16, 32, 64): return False try: expected = _sign(date_compact, sig_len) except Exception: return False return hmac.compare_digest(sig, expected) # ── Schlüssel validieren ────────────────────────────────────────────── def validate_key(key: str) -> Tuple[bool, Optional[date], str]: """Prüft einen Aktivierungsschlüssel tolerant. Returns: ``(valid, expiry_date_or_None, reason_text)`` """ if not key or not isinstance(key, str): return False, None, "Kein Schlüssel eingegeben." canonical = _normalize_key(key) if not canonical: return False, None, ( "Ungültiges Schlüsselformat. Erwartet wird ein " "Schlüssel der Form AzA-YYYYMMDD-...." ) parts = canonical.split("-") if len(parts) != 3 or parts[0] != "AZA": return False, None, "Ungültiges Schlüsselformat." date_part, sig_part = parts[1], parts[2] try: expiry = datetime.strptime(date_part, "%Y%m%d").date() except ValueError: return False, None, "Ungültiges Datum im Schlüssel." if not _try_signature_lengths(date_part, sig_part): return False, None, "Schlüssel-Signatur ungültig." if expiry < date.today(): return False, expiry, ( f"Schlüssel abgelaufen am {expiry.strftime('%d.%m.%Y')}." ) return True, expiry, f"Gültig bis {expiry.strftime('%d.%m.%Y')}." # ── Persistenz ──────────────────────────────────────────────────────── def save_activation_key(key: str) -> None: """Speichert den Schlüssel in kanonischer Form.""" canonical = _normalize_key(key) or (key or "").strip() path = _activation_path() data = {"key": canonical, "saved_at": int(time.time())} os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load_activation_key() -> Optional[str]: path = _activation_path() if not os.path.isfile(path): return None try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) raw = data.get("key") if not raw: return None return _normalize_key(raw) or raw except Exception: return None # ── Demo-Modus-Marker ───────────────────────────────────────────────── DEMO_OPT_OUT_FILENAME = "kg_diktat_demo_opt_in.json" def _demo_opt_in_path() -> str: return os.path.join(get_writable_data_dir(), DEMO_OPT_OUT_FILENAME) def mark_demo_opt_in() -> None: """Merkt sich, dass der Benutzer bewusst im Demomodus weiterfährt.""" try: path = _demo_opt_in_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump({"opted_in": True, "ts": int(time.time())}, f) except Exception: pass def has_demo_opt_in() -> bool: try: path = _demo_opt_in_path() if not os.path.isfile(path): return False with open(path, "r", encoding="utf-8") as f: data = json.load(f) return bool(data.get("opted_in")) except Exception: return False # ── Startup-Check ───────────────────────────────────────────────────── def _get_install_date_file() -> str: return os.path.join(get_writable_data_dir(), "install_date.json") def _get_install_date() -> date: """Liefert das Erstinstallations-Datum; legt es beim ersten Aufruf an.""" path = _get_install_date_file() if os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return datetime.strptime(data["install_date"], "%Y-%m-%d").date() except Exception: pass install_d = date.today() try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump({"install_date": install_d.strftime("%Y-%m-%d")}, f) except Exception: pass return install_d def check_app_access() -> Tuple[bool, str]: """Prüft ob die App gestartet werden darf. Logik: 1. Gespeicherter Aktivierungsschlüssel vorhanden und gültig? -> OK 2. Testphase (APP_TRIAL_DAYS ab Erstinstallation) noch nicht abgelaufen? -> OK 3. Hartes Ablaufdatum (APP_HARD_EXPIRY) als Sicherheitsnetz -> Gesperrt 4. Sonst -> Gesperrt Returns: ``(allowed, user_message)`` """ stored_key = load_activation_key() if stored_key: valid, expiry, _ = validate_key(stored_key) if valid and expiry is not None: days_left = (expiry - date.today()).days return True, ( f"Aktiviert bis {expiry.strftime('%d.%m.%Y')} " f"({days_left} Tage verbleibend)." ) install_d = _get_install_date() trial_end = install_d + timedelta(days=APP_TRIAL_DAYS) today = date.today() try: hard_expiry = datetime.strptime(APP_HARD_EXPIRY, "%Y-%m-%d").date() except ValueError: hard_expiry = None if hard_expiry and today > hard_expiry: if stored_key: _, _, reason = validate_key(stored_key) return False, ( f"Testphase und Aktivierung abgelaufen.\n{reason}\n" "Bitte neuen Aktivierungsschlüssel eingeben." ) return False, ( "Testphase abgelaufen.\n" "Bitte Aktivierungsschlüssel eingeben, um fortzufahren." ) if today <= trial_end: days_left = (trial_end - today).days return True, ( f"Testphase: noch {days_left} Tag(e) verbleibend " f"(bis {trial_end.strftime('%d.%m.%Y')})." ) if stored_key: _, _, reason = validate_key(stored_key) return False, ( f"Testphase abgelaufen.\n{reason}\n" "Bitte neuen Aktivierungsschlüssel eingeben." ) return False, ( f"Die {APP_TRIAL_DAYS}-tägige Testphase ist abgelaufen.\n" "Bitte Aktivierungsschlüssel eingeben, um fortzufahren." ) # ── CLI-Hilfsmittel zum Generieren (für den Entwickler) ────────────── if __name__ == "__main__": import sys as _sys if len(_sys.argv) < 2: print("Verwendung: python aza_activation.py ") print("Beispiel: python aza_activation.py 2026-06-30") _sys.exit(1) exp = _sys.argv[1] key = generate_key(exp) print("\nAktivierungsschlüssel generiert:") print(f" Ablaufdatum: {exp}") print(f" Schlüssel: {key}") ok, dt, msg = validate_key(key) print(f" Validierung: {'OK' if ok else 'FEHLER'} – {msg}")