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