diff --git a/README.md b/README.md index 872b18b..d393562 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ # DNS TAPIR Node Manager This repository contains the DNS TAPIR Node Manager, a server component for managing nodes. + + +## Enrollment + +### Request + +The enrollment request is a JWS sign with both the data key (algorithm depending on key algorithm) and the enrollment secret (algorithm `HS256`). JWS payload is a dictionary with the following properties: + +- `timestamp`, A timestamp with the current time (ISO8601) +- `x509_csr`, A string with a PEM-encoded X.509 Certificate Signing Request with _Common Name_ and _Subject Alterantive Name_ set to the full node name. +- `public_key`, A JWK dictionary containing the public data key. + +### Response + +The enrollment response is a dictionary containing at least the following properties: + +- `x509_certificate`, X.509 Client Certificate Bundle (PEM) +- `x509_ca_certificate`, X.509 CA Certificate Bundle (PEM) +- `mqtt_broker`, MQTT broker address (URI) +- `mqtt_topics`, Dictionary of per application MQTT configuration topic +- `trusted_keys`, List of JWKs used for signing data from core services + + +## Renewal + +### Request + +The renewal request is a JWS sign with the data key (algorithm depending on key algorithm). JWS payload is a dictionary with the following properties: + +- `timestamp`, A timestamp with the current time (ISO8601) +- `x509_csr`, A string with a PEM-encoded X.509 Certificate Signing Request with _Common Name_ and _Subject Alterantive Name_ set to the full node name. + +### Response + +The enrollment response is a dictionary containing at least the following properties: + +- `x509_certificate`, X.509 Client Certificate Bundle (PEM) +- `x509_ca_certificate`, X.509 CA Certificate Bundle (PEM) diff --git a/docker-compose.yaml b/docker-compose.yaml index 61bb0af..a8a85bc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -92,23 +92,3 @@ configs: static_configs: - targets: ['otel-collector:8889'] - targets: ['otel-collector:8888'] - vault.json: - content: | - { - "storage": { - "file": { - "path": "/vault/file" - } - }, - "listener": [ - { - "tcp": { - "address": "0.0.0.0:8200", - "tls_disable": true - } - } - ], - "default_lease_ttl": "168h", - "max_lease_ttl": "720h", - "ui": true - } diff --git a/nodeman/client.py b/nodeman/client.py index 9880461..8921268 100644 --- a/nodeman/client.py +++ b/nodeman/client.py @@ -2,6 +2,7 @@ import json import logging import sys +from datetime import datetime, timezone from urllib.parse import urljoin import httpx @@ -26,7 +27,13 @@ def enroll(name: str, server: str, hmac_key: JWK, data_key: JWK, x509_key: Priva data_alg = jwk_to_alg(data_key) x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - jws_payload = json.dumps({"x509_csr": x509_csr, "public_key": data_key.export_public(as_dict=True)}) + jws_payload = json.dumps( + { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + "public_key": data_key.export_public(as_dict=True), + } + ) jws = JWS(payload=jws_payload) jws.add_signature(key=hmac_key, alg=hmac_alg, protected={"alg": hmac_alg}) @@ -50,7 +57,7 @@ def renew(name: str, server: str, data_key: JWK, x509_key: PrivateKey) -> NodeCe data_alg = jwk_to_alg(data_key) x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - jws_payload = json.dumps({"x509_csr": x509_csr, "public_key": data_key.export_public(as_dict=True)}) + jws_payload = json.dumps({"x509_csr": x509_csr}) jws = JWS(payload=jws_payload) jws.add_signature(key=data_key, alg=data_alg, protected={"alg": data_alg}) diff --git a/nodeman/models.py b/nodeman/models.py index 700d2b3..0d6c1c9 100644 --- a/nodeman/models.py +++ b/nodeman/models.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from enum import StrEnum from typing import Self @@ -8,6 +8,8 @@ from .db_models import TapirNode from .settings import MqttUrl +MAX_REQUEST_AGE = 300 + class PublicKeyFormat(StrEnum): PEM = "application/x-pem-file" @@ -37,6 +39,25 @@ class PublicJwk(BaseModel): e: str | None = None +class NodeRequest(BaseModel): + timestamp: datetime = Field(title="Timestamp") + x509_csr: str = Field(title="X.509 Client Certificate Bundle") + + @field_validator("timestamp") + @classmethod + def validate_timestamp(cls, ts: datetime): + if (td := (datetime.now(tz=timezone.utc) - ts).total_seconds()) > MAX_REQUEST_AGE: + raise ValueError(f"Request too old or in the future, delta={td}") + + +class EnrollmentRequest(NodeRequest): + public_key: PublicJwk = Field(title="Public data key") + + +class RenewalRequest(NodeRequest): + pass + + class NodeInformation(BaseModel): name: str = Field(title="Node name") public_key: PublicJwk | None = Field(title="Public key") diff --git a/nodeman/nodes.py b/nodeman/nodes.py index 10e0412..074c690 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -12,6 +12,7 @@ from .authn import get_current_username from .db_models import TapirNode, TapirNodeSecret from .models import ( + EnrollmentRequest, NodeBootstrapInformation, NodeCertificate, NodeCollection, @@ -19,6 +20,7 @@ NodeInformation, PublicJwk, PublicKeyFormat, + RenewalRequest, ) from .x509 import process_csr_request @@ -224,20 +226,20 @@ async def enroll_node( logger.warning("Invalid HMAC signature from %s", name, extra={"nodename": name}) raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid HMAC signature") from exc - message = json.loads(jws.payload) - public_key = JWK(**message["public_key"]) + message = EnrollmentRequest.model_validate_json(jws.payload) + public_key = JWK(**message.public_key.model_dump(exclude_none=True)) # Verify signature by public data key try: jws.verify(key=public_key) - logger.debug("Valid proof-of-possession signature from %s", name, extra={"nodename": name}) + logger.debug("Valid data signature from %s", name, extra={"nodename": name}) except InvalidJWSSignature as exc: - logger.warning("Invalid proof-of-possession signature from %s", name, extra={"nodename": name}) - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid proof-of-possession signature") from exc + logger.warning("Invalid data signature from %s", name, extra={"nodename": name}) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid data signature") from exc node.public_key = public_key.export(as_dict=True, private_key=False) # Verify X.509 CSR and issue certificate - x509_csr = x509.load_pem_x509_csr(message["x509_csr"].encode()) + x509_csr = x509.load_pem_x509_csr(message.x509_csr.encode()) with tracer.start_as_current_span("issue_certificate"): node_certificate = process_csr_request(csr=x509_csr, name=name, request=request) @@ -289,14 +291,14 @@ async def renew_node( # Verify signature by public data key try: jws.verify(key=public_key) - logger.debug("Valid proof-of-possession signature from %s", name, extra={"nodename": name}) + logger.debug("Valid data signature from %s", name, extra={"nodename": name}) except InvalidJWSSignature as exc: - logger.warning("Invalid proof-of-possession signature from %s", name, extra={"nodename": name}) - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid proof-of-possession signature") from exc - message = json.loads(jws.payload) + logger.warning("Invalid data signature from %s", name, extra={"nodename": name}) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid data signature") from exc + message = RenewalRequest.model_validate_json(jws.payload) # Verify X.509 CSR and issue certificate - x509_csr = x509.load_pem_x509_csr(message["x509_csr"].encode()) + x509_csr = x509.load_pem_x509_csr(message.x509_csr.encode()) with tracer.start_as_current_span("issue_certificate"): res = process_csr_request(csr=x509_csr, name=name, request=request) diff --git a/nodeman/settings.py b/nodeman/settings.py index de51416..39d1247 100644 --- a/nodeman/settings.py +++ b/nodeman/settings.py @@ -32,11 +32,6 @@ class StepSettings(BaseModel): provisioner_private_key: FilePath -class VaultSettings(BaseModel): - server: AnyHttpUrl | None = Field(default="http://localhost:8200") - mount_point: str | None = None - - class NodesSettings(BaseModel): domain: str = Field(default="example.com") trusted_keys: FilePath | None = Field(default=None) @@ -63,7 +58,6 @@ def create(cls, username: str, password: str) -> Self: class Settings(BaseSettings): mongodb: MongoDB = Field(default=MongoDB()) step: StepSettings | None = None - # vault: VaultSettings = Field(default=VaultSettings()) otlp: OtlpSettings | None = None users: list[User] = Field(default=[]) diff --git a/nodeman/x509.py b/nodeman/x509.py index 9135a9a..73a9eea 100644 --- a/nodeman/x509.py +++ b/nodeman/x509.py @@ -88,6 +88,9 @@ def verify_x509_csr(name: str, csr: x509.CertificateSigningRequest) -> None: elif len(csr.extensions) > 1: raise CertificateSigningRequestException("Multiple extensions") + if not csr.is_signature_valid: + raise CertificateSigningRequestException("Invalid CSR signature") + # ensure SubjectAlternativeName is correct san_ext = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) san_value = san_ext.value.get_values_for_type(x509.DNSName) diff --git a/tests/test_api.py b/tests/test_api.py index 069def3..5d400c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ import json import logging import uuid +from datetime import datetime, timezone from urllib.parse import urljoin import pytest @@ -91,7 +92,11 @@ def _test_enroll(data_key: JWK, x509_key: PrivateKey, requested_name: str | None x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - payload = {"x509_csr": x509_csr, "public_key": data_key.export_public(as_dict=True)} + payload = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + "public_key": data_key.export_public(as_dict=True), + } jws = JWS(payload=json.dumps(payload)) jws.add_signature(key=hmac_key, alg=hmac_alg, protected={"alg": hmac_alg}) @@ -148,7 +153,10 @@ def _test_enroll(data_key: JWK, x509_key: PrivateKey, requested_name: str | None # Renew certificate (bad) x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - payload = {"x509_csr": x509_csr} + payload = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + } jws = JWS(payload=json.dumps(payload)) jws.add_signature(key=rekey(data_key), alg=data_alg, protected={"alg": data_alg}) @@ -161,7 +169,10 @@ def _test_enroll(data_key: JWK, x509_key: PrivateKey, requested_name: str | None # Renew certificate x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - payload = {"x509_csr": x509_csr} + payload = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + } jws = JWS(payload=json.dumps(payload)) jws.add_signature(key=data_key, alg=data_alg, protected={"alg": data_alg}) @@ -260,7 +271,11 @@ def test_enroll_bad_hmac_signature() -> None: x509_key = ec.generate_private_key(ec.SECP256R1()) x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - payload = {"x509_csr": x509_csr, "public_key": data_key.export_public(as_dict=True)} + payload = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + "public_key": data_key.export_public(as_dict=True), + } jws = JWS(payload=json.dumps(payload)) jws.add_signature(key=hmac_key, alg=hmac_alg, protected={"alg": hmac_alg}) @@ -304,7 +319,11 @@ def test_enroll_bad_data_signature() -> None: x509_key = ec.generate_private_key(ec.SECP256R1()) x509_csr = generate_x509_csr(key=x509_key, name=name).public_bytes(serialization.Encoding.PEM).decode() - payload = {"x509_csr": x509_csr, "public_key": data_key.export_public(as_dict=True)} + payload = { + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + "x509_csr": x509_csr, + "public_key": data_key.export_public(as_dict=True), + } jws = JWS(payload=json.dumps(payload)) jws.add_signature(key=hmac_key, alg=hmac_alg, protected={"alg": hmac_alg})