From 0b3bdc884198a138b8efab4c2cde1f7e786d7f6f Mon Sep 17 00:00:00 2001 From: Rehan Khan Date: Sat, 22 Aug 2026 16:48:10 +0500 Subject: [PATCH 1/2] feat: harden certificate lifecycle --- .gitignore | 5 ++ collector/README.md | 16 ++++- collector/certs/check_expiry.sh | 26 ++++++++ collector/certs/generate_ca.sh | 32 ++++++++-- collector/certs/openssl.cnf | 36 +++++++++++ collector/certs/revoke_node.sh | 22 +++++++ collector/config/collector.yaml | 1 + collector/tests/conftest.py | 47 +++++++++++++- collector/tests/test_config.py | 22 +++++++ collector/tests/test_server.py | 85 ++++++++++++++++++++++++- collector/vestrix_collector/__main__.py | 35 ++++++++++ collector/vestrix_collector/config.py | 4 +- collector/vestrix_collector/server.py | 49 ++++++++++++-- docs/DPIA.md | 10 ++- docs/threat-model.md | 12 ++-- 15 files changed, 373 insertions(+), 29 deletions(-) create mode 100644 collector/certs/check_expiry.sh create mode 100644 collector/certs/openssl.cnf create mode 100644 collector/certs/revoke_node.sh create mode 100644 collector/tests/test_config.py diff --git a/.gitignore b/.gitignore index 7a3275b..0a6a0c6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ node_modules/ !**/test-fixtures/*.pem ca.crt ca.srl +/collector/certs/ca.crl +/collector/certs/index.txt* +/collector/certs/serial* +/collector/certs/crlnumber* +/collector/certs/newcerts/ # Data dataset/raw/ diff --git a/collector/README.md b/collector/README.md index 48258f8..37efe0f 100644 --- a/collector/README.md +++ b/collector/README.md @@ -2,6 +2,16 @@ mTLS ingest service that receives CSI data from sensor nodes. -Status: collector-side mTLS ingest, certificate-CN allow-listing, payload validation, -and in-memory anti-replay checks are implemented and tested. ESP32 client -provisioning and automated certificate enrollment/rotation are not implemented. +Status: collector-side mTLS ingest, certificate-CN allow-listing, CRL-based +revocation, SIGHUP reload of the allow-list and CRL, payload validation, and +in-memory anti-replay checks are implemented and tested. `certs/check_expiry.sh` +flags node certificates nearing expiry (30 days by default). + +Revoke a certificate issued by the current CA tooling with +`bash certs/revoke_node.sh `, distribute the regenerated `ca.crl` to the +configured `tls.crl_path`, and send SIGHUP to each collector process. Certificates +issued by the previous stateless script must be reissued before they can be tracked +and revoked through the CA database. + +ESP32 client provisioning, automated certificate enrollment/rotation, OCSP, HSM +support, and passphrase-protected private keys are not implemented. diff --git a/collector/certs/check_expiry.sh b/collector/certs/check_expiry.sh new file mode 100644 index 0000000..55740f3 --- /dev/null +++ b/collector/certs/check_expiry.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$CERT_DIR" + +DAYS="${1:-30}" +if [[ ! "$DAYS" =~ ^[0-9]+$ ]]; then + echo "Usage: $0 [warning-days]" >&2 + exit 2 +fi + +threshold_seconds=$((DAYS * 86400)) +status=0 +shopt -s nullglob +for cert in *.crt; do + case "$cert" in + ca.crt|server.crt) continue ;; + esac + if ! openssl x509 -checkend "$threshold_seconds" -noout -in "$cert" >/dev/null; then + expires="$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2-)" + echo "[!] ${cert} expires within ${DAYS} days (${expires})." + status=1 + fi +done +exit "$status" diff --git a/collector/certs/generate_ca.sh b/collector/certs/generate_ca.sh index 499124c..86bc1fb 100644 --- a/collector/certs/generate_ca.sh +++ b/collector/certs/generate_ca.sh @@ -1,32 +1,50 @@ #!/usr/bin/env bash -# Vestrix — project CA + per-node cert generator. -# NOT production-hardened as-is: no revocation, no HSM, no passphrase on keys. -# Good enough for v0.1 bring-up; revisit before any real deployment. +# Vestrix — project CA + tracked per-node certificate generator. +# NOT production-hardened as-is: no HSM and no passphrase on private keys. set -euo pipefail -CERT_DIR="$(dirname "$0")" +CERT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" cd "$CERT_DIR" +mkdir -p newcerts +touch index.txt +[ -f serial ] || printf '1000\n' > serial +[ -f crlnumber ] || printf '1000\n' > crlnumber + if [ ! -f ca.key ]; then echo "[*] Generating project root CA..." openssl genrsa -out ca.key 4096 openssl req -x509 -new -key ca.key -days 3650 -out ca.crt \ + -config openssl.cnf -extensions v3_ca \ -subj "/CN=Vestrix Root CA" else echo "[*] Root CA already exists, skipping." fi +openssl ca -batch -config openssl.cnf -gencrl -out ca.crl + NODE_ID="${1:-}" if [ -z "$NODE_ID" ]; then echo "Usage: $0 (e.g. $0 node-07)" exit 0 fi +if [[ ! "$NODE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then + echo "Node ID must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" >&2 + exit 2 +fi +if [ -e "${NODE_ID}.key" ] || [ -e "${NODE_ID}.crt" ]; then + echo "Refusing to overwrite existing certificate or key for ${NODE_ID}." >&2 + exit 2 +fi echo "[*] Issuing cert for ${NODE_ID}..." openssl genrsa -out "${NODE_ID}.key" 2048 openssl req -new -key "${NODE_ID}.key" -out "${NODE_ID}.csr" \ -subj "/CN=${NODE_ID}" -openssl x509 -req -in "${NODE_ID}.csr" -CA ca.crt -CAkey ca.key \ - -CAcreateserial -out "${NODE_ID}.crt" -days 365 +openssl ca -batch -config openssl.cnf -extensions client_cert -days 365 \ + -in "${NODE_ID}.csr" -out "${NODE_ID}.crt" -notext +rm "${NODE_ID}.csr" +openssl ca -batch -config openssl.cnf -gencrl -out ca.crl -echo "[*] Done. Files: ${NODE_ID}.key ${NODE_ID}.crt (keep .key off the node's filesystem in plaintext where possible)." +echo "[*] Done. Files: ${NODE_ID}.key ${NODE_ID}.crt" +echo "[*] Run bash check_expiry.sh to flag certificates nearing expiry." diff --git a/collector/certs/openssl.cnf b/collector/certs/openssl.cnf new file mode 100644 index 0000000..90d8840 --- /dev/null +++ b/collector/certs/openssl.cnf @@ -0,0 +1,36 @@ +[ca] +default_ca = CA_default + +[CA_default] +dir = . +database = $dir/index.txt +new_certs_dir = $dir/newcerts +certificate = $dir/ca.crt +private_key = $dir/ca.key +serial = $dir/serial +crlnumber = $dir/crlnumber +default_days = 365 +default_crl_days = 30 +default_md = sha256 +policy = node_policy +unique_subject = no +crl_extensions = crl_ext + +[node_policy] +commonName = supplied + +[v3_ca] +basicConstraints = critical, CA:true +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always + +[client_cert] +basicConstraints = critical, CA:false +keyUsage = critical, digitalSignature +extendedKeyUsage = clientAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer + +[crl_ext] +authorityKeyIdentifier = keyid:always diff --git a/collector/certs/revoke_node.sh b/collector/certs/revoke_node.sh new file mode 100644 index 0000000..8c8ba10 --- /dev/null +++ b/collector/certs/revoke_node.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$CERT_DIR" + +NODE_ID="${1:-}" +if [[ ! "$NODE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +if [ ! -f "${NODE_ID}.crt" ]; then + echo "Certificate not found: ${NODE_ID}.crt" >&2 + exit 2 +fi + +openssl ca -batch -config openssl.cnf -revoke "${NODE_ID}.crt" \ + -crl_reason keyCompromise +openssl ca -batch -config openssl.cnf -gencrl -out ca.crl + +echo "[*] Revoked ${NODE_ID}; regenerated ca.crl." +echo "[*] Send SIGHUP to the collector so new connections use the updated CRL." diff --git a/collector/config/collector.yaml b/collector/config/collector.yaml index 6005a57..5a35dee 100644 --- a/collector/config/collector.yaml +++ b/collector/config/collector.yaml @@ -1,5 +1,6 @@ tls: ca_cert: ../certs/ca.crt + crl_path: ../certs/ca.crl server_cert: ../certs/server.crt server_key: ../certs/server.key diff --git a/collector/tests/conftest.py b/collector/tests/conftest.py index 568c74d..60fa68a 100644 --- a/collector/tests/conftest.py +++ b/collector/tests/conftest.py @@ -15,11 +15,15 @@ class Identity: cert: Path key: Path + certificate: x509.Certificate @dataclass(frozen=True, slots=True) class CertificateBundle: ca_cert: Path + crl_path: Path + ca_certificate: x509.Certificate + ca_key: rsa.RSAPrivateKey server: Identity enrolled_node: Identity unenrolled_node: Identity @@ -79,7 +83,7 @@ def _issue_identity( key_path = directory / f"{common_name}.key" _write_cert(cert_path, cert) _write_key(key_path, key) - return Identity(cert=cert_path, key=key_path) + return Identity(cert=cert_path, key=key_path, certificate=cert) def _self_signed_identity(directory: Path, common_name: str) -> Identity: @@ -112,7 +116,39 @@ def _self_signed_identity(directory: Path, common_name: str) -> Identity: key_path = directory / f"{common_name}.key" _write_cert(cert_path, cert) _write_key(key_path, key) - return Identity(cert=cert_path, key=key_path) + return Identity(cert=cert_path, key=key_path, certificate=cert) + + +def write_crl( + certificates: CertificateBundle, + revoked_identities: tuple[Identity, ...] = (), +) -> None: + """Write a test CRL containing the supplied identities.""" + now = datetime.now(UTC) + builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(certificates.ca_certificate.subject) + .last_update(now - timedelta(minutes=1)) + .next_update(now + timedelta(days=1)) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + certificates.ca_key.public_key() + ), + critical=False, + ) + ) + for identity in revoked_identities: + builder = builder.add_revoked_certificate( + x509.RevokedCertificateBuilder() + .serial_number(identity.certificate.serial_number) + .revocation_date(now) + .build() + ) + certificates.crl_path.write_bytes( + builder.sign(certificates.ca_key, hashes.SHA256()).public_bytes( + serialization.Encoding.PEM + ) + ) @pytest.fixture @@ -155,8 +191,11 @@ def certificates(tmp_path: Path) -> CertificateBundle: ca_path = tmp_path / "ca.crt" _write_cert(ca_path, ca_cert) - return CertificateBundle( + bundle = CertificateBundle( ca_cert=ca_path, + crl_path=tmp_path / "ca.crl", + ca_certificate=ca_cert, + ca_key=ca_key, server=_issue_identity( tmp_path, common_name="collector-test", @@ -180,3 +219,5 @@ def certificates(tmp_path: Path) -> CertificateBundle: ), self_signed_node=_self_signed_identity(tmp_path, "rogue-node"), ) + write_crl(bundle) + return bundle diff --git a/collector/tests/test_config.py b/collector/tests/test_config.py new file mode 100644 index 0000000..823e052 --- /dev/null +++ b/collector/tests/test_config.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from vestrix_collector.config import load_config + + +def test_crl_path_is_resolved_from_collector_yaml(tmp_path: Path) -> None: + config_path = tmp_path / "collector.yaml" + config_path.write_text( + """tls: + ca_cert: ca.crt + crl_path: ca.crl + server_cert: server.crt + server_key: server.key +allowlist_path: nodes.yaml +server: + host: 127.0.0.1 + port: 8443 +""", + encoding="utf-8", + ) + + assert load_config(config_path).tls.crl_path == tmp_path / "ca.crl" diff --git a/collector/tests/test_server.py b/collector/tests/test_server.py index b63ee2a..b4844ff 100644 --- a/collector/tests/test_server.py +++ b/collector/tests/test_server.py @@ -10,7 +10,7 @@ from typing import Any import pytest -from conftest import CertificateBundle, Identity +from conftest import CertificateBundle, Identity, write_crl from vestrix_collector.config import CollectorConfig, ServerConfig, TLSConfig from vestrix_collector.models import CSIEvent @@ -54,6 +54,7 @@ def _config(tmp_path: Path, certificates: CertificateBundle) -> CollectorConfig: return CollectorConfig( tls=TLSConfig( ca_cert=certificates.ca_cert, + crl_path=certificates.crl_path, server_cert=certificates.server.cert, server_key=certificates.server.key, ), @@ -105,6 +106,88 @@ async def scenario(server: CollectorServer) -> dict[str, str]: assert received == [_payload()] +def test_revoked_enrolled_certificate_is_rejected_during_tls( + tmp_path: Path, certificates: CertificateBundle +) -> None: + received: list[CSIEvent] = [] + write_crl(certificates, (certificates.enrolled_node,)) + + async def scenario(server: CollectorServer) -> None: + with pytest.raises(ssl.SSLError): + await asyncio.to_thread( + _send_event, + server.port, + certificates.ca_cert, + certificates.enrolled_node, + _payload(), + ) + + asyncio.run( + _with_server(_config(tmp_path, certificates), received.append, scenario) + ) + assert received == [] + + +def test_crl_reload_rejects_revoked_node_without_restart( + tmp_path: Path, certificates: CertificateBundle +) -> None: + received: list[CSIEvent] = [] + + async def scenario(server: CollectorServer) -> dict[str, str]: + first = await asyncio.to_thread( + _send_event, + server.port, + certificates.ca_cert, + certificates.enrolled_node, + _payload(), + ) + write_crl(certificates, (certificates.enrolled_node,)) + await server.reload_security_state() + with pytest.raises(ssl.SSLError): + await asyncio.to_thread( + _send_event, + server.port, + certificates.ca_cert, + certificates.enrolled_node, + _payload(2), + ) + return first + + response = asyncio.run( + _with_server(_config(tmp_path, certificates), received.append, scenario) + ) + assert response == { + "status": "accepted", + "reason": "authenticated_event_handed_off", + } + assert received == [_payload()] + + +def test_allowlist_reload_rejects_removed_node_without_restart( + tmp_path: Path, certificates: CertificateBundle +) -> None: + received: list[CSIEvent] = [] + config = _config(tmp_path, certificates) + + async def scenario(server: CollectorServer) -> dict[str, str]: + config.allowlist_path.write_text("nodes:\n - node-99\n", encoding="utf-8") + await server.reload_security_state() + return await asyncio.to_thread( + _send_event, + server.port, + certificates.ca_cert, + certificates.enrolled_node, + None, + ) + + response = asyncio.run(_with_server(config, received.append, scenario)) + assert response == { + "status": "rejected", + "reason": "unenrolled_certificate_cn", + } + assert received == [] + + def test_valid_certificate_with_unenrolled_cn_is_rejected( tmp_path: Path, certificates: CertificateBundle ) -> None: diff --git a/collector/vestrix_collector/__main__.py b/collector/vestrix_collector/__main__.py index 1ebf09f..659b304 100644 --- a/collector/vestrix_collector/__main__.py +++ b/collector/vestrix_collector/__main__.py @@ -4,6 +4,8 @@ import argparse import asyncio +import logging +import signal from collections.abc import Sequence from pathlib import Path @@ -26,9 +28,42 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: async def _run(config_path: Path) -> None: server = CollectorServer(load_config(config_path)) await server.start() + loop = asyncio.get_running_loop() + reload_task: asyncio.Task[None] | None = None + + async def reload_security_state() -> None: + try: + await server.reload_security_state() + except Exception as exc: + logging.getLogger("vestrix_collector.decisions").error( + "collector security-state reload failed", + extra={ + "event": "collector_lifecycle", + "decision_fields": { + "state": "security_state_reload_failed", + "error_type": type(exc).__name__, + }, + }, + ) + + def request_reload() -> None: + nonlocal reload_task + if reload_task is None or reload_task.done(): + reload_task = asyncio.create_task(reload_security_state()) + + signal_installed = False + try: + loop.add_signal_handler(signal.SIGHUP, request_reload) + signal_installed = True + except (AttributeError, NotImplementedError): + pass try: await asyncio.Event().wait() finally: + if signal_installed: + loop.remove_signal_handler(signal.SIGHUP) + if reload_task is not None: + await reload_task await server.close() diff --git a/collector/vestrix_collector/config.py b/collector/vestrix_collector/config.py index e5c2e08..341ed80 100644 --- a/collector/vestrix_collector/config.py +++ b/collector/vestrix_collector/config.py @@ -16,6 +16,7 @@ class ConfigError(ValueError): @dataclass(frozen=True, slots=True) class TLSConfig: ca_cert: Path + crl_path: Path server_cert: Path server_key: Path @@ -81,7 +82,7 @@ def load_config(path: str | Path) -> CollectorConfig: root = _mapping(raw, "config") _exact_keys(root, {"tls", "allowlist_path", "server"}, "config") tls = _mapping(root["tls"], "tls") - _exact_keys(tls, {"ca_cert", "server_cert", "server_key"}, "tls") + _exact_keys(tls, {"ca_cert", "crl_path", "server_cert", "server_key"}, "tls") server = _mapping(root["server"], "server") required_server_keys = {"host", "port"} optional_server_keys = { @@ -111,6 +112,7 @@ def load_config(path: str | Path) -> CollectorConfig: return CollectorConfig( tls=TLSConfig( ca_cert=_resolve_path(base_dir, tls["ca_cert"], "tls.ca_cert"), + crl_path=_resolve_path(base_dir, tls["crl_path"], "tls.crl_path"), server_cert=_resolve_path(base_dir, tls["server_cert"], "tls.server_cert"), server_key=_resolve_path(base_dir, tls["server_key"], "tls.server_key"), ), diff --git a/collector/vestrix_collector/server.py b/collector/vestrix_collector/server.py index 4a0f244..33bc32c 100644 --- a/collector/vestrix_collector/server.py +++ b/collector/vestrix_collector/server.py @@ -49,11 +49,13 @@ def load_enrolled_nodes(path: Path) -> frozenset[str]: def build_server_tls_context(config: CollectorConfig) -> ssl.SSLContext: - """Build a server context that requires a project-CA client certificate.""" + """Build a server context requiring a non-revoked project-CA certificate.""" context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.minimum_version = ssl.TLSVersion.TLSv1_2 context.verify_mode = ssl.CERT_REQUIRED context.load_verify_locations(cafile=str(config.tls.ca_cert)) + context.load_verify_locations(cafile=str(config.tls.crl_path)) + context.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF context.load_cert_chain( certfile=str(config.tls.server_cert), keyfile=str(config.tls.server_key) ) @@ -73,8 +75,8 @@ def __init__( self._config = config self._event_logger = event_logger or forensics.log_event self._logger = logger or logging.getLogger("vestrix_collector.decisions") - self._enrolled_nodes = load_enrolled_nodes(config.allowlist_path) - self._tls_context = build_server_tls_context(config) + self._security_state_lock = threading.Lock() + self._enrolled_nodes, self._tls_context = self._load_security_state() self._sequence_lock = threading.Lock() self._last_sequences: dict[str, int] = {} self._listener: socket.socket | None = None @@ -88,6 +90,31 @@ def port(self) -> int: raise RuntimeError("collector has not been started") return int(self._listener.getsockname()[1]) + async def reload_security_state(self) -> None: + """Reload the allow-list and CRL-backed TLS context for new connections.""" + enrolled_nodes, tls_context = await asyncio.to_thread( + self._load_security_state + ) + with self._security_state_lock: + self._enrolled_nodes = enrolled_nodes + self._tls_context = tls_context + self._logger.info( + "collector security state reloaded", + extra={ + "event": "collector_lifecycle", + "decision_fields": { + "state": "security_state_reloaded", + "enrolled_node_count": len(enrolled_nodes), + }, + }, + ) + + def _load_security_state(self) -> tuple[frozenset[str], ssl.SSLContext]: + return ( + load_enrolled_nodes(self._config.allowlist_path), + build_server_tls_context(self._config), + ) + async def start(self) -> None: """Bind the listener and start accepting sockets.""" if self._listener is not None: @@ -153,8 +180,11 @@ def _handle_socket(self, client: socket.socket, address: Any) -> None: try: client.setblocking(True) client.settimeout(self._config.server.handshake_timeout_seconds) - with self._tls_context.wrap_socket(client, server_side=True) as tls_socket: - self._handle_tls_socket(tls_socket, peer) + with self._security_state_lock: + tls_context = self._tls_context + enrolled_nodes = self._enrolled_nodes + with tls_context.wrap_socket(client, server_side=True) as tls_socket: + self._handle_tls_socket(tls_socket, peer, enrolled_nodes) except (ssl.SSLError, TimeoutError, OSError) as exc: self._decision( "reject", @@ -174,14 +204,19 @@ def _handle_socket(self, client: socket.socket, address: Any) -> None: ) client.close() - def _handle_tls_socket(self, tls_socket: ssl.SSLSocket, peer: str) -> None: + def _handle_tls_socket( + self, + tls_socket: ssl.SSLSocket, + peer: str, + enrolled_nodes: frozenset[str], + ) -> None: certificate = tls_socket.getpeercert() common_names = _certificate_common_names(certificate) if len(common_names) != 1: self._reject_tls_socket(tls_socket, "invalid_certificate_cn", peer=peer) return certificate_node_id = common_names[0] - if certificate_node_id not in self._enrolled_nodes: + if certificate_node_id not in enrolled_nodes: self._reject_tls_socket( tls_socket, "unenrolled_certificate_cn", diff --git a/docs/DPIA.md b/docs/DPIA.md index 6520fef..94e709b 100644 --- a/docs/DPIA.md +++ b/docs/DPIA.md @@ -339,8 +339,10 @@ The following controls exist, subject to the implementation limits in the - **Transport authentication and integrity:** the collector implements mTLS, CA validation, an exact certificate-CN allow-list, and binding between the - certificate CN and payload `node_id`. The ESP32 client is not implemented, so - this is not yet an end-to-end production control. + certificate CN and payload `node_id`. It checks a configured CRL, supports + SIGHUP reload of the CRL and allow-list for new connections, and includes a + 30-day node-certificate expiry warning script. The ESP32 client is not + implemented, so this is not yet an end-to-end production control. - **Input and replay checks:** the collector enforces a strict payload schema, numeric and timestamp bounds, message-size limits, and a per-node increasing sequence number. Replay state is held in memory and is lost on restart. @@ -374,7 +376,9 @@ As of this template version, Vestrix does **not** provide: dataset matching and re-identification; - built-in user RBAC, access reviews, or audit logs for reads and exports; - encryption at rest or deployment-wide key management; -- automatic certificate revocation, rotation, or durable replay state; +- automated certificate enrollment or rotation, OCSP, HSM-backed or + passphrase-protected private keys, or durable replay state (operator-driven CRL + revocation and expiry reporting are provided); - an end-to-end dispatcher connecting collector, ML, forensic, and SOC stages; - an implemented production model, SHAP generator, or real-world accuracy and generalization results; diff --git a/docs/threat-model.md b/docs/threat-model.md index 4c85883..f990e6c 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -118,6 +118,7 @@ measurement. | Status | Control | Security effect | |---|---|---| | `IMPLEMENTED` | The asyncio TCP collector requires mutual TLS and validates client certificates against the configured CA. | Rejects unauthenticated TCP clients and protects the confidentiality and integrity of accepted data in transit. | +| `IMPLEMENTED` | OpenSSL leaf-certificate CRL checking rejects certificates listed in the configured CRL. The allow-list and CRL-backed TLS context are atomically reloadable for new connections through SIGHUP on supported platforms. | Lets an operator revoke an issued node certificate or change enrollment without restarting the collector. Existing connections are not retroactively terminated. | | `IMPLEMENTED` | The collector requires one certificate CN, checks it against an exact node allow-list, and binds the payload `node_id` to that CN. | Prevents an authenticated node from claiming a different allow-listed node identity through the payload alone. | | `IMPLEMENTED` | Strict payload schema, type, size, timestamp, and numeric-bound validation is performed before handoff. | Rejects malformed or out-of-contract events; it does not establish that well-formed CSI is physically truthful. | | `IMPLEMENTED` | A per-node, strictly increasing `sequence_number` is enforced in collector memory. Replayed or out-of-order values are rejected before handoff. | Detects replay within the lifetime of the collector process for a given certificate identity. | @@ -145,10 +146,13 @@ measurement. - Replay state is held in process memory. A collector restart loses the last-seen sequence values, so the present mechanism does not provide replay continuity across restarts or node re-enrollment. -- The allow-list is loaded when the collector is constructed. CRL/OCSP checking, - automated credential rotation, and hot reload of enrollment state are not - implemented; credential revocation requires an operator configuration change and - collector restart. +- The collector checks the configured CRL and can atomically reload both that CRL + and the node allow-list on SIGHUP. Revocation remains an operator action: run the + CA revocation helper, distribute the regenerated CRL to the configured path, and + signal each collector process. A failed reload retains the last valid state. +- `check_expiry.sh` flags node certificates within 30 days of expiry by default; it + provides visibility only. Automated enrollment and rotation, OCSP, HSM-backed + key storage, and passphrase-protected private keys are not implemented. - RF-domain manipulation occurs before TLS protection and is not mitigated by mTLS or JSON validation. - No adversarial CSI robustness result is available for Vestrix. Published results From f8b7f2002fdacfa7b3d4a83915c55a3f8d0dd581 Mon Sep 17 00:00:00 2001 From: Rehan Khan Date: Sat, 22 Aug 2026 16:53:53 +0500 Subject: [PATCH 2/2] test: verify CA bootstrap and SIGHUP reload --- collector/tests/test_cert_scripts.py | 96 ++++++++++++++++++++++++++++ collector/tests/test_server.py | 96 +++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 collector/tests/test_cert_scripts.py diff --git a/collector/tests/test_cert_scripts.py b/collector/tests/test_cert_scripts.py new file mode 100644 index 0000000..b95e3c8 --- /dev/null +++ b/collector/tests/test_cert_scripts.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from vestrix_collector.config import CollectorConfig, ServerConfig, TLSConfig +from vestrix_collector.server import CollectorServer + + +@pytest.mark.skipif(sys.platform != "linux", reason="requires Linux OpenSSL tooling") +def test_fresh_ca_bootstrap_creates_empty_loadable_crl(tmp_path: Path) -> None: + cert_tools = Path(__file__).parents[1] / "certs" + shutil.copy(cert_tools / "generate_ca.sh", tmp_path) + shutil.copy(cert_tools / "openssl.cnf", tmp_path) + subprocess.run( + ["bash", "generate_ca.sh"], cwd=tmp_path, check=True, capture_output=True + ) + + crl_text = subprocess.run( + ["openssl", "crl", "-in", "ca.crl", "-noout", "-text"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ).stdout + assert "Serial Number:" not in crl_text + + subprocess.run( + ["openssl", "genrsa", "-out", "server.key", "2048"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "req", + "-new", + "-key", + "server.key", + "-out", + "server.csr", + "-subj", + "/CN=collector-test", + ], + cwd=tmp_path, + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "ca.crt", + "-CAkey", + "ca.key", + "-set_serial", + "2000", + "-out", + "server.crt", + "-days", + "1", + ], + cwd=tmp_path, + check=True, + capture_output=True, + ) + allowlist = tmp_path / "nodes.yaml" + allowlist.write_text("nodes:\n - node-01\n", encoding="utf-8") + config = CollectorConfig( + tls=TLSConfig( + ca_cert=tmp_path / "ca.crt", + crl_path=tmp_path / "ca.crl", + server_cert=tmp_path / "server.crt", + server_key=tmp_path / "server.key", + ), + allowlist_path=allowlist, + server=ServerConfig(host="127.0.0.1", port=0), + ) + + async def start_and_close() -> None: + server = CollectorServer(config) + await server.start() + assert server.port > 0 + await server.close() + + asyncio.run(start_and_close()) diff --git a/collector/tests/test_server.py b/collector/tests/test_server.py index b4844ff..1533287 100644 --- a/collector/tests/test_server.py +++ b/collector/tests/test_server.py @@ -3,13 +3,19 @@ import asyncio import json import logging +import os +import signal import socket import ssl +import subprocess +import sys +import time from collections.abc import Callable from pathlib import Path from typing import Any import pytest +import yaml from conftest import CertificateBundle, Identity, write_crl from vestrix_collector.config import CollectorConfig, ServerConfig, TLSConfig @@ -188,6 +194,94 @@ async def scenario(server: CollectorServer) -> dict[str, str]: assert received == [] +@pytest.mark.skipif(sys.platform != "linux", reason="requires POSIX SIGHUP") +def test_real_sighup_reloads_crl_in_collector_process( + tmp_path: Path, certificates: CertificateBundle +) -> None: + with socket.socket() as reserved_socket: + reserved_socket.bind(("127.0.0.1", 0)) + port = reserved_socket.getsockname()[1] + + allowlist = tmp_path / "nodes.yaml" + allowlist.write_text("nodes:\n - node-01\n", encoding="utf-8") + config_path = tmp_path / "collector.yaml" + config_path.write_text( + yaml.safe_dump( + { + "tls": { + "ca_cert": str(certificates.ca_cert), + "crl_path": str(certificates.crl_path), + "server_cert": str(certificates.server.cert), + "server_key": str(certificates.server.key), + }, + "allowlist_path": str(allowlist), + "server": {"host": "127.0.0.1", "port": port}, + } + ), + encoding="utf-8", + ) + process = subprocess.Popen( + [sys.executable, "-m", "vestrix_collector", "--config", str(config_path)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 10 + while True: + if process.poll() is not None: + pytest.fail( + f"collector exited before startup:\n{process.stdout.read()}" + ) + try: + first = _send_event( + port, + certificates.ca_cert, + certificates.enrolled_node, + _payload(), + ) + break + except OSError: + if time.monotonic() >= deadline: + pytest.fail( + "collector did not accept a connection within 10 seconds" + ) + time.sleep(0.05) + assert first == { + "status": "accepted", + "reason": "authenticated_event_handed_off", + } + + write_crl(certificates, (certificates.enrolled_node,)) + os.kill(process.pid, signal.SIGHUP) + deadline = time.monotonic() + 10 + sequence_number = 2 + while True: + try: + _send_event( + port, + certificates.ca_cert, + certificates.enrolled_node, + _payload(sequence_number), + ) + except ssl.SSLError: + break + if time.monotonic() >= deadline: + pytest.fail( + "collector did not enforce the reloaded CRL within 10 seconds" + ) + sequence_number += 1 + time.sleep(0.05) + assert process.poll() is None + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + def test_valid_certificate_with_unenrolled_cn_is_rejected( tmp_path: Path, certificates: CertificateBundle ) -> None: @@ -222,7 +316,7 @@ def test_self_signed_client_certificate_is_rejected_during_tls( caplog.set_level(logging.INFO, logger="vestrix_collector.decisions") async def scenario(server: CollectorServer) -> None: - with pytest.raises(ssl.SSLError): + with pytest.raises(OSError): await asyncio.to_thread( _send_event, server.port,