Skip to content

Commit

Permalink
Some API cleanup, documentation and better validation (#21)
Browse files Browse the repository at this point in the history
* do not submit public key when renewing

* use better data model when parsing requests

* remove vault

* require valid CSR signature

* require timestamp in requests, max 300 s in the past/future

* add docs

* speling

* use same terminology
  • Loading branch information
jschlyter authored Dec 5, 2024
1 parent cff5e7e commit ed1469e
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 45 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 0 additions & 20 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
11 changes: 9 additions & 2 deletions nodeman/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import logging
import sys
from datetime import datetime, timezone
from urllib.parse import urljoin

import httpx
Expand All @@ -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})
Expand All @@ -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})
Expand Down
23 changes: 22 additions & 1 deletion nodeman/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from enum import StrEnum
from typing import Self

Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
24 changes: 13 additions & 11 deletions nodeman/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
from .authn import get_current_username
from .db_models import TapirNode, TapirNodeSecret
from .models import (
EnrollmentRequest,
NodeBootstrapInformation,
NodeCertificate,
NodeCollection,
NodeConfiguration,
NodeInformation,
PublicJwk,
PublicKeyFormat,
RenewalRequest,
)
from .x509 import process_csr_request

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
6 changes: 0 additions & 6 deletions nodeman/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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=[])

Expand Down
3 changes: 3 additions & 0 deletions nodeman/x509.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 24 additions & 5 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import uuid
from datetime import datetime, timezone
from urllib.parse import urljoin

import pytest
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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})
Expand All @@ -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})
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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})
Expand Down

0 comments on commit ed1469e

Please sign in to comment.