Files
tesm-license/tests/test_licensing.py
T
alientimandClaude Opus 5 f7805a2180 TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

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

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* 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.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:01:23 +02:00

409 lines
15 KiB
Python

"""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)