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>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user