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