TESM 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen
Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich
jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:00:06 +02:00
co-authored by Claude Opus 5
parent a193b9d6e9
commit 7354ff352b
227 changed files with 37605 additions and 21187 deletions
+408
View File
@@ -0,0 +1,408 @@
"""Phase-0-Tests: das Lizenzprotokoll isoliert, ohne Flask, ohne Datenbank.
Deckt bewusst auch die Angriffsfaelle ab, die im Vorgaengerprojekt nicht
abgesichert waren: Signatur-Wiederverwendung ueber Domaenengrenzen, Replay
alter Anfragen/Antworten, Downgrade eines nachgelieferten Lizenz-Updates und
Ausstellung durch einen nicht gepinnten Aussteller.
"""
from __future__ import annotations
import copy
from datetime import timedelta
import pytest
import tesm_licensing as lic
VENDOR = {
"name": "WiS GmbH",
"phone": "+49 000 000",
"email": "lizenzen@example.invalid",
"address": "Musterweg 1, 12345 Musterstadt",
"logo_base64": "",
}
CUSTOMER = {"name": "Beispiel AG", "contact_email": "it@example.invalid", "customer_ref": "K-1"}
@pytest.fixture()
def master_keys() -> tuple[str, str]:
return lic.generate_keypair()
def issue(master_keys, **overrides):
priv, pub = master_keys
params = dict(
customer=CUSTOMER,
license_type="standard",
modules=[],
valid_days=365,
master_private_key_b64=priv,
master_public_key_b64=pub,
master_endpoint="https://lizenz.example.invalid/",
vendor=VENDOR,
)
params.update(overrides)
return lic.issue_license(**params)
# --------------------------------------------------------------------------- #
# Lizenzdatei
# --------------------------------------------------------------------------- #
def test_issued_license_verifies_and_splits(master_keys):
_, pub = master_keys
bundle, client_pub = issue(master_keys, modules=["dhcp", "maintenance"])
document, client_priv = lic.split_bundle(bundle)
lic.verify_license(document, trusted_master_keys=[pub])
assert document["license_pubkey"] == client_pub
assert lic.public_from_private(client_priv) == client_pub
# Der private Schluessel darf nicht im Lizenzdokument stehen.
assert "client_key" not in document
assert "license_privkey" not in document
status = lic.license_status(document, issuer_pinned=True)
assert status.modules == {"dhcp", "maintenance"}
assert status.modules_active is True
assert status.expired is False
assert status.days_left == 365
assert status.warnings == ()
def test_tampered_field_breaks_signature(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document["modules"] = ["dhcp", "fileshare", "maintenance"]
with pytest.raises(lic.SignatureInvalid):
lic.verify_license(document)
def test_swapped_master_key_is_detected(master_keys):
"""Ein Angreifer, der Payload *und* master_pubkey ersetzt, faellt am Pinning auf."""
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
evil_priv, evil_pub = lic.generate_keypair()
forged = lic.resign_license(
document, master_private_key_b64=evil_priv, changes={"modules": list(lic.ALL_MODULES)}
)
forged["master_pubkey"] = evil_pub
forged["master_key_id"] = lic.key_id(evil_pub)
forged["signature"] = lic.sign(
{k: v for k, v in forged.items() if k != "signature"},
evil_priv,
lic.SIG_CONTEXT_LICENSE,
)
# In sich konsistent -- genau das war die Schwaeche von V1.
lic.verify_license(forged)
# Mit gepinntem Aussteller fliegt es auf.
_, real_pub = master_keys
with pytest.raises(lic.UntrustedIssuer):
lic.verify_license(forged, trusted_master_keys=[real_pub])
def test_key_id_mismatch_rejected(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document["master_key_id"] = "0" * 16
with pytest.raises(lic.InvalidLicenseFile):
lic.verify_license(document)
def test_license_signature_not_reusable_as_response(master_keys):
"""Domaenentrennung: Lizenz-Signatur darf nicht als Antwort-Signatur gelten."""
_, pub = master_keys
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
payload = {k: v for k, v in document.items() if k != "signature"}
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_LICENSE) is True
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_RESPONSE) is False
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_REQUEST) is False
def test_lifetime_license(master_keys):
bundle, _ = issue(master_keys, license_type="enterprise", lifetime=True, valid_days=0)
document, _ = lic.split_bundle(bundle)
lic.verify_license(document)
status = lic.license_status(document, issuer_pinned=True)
assert status.lifetime is True
assert status.expires_at is not None
assert status.days_left is None
assert status.expired is False
assert status.modules_active is True
def test_trial_lifetime_forbidden(master_keys):
with pytest.raises(ValueError):
issue(master_keys, license_type="trial", lifetime=True, valid_days=0)
def test_license_server_role_rejects_modules(master_keys):
with pytest.raises(ValueError):
issue(master_keys, role="license_server", modules=["dhcp"])
def test_grace_period_and_expiry(master_keys):
bundle, _ = issue(master_keys, valid_days=10, modules=["dhcp"])
document, _ = lic.split_bundle(bundle)
issued = lic.parse_iso(document["issued_at"])
warn = lic.license_status(document, now=issued + timedelta(days=9), issuer_pinned=True)
assert warn.expiring_soon is True and warn.expired is False and warn.modules_active is True
grace = lic.license_status(document, now=issued + timedelta(days=25), issuer_pinned=True)
assert grace.expired is True
assert grace.grace_active is True
assert grace.modules_active is True # Kulanz laeuft noch
assert grace.days_since_expiry == 15
dead = lic.license_status(document, now=issued + timedelta(days=45), issuer_pinned=True)
assert dead.grace_active is False
assert dead.modules_active is False
def test_not_before_blocks_future_license(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
issued = lic.parse_iso(document["issued_at"])
status = lic.license_status(document, now=issued - timedelta(hours=1), issuer_pinned=True)
assert status.not_yet_valid is True
assert status.modules_active is False
def test_status_without_pinning_warns(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
status = lic.license_status(document)
assert any("gepinnt" in w for w in status.warnings)
def test_role_defaults_to_customer_when_missing(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document.pop("role")
assert lic.license_status(document).role == "customer"
def test_v1_bundle_compatibility(master_keys):
"""Eine alte Datei mit eingebettetem license_privkey laesst sich noch aufteilen."""
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
legacy = {**document, "license_privkey": client_priv}
doc2, priv2 = lic.split_bundle(legacy)
assert priv2 == client_priv
assert "license_privkey" not in doc2
# --------------------------------------------------------------------------- #
# Aktivierungsprotokoll
# --------------------------------------------------------------------------- #
def test_activation_round_trip(master_keys):
master_priv, master_pub = master_keys
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv, hostname="tesm-kunde")
lic.verify_client_request(request, license_pub, expected_action="activate")
response = lic.build_master_response(
"activate",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
)
lic.verify_master_response(
response,
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
current_seq=document["seq"],
)
def test_offline_code_round_trip_is_identical(master_keys):
master_priv, master_pub = master_keys
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
code = lic.encode_code(request)
# Ein per Mail transportierter Code darf Umbrueche/Whitespace enthalten.
wrapped = "\n".join(code[i : i + 48] for i in range(0, len(code), 48))
assert lic.decode_code(wrapped) == request
lic.verify_client_request(lic.decode_code(wrapped), license_pub)
response = lic.build_master_response(
"activate",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
)
lic.verify_master_response(
lic.decode_code(lic.encode_code(response)),
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
)
def test_request_signature_requires_correct_license_key(master_keys):
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
other_priv, other_pub = lic.generate_keypair()
request = lic.build_client_request("heartbeat", document, client_priv)
with pytest.raises(lic.SignatureInvalid):
lic.verify_client_request(request, other_pub)
assert other_priv # nur zur Vollstaendigkeit
def test_request_replay_is_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
used: set[str] = set()
def seen(nonce: str) -> bool:
if nonce in used:
return True
used.add(nonce)
return False
lic.verify_client_request(request, license_pub, seen_nonce=seen)
with pytest.raises(lic.ReplayDetected):
lic.verify_client_request(request, license_pub, seen_nonce=seen)
def test_stale_request_is_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
old = lic.utcnow() - timedelta(seconds=lic.REQUEST_MAX_AGE_SECONDS + 60)
request = lic.build_client_request("heartbeat", document, client_priv, now=old)
with pytest.raises(lic.ReplayDetected):
lic.verify_client_request(request, license_pub)
def test_unsigned_request_does_not_burn_nonce(master_keys):
"""Nonce-Verbrauch erst nach gueltiger Signatur -- sonst waere die Nonce-Tabelle
ein kostenloser DoS-Vektor."""
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
request["signature"] = lic.sign(
{"boese": True}, lic.generate_keypair()[0], lic.SIG_CONTEXT_REQUEST
)
calls: list[str] = []
with pytest.raises(lic.SignatureInvalid):
lic.verify_client_request(request, license_pub, seen_nonce=lambda n: calls.append(n) or False)
assert calls == []
def test_response_bound_to_request_nonce(master_keys):
master_priv, master_pub = master_keys
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
first = lic.build_client_request("activate", document, client_priv)
response = lic.build_master_response(
"activate",
license_id=first["license_id"],
fingerprint=first["fingerprint"],
request_nonce=first["nonce"],
master_private_key=master_priv,
)
second = lic.build_client_request("activate", document, client_priv)
with pytest.raises(lic.ReplayDetected):
lic.verify_master_response(
response,
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=second["fingerprint"],
expected_action="activate",
expected_nonce=second["nonce"],
)
def test_license_update_downgrade_rejected(master_keys):
master_priv, master_pub = master_keys
bundle, _ = issue(master_keys, modules=["dhcp"])
document, client_priv = lic.split_bundle(bundle)
updated = lic.resign_license(
document, master_private_key_b64=master_priv, changes={"modules": ["dhcp", "fileshare"]}
)
assert updated["seq"] == document["seq"] + 1
lic.verify_license(updated, trusted_master_keys=[master_pub])
request = lic.build_client_request("heartbeat", updated, client_priv)
stale = lic.build_master_response(
"heartbeat",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
license_update=document, # alte Revision
)
with pytest.raises(lic.ReplayDetected):
lic.verify_master_response(
stale,
master_pub,
expected_license_id=updated["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="heartbeat",
expected_nonce=request["nonce"],
current_seq=updated["seq"],
)
def test_report_size_limit(master_keys):
bundle, license_pub = issue(master_keys, role="license_server")
document, client_priv = lic.split_bundle(bundle)
report = {"customers": [{"name": f"K{i}"} for i in range(lic.MAX_REPORT_ROWS + 1)]}
request = lic.build_client_request("heartbeat", document, client_priv, report=report)
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(request, license_pub)
def test_malformed_fingerprint_and_nonce_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
base = lic.build_client_request("activate", document, client_priv)
bad_fp = copy.deepcopy(base)
bad_fp["fingerprint"] = "nicht-hex"
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(bad_fp, license_pub)
bad_nonce = copy.deepcopy(base)
bad_nonce["nonce"] = "kurz"
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(bad_nonce, license_pub)
def test_wrong_action_for_endpoint(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("heartbeat", document, client_priv)
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(request, license_pub, expected_action="activate")
def test_fingerprint_is_stable_and_hex():
first = lic.system_fingerprint()
assert first == lic.system_fingerprint()
assert len(first) == 64
int(first, 16)