This commit is contained in:
2026-05-04 21:34:19 +02:00
parent d4822fc8dc
commit 03d188d119
5169 changed files with 832053 additions and 248 deletions

View File

@@ -1,23 +1,42 @@
# -*- coding: utf-8 -*-
"""
AZA Aktivierungsschlüssel-System.
AzA Aktivierungsschlüssel-System.
Ermöglicht dem Entwickler, Freigabeschlüssel für beliebige Geräte zu
erzeugen. Die App prüft beim Start:
1. Hartes Ablaufdatum (APP_HARD_EXPIRY)
2. Gültigen Aktivierungsschlüssel (optional, verlängert über APP_HARD_EXPIRY hinaus)
Erzeugt, prüft und persistiert Aktivierungsschlüssel.
Schlüssel-Format: AZA-YYYYMMDD-<hmac_hex[:12]>
- YYYY-MM-DD = Ablaufdatum des Schlüssels
- HMAC-SHA256(expiry_str, secret)[:12] = Signatur
Schlüssel-Format
----------------
Kanonisch:
AZA-YYYYMMDD-<hmac_hex[:12]>
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
from datetime import datetime, date
import unicodedata
from datetime import datetime, date, timedelta
from typing import Optional, Tuple
from aza_config import (
@@ -29,75 +48,166 @@ from aza_config import (
)
# ── Pfade ─────────────────────────────────────────────────────────────
def _activation_path() -> str:
return os.path.join(get_writable_data_dir(), ACTIVATION_CONFIG_FILENAME)
def _sign(expiry_str: str) -> str:
"""12 Zeichen HMAC-Hex-Signatur für ein Ablaufdatum."""
# ── 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_str.encode("utf-8"),
expiry_compact.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return sig[:12]
if length <= 0:
return sig
return sig[:length]
# ── Schlüssel generieren (für den Entwickler) ──────────────────────
# ── Schlüssel generieren ──────────────────────────────────────────────
def generate_key(expiry_date: str) -> str:
"""Erzeugt einen Aktivierungsschlüssel.
Args:
expiry_date: Ablaufdatum als 'YYYY-MM-DD'.
Returns:
Schlüssel im Format 'AZA-YYYYMMDD-<sig>'.
"""
"""Erzeugt einen kanonischen Aktivierungsschlüssel ``AZA-YYYYMMDD-<sig12>``."""
dt = datetime.strptime(expiry_date, "%Y-%m-%d")
tag = dt.strftime("%Y%m%d")
sig = _sign(tag)
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<y>\d{4})\s*[-./ ]?\s*(?P<m>\d{2})\s*[-./ ]?\s*(?P<d>\d{2})"
)
def _normalize_key(raw: str) -> Optional[str]:
"""Bringt einen frei eingegebenen Schlüssel in das kanonische Format
``AZA-YYYYMMDD-<sigHEX>`` (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}"
# ── Schlüssel validieren ───────────────────────────────────────────
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.
"""Prüft einen Aktivierungsschlüssel tolerant.
Returns:
(valid, expiry_date_or_None, reason_text)
``(valid, expiry_date_or_None, reason_text)``
"""
if not key or not isinstance(key, str):
return False, None, "Kein Schlüssel eingegeben."
key = key.strip().upper()
parts = key.split("-")
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].lower()
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."
expected_sig = _sign(date_part)
if not hmac.compare_digest(sig_part, expected_sig):
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 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 ─────────────────────────────────────────────────────
# ── 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": key.strip(), "saved_at": int(time.time())}
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)
@@ -109,12 +219,47 @@ def load_activation_key() -> Optional[str]:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("key")
raw = data.get("key")
if not raw:
return None
return _normalize_key(raw) or raw
except Exception:
return None
# ── Startup-Check ──────────────────────────────────────────────────
# ── 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")
@@ -150,17 +295,19 @@ def check_app_access() -> Tuple[bool, str]:
4. Sonst -> Gesperrt
Returns:
(allowed, user_message)
``(allowed, user_message)``
"""
stored_key = load_activation_key()
if stored_key:
valid, expiry, reason = validate_key(stored_key)
if valid:
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')} ({days_left} Tage verbleibend)."
return True, (
f"Aktiviert bis {expiry.strftime('%d.%m.%Y')} "
f"({days_left} Tage verbleibend)."
)
install_d = _get_install_date()
from datetime import timedelta
trial_end = install_d + timedelta(days=APP_TRIAL_DAYS)
today = date.today()
@@ -172,21 +319,36 @@ def check_app_access() -> Tuple[bool, str]:
if hard_expiry and today > hard_expiry:
if stored_key:
_, _, reason = validate_key(stored_key)
return False, f"Testphase und Aktivierung abgelaufen.\n{reason}\nBitte neuen Aktivierungsschlüssel eingeben."
return False, "Testphase abgelaufen.\nBitte Aktivierungsschlüssel eingeben, um fortzufahren."
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 (bis {trial_end.strftime('%d.%m.%Y')})."
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}\nBitte neuen Aktivierungsschlüssel eingeben."
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.\nBitte Aktivierungsschlüssel eingeben, um fortzufahren."
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) ────────────
# ── CLI-Hilfsmittel zum Generieren (für den Entwickler) ──────────────
if __name__ == "__main__":
import sys as _sys
@@ -198,7 +360,7 @@ if __name__ == "__main__":
exp = _sys.argv[1]
key = generate_key(exp)
print(f"\nAktivierungsschlüssel generiert:")
print("\nAktivierungsschlüssel generiert:")
print(f" Ablaufdatum: {exp}")
print(f" Schlüssel: {key}")