Files
framebridge/source/tests/test_tls.py
T

142 lines
7.2 KiB
Python

"""Real TLS and certificate-bootstrap checks in isolated application profiles."""
import hashlib
import ipaddress
import os
from pathlib import Path
import socket
import ssl
import stat
import tempfile
import threading
import unittest
from unittest import mock
from framebridge_expanded import broker, node, tls
from framebridge_expanded.config import default_config, load_config, save_config
ROOT = Path(__file__).resolve().parents[1]
class CertificateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="tls-test-", dir=ROOT / "qa")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
def profile(self, name, role="central"):
state = self.root / name
save_config(state, default_config(role, name))
return state
def test_certificate_sans_fingerprint_private_modes_and_config(self):
state = self.profile("cert-central")
result = tls.create_certificates(state, "central.example.test", "192.0.2.10")
cfg = load_config(state)
x509, hashes, serialization, *_ = tls._crypto()
ca = x509.load_pem_x509_certificate(Path(result["ca_public_file"]).read_bytes())
cert = x509.load_pem_x509_certificate(Path(result["server_certificate_file"]).read_bytes())
self.assertEqual(result["ca_fingerprint_sha256"], ca.fingerprint(hashes.SHA256()).hex())
self.assertEqual(result["server_fingerprint_sha256"], cert.fingerprint(hashes.SHA256()).hex())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
self.assertEqual(set(san.get_values_for_type(x509.DNSName)), {"central.example.test", "localhost"})
self.assertEqual(set(san.get_values_for_type(x509.IPAddress)), {
ipaddress.ip_address("192.0.2.10"), ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")})
self.assertTrue(ca.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
self.assertFalse(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
self.assertEqual(stat.S_IMODE((state / "tls").stat().st_mode), 0o700)
for path in (state / "tls").iterdir():
expected = 0o644 if path.name == "ca-public.pem" else 0o600
self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, path.name)
self.assertEqual(cfg["tls_ca"], result["ca_public_file"])
self.assertEqual(cfg["broker_url"], "https://192.0.2.10:20878")
self.assertEqual(cfg["broker_control_url"], "https://127.0.0.1:20878")
self.assertEqual(cfg["broker_listen_host"], "127.0.0.1")
self.assertFalse(result["private_key_values_exported"])
self.assertFalse(result["openai_credentials_created_or_saved"])
self.assertFalse(any("private.key" in key for key in result))
def test_existing_material_is_never_overwritten(self):
state = self.profile("no-overwrite")
tls.create_certificates(state, "central.test", "127.0.0.1")
before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (state / "tls").iterdir()}
with self.assertRaises(FileExistsError):
tls.create_certificates(state, "replacement.test", "127.0.0.1")
after = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (state / "tls").iterdir()}
self.assertEqual(before, after)
def test_invalid_or_active_profile_never_creates_material(self):
standalone = self.profile("standalone", "standalone")
with self.assertRaises(ValueError):
tls.create_certificates(standalone, "central.test", "127.0.0.1")
state = self.profile("active")
with mock.patch("framebridge_expanded.runtime.status", return_value={"running": True}):
with self.assertRaises(RuntimeError):
tls.create_certificates(state, "central.test", "127.0.0.1")
with self.assertRaises(ValueError):
tls.create_certificates(state, "bad host", "127.0.0.1")
with self.assertRaises(ValueError):
tls.create_certificates(state, "central.test", "0.0.0.0")
self.assertFalse((state / "tls").exists())
def test_real_https_node_enrollment_and_wrong_ca_rejection(self):
# Override only with a local, assigned address to exercise an actual LAN route.
address = os.environ.get("FRAMEBRIDGE_TLS_TEST_ADDRESS", "127.0.0.1")
ipaddress.ip_address(address)
with socket.socket() as probe:
probe.bind(("0.0.0.0", 0))
port = probe.getsockname()[1]
central = self.profile("https-central")
cfg = load_config(central)
cfg["ports"]["broker"] = port
if port in (cfg["ports"]["mcp"], cfg["ports"]["hub"]):
self.skipTest("Ephemeral broker port overlaps a default service port.")
save_config(central, cfg)
public = tls.create_certificates(central, "central.test", address, listen_host="0.0.0.0")
server = broker.make_server("0.0.0.0", port, central)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
joining = self.profile("https-node", "node")
nc = load_config(joining)
nc.update(broker_url=public["broker_url"], tls_ca=public["ca_public_file"])
save_config(joining, nc)
enrolled = node.enroll(public["broker_url"], "https-node", joining)
broker.configure(state_dir=central)
pending = broker.list_pending()
entry = next(p for p in pending if p["node_id"] == enrolled["node_id"])
self.assertEqual(entry["fingerprint"], enrolled["fingerprint"])
approved = broker.approve(entry["request_id"], enrolled["node_id"], central)
polled = node.poll_enrollment(joining)
self.assertEqual(polled["state"], "paired")
self.assertFalse(polled.get("broker_confirm_required", False))
self.assertTrue(any(p["node_id"] == enrolled["node_id"] for p in broker.list_confirmed_nodes()))
wrong = self.profile("wrong-authority")
wrong_public = tls.create_certificates(wrong, "other.test", address)
rejected = self.profile("wrong-ca-node", "node")
rc = load_config(rejected)
rc.update(broker_url=public["broker_url"], tls_ca=wrong_public["ca_public_file"])
save_config(rejected, rc)
before = broker.list_pending()
with self.assertRaises(Exception) as failure:
node.enroll(public["broker_url"], "wrong-ca-node", rejected)
# Check the verification error, rather than accepting any transport failure.
chain = []
error = failure.exception
for _ in range(6):
if error is None:
break
chain.append(str(error))
error = error.__cause__ or error.__context__
self.assertIn("CERTIFICATE_VERIFY_FAILED", " ".join(chain))
self.assertEqual(before, broker.list_pending())
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
self.assertFalse(thread.is_alive())
if __name__ == "__main__":
unittest.main()