Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
16 changes: 13 additions & 3 deletions collector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <node-id>`, 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.
26 changes: 26 additions & 0 deletions collector/certs/check_expiry.sh
Original file line number Diff line number Diff line change
@@ -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"
32 changes: 25 additions & 7 deletions collector/certs/generate_ca.sh
Original file line number Diff line number Diff line change
@@ -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 <node-id> (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."
36 changes: 36 additions & 0 deletions collector/certs/openssl.cnf
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions collector/certs/revoke_node.sh
Original file line number Diff line number Diff line change
@@ -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 <node-id>" >&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."
1 change: 1 addition & 0 deletions collector/config/collector.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
47 changes: 44 additions & 3 deletions collector/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
96 changes: 96 additions & 0 deletions collector/tests/test_cert_scripts.py
Original file line number Diff line number Diff line change
@@ -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())
22 changes: 22 additions & 0 deletions collector/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading