Files
aza/AzA march 2026/aza_activation.py
2026-05-04 21:34:19 +02:00

369 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
AzA Aktivierungsschlüssel-System.
Erzeugt, prüft und persistiert Aktivierungsschlüssel.
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
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-<sig12>``."""
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<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}"
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 <YYYY-MM-DD>")
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}")