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>
409 lines
15 KiB
Python
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)
|