Files
taylanbakircioglu eee0a4716a feat(ssl): encrypt the pending CSR private key at rest (v1.10.1, closes #53)
Closes the follow-up filed during the v1.9.0 CSR review. The private key of a
PENDING CSR is now Fernet-encrypted in the database instead of being stored as
a raw PEM.

Why this key specifically: it is the one key in the system that sits idle. It
is generated at CSR creation, waits for an external CA to sign the request
(days to weeks), and is destroyed the moment the signed certificate is
imported. It is never transmitted to an agent and never leaves the server.
ssl_certificates.private_key_content and the ACME order keys are deliberately
NOT covered, because agents must receive those in plaintext on every poll, so
encrypting them at rest buys nothing without an end-to-end redesign.

Implementation follows the pattern already used for the VRRP secret, TOTP
secrets and DNS provider credentials: a new utils/csr_key_crypto.py with its
own CSR_ENCRYPTION_KEY env var and its own HKDF info string
("csr-private-key-v1"), so rotating one secret class never affects another.

No schema change and deliberately NO SCHEMA_VERSION bump: the Fernet token
replaces the PEM inside the existing ssl_csrs.private_key_pem TEXT column. A
bump would re-run the migration sequence and re-seed the four built-in roles to
their defaults, which is a needless side effect for a storage-format change.

Backward compatible with no data migration. Rows written before this release
hold a raw PEM and are still read unchanged; the discriminator is exact rather
than a heuristic, since a Fernet token is base64url and can never contain the
"-----BEGIN" marker. Legacy rows drain naturally because a CSR's key copy is
NULLed on import.

A key that cannot be decrypted (SECRET_KEY rotated while CSR_ENCRYPTION_KEY was
unset) now fails with an explicit "delete this CSR and create a new one" error.
Previously that situation would have surfaced as the far more confusing
"certificate does not match this CSR's private key".

Also documents all four per-purpose encryption keys in .env.template. Only
VIP_ENCRYPTION_KEY was listed; MFA_ENCRYPTION_KEY and
DNS_PROVIDER_ENCRYPTION_KEY had been missing since v1.6.0 and v1.8.0.

Verified before release, on a corporate pre-production environment and locally:
- Full backend suite 1234 -> 1243 passed (+9 new tests), 0 failed.
- Against a real Postgres: a CSR created through the API stores a Fernet token
  with no PEM header in the column, and imports successfully.
- Full 1.10.0 -> 1.10.1 -> 1.10.0 drill on one database volume. The upgrade
  logs "Schema already at version 10 (>= 10); skipping migration run", so no
  migration executes and the built-in roles are not re-seeded. A CSR created on
  1.10.0 with a plaintext key imports successfully after the upgrade, which is
  the backward-compatibility guarantee proven against a real row rather than a
  mock.
- rsa-2048, rsa-4096 and ecdsa-p384 all round-trip through create, encrypt,
  decrypt and import.
- Key derivation is stable across processes: two independent containers sharing
  SECRET_KEY decrypt each other's tokens (required for UVICORN_WORKERS > 1 and
  multi-replica deployments), while a different SECRET_KEY yields None rather
  than a wrong key or an exception.
- Downgrade behaviour was measured, not assumed: 1.10.0 cannot parse the token
  and fails with HTTP 500 "key parse failed (encrypted?)" rather than pairing a
  wrong key. The rollback note states the measured behaviour.
- No CSR endpoint returns the key in any form: list and detail responses
  contain neither a PEM nor a Fernet token.

Not changed here, from the issue's "worth folding in" list: the create rate
limit is not a concurrency guard, create_csr holds a pooled connection across
RSA key generation, detail=str(e) echoes internal error text (a repo-wide
convention), and is_global skips cluster validation in both routers/ssl.py and
routers/csr.py. None are storage concerns and each is a separate change.
2026-08-08 01:44:32 +03:00

121 lines
5.3 KiB
Python

"""Issue #53 — at-rest encryption for the pending CSR private key (v1.10.1).
Mirrors the established Fernet + HKDF(SECRET_KEY) pattern already used for the VRRP secret
(services/keepalived_config.py), TOTP secrets (services/mfa_service.py) and DNS provider
credentials (utils/dns_credentials.py): prefer an explicit CSR_ENCRYPTION_KEY env var (enables
key rotation), else derive a stable key from SECRET_KEY via HKDF with its own versioned info
string, so a rotation of one secret class never affects another.
WHY this key and not every key in the system: the CSR private key is the one key that sits IDLE.
It is generated at CSR creation, waits for an external CA to sign the request (days to weeks),
and is destroyed the moment the signed certificate is imported — it is never transmitted to an
agent and never leaves the server. `ssl_certificates.private_key_content` and the ACME order keys
are different: agents must receive them in plaintext on every poll, so encrypting them at rest
buys nothing without an end-to-end redesign.
STORAGE: the Fernet token replaces the PEM in the SAME `ssl_csrs.private_key_pem` TEXT column.
No new column, no new table, and deliberately NO `SCHEMA_VERSION` bump — a bump would re-run the
migration sequence and re-seed the four built-in roles to their defaults (see UPGRADE_GUIDE.md),
which is a needless side effect for a storage-format change.
BACKWARD COMPATIBILITY: rows written before this release hold a raw PEM. `decrypt_csr_private_key`
detects those by their `-----BEGIN` header and returns them unchanged. The discriminator is exact,
not a heuristic: a Fernet token is base64url text and can never contain "-----". Legacy rows drain
naturally, since a CSR's key copy is NULLed on import.
KEY ROTATION: if SECRET_KEY rotates while CSR_ENCRYPTION_KEY is unset, previously stored keys
become undecryptable and `decrypt_csr_private_key` returns None. Callers MUST surface a clear
"delete this CSR and create a new one" error — the CSR is unusable at that point, because the
signed certificate can no longer be paired with its key.
"""
from __future__ import annotations
import base64
import logging
import os
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from config import SECRET_KEY
logger = logging.getLogger(__name__)
# A PEM private key always carries this header; a Fernet token is base64url and never can.
_PEM_MARKER = "-----BEGIN"
_fernet_instance: Optional[Fernet] = None
def _resolve_fernet_key() -> bytes:
"""Prefer an explicit CSR_ENCRYPTION_KEY; else derive from SECRET_KEY via HKDF with a
versioned info string (so stored keys survive restarts)."""
explicit = os.getenv("CSR_ENCRYPTION_KEY", "").strip()
if explicit:
try:
Fernet(explicit.encode())
return explicit.encode()
except Exception as exc: # noqa: BLE001
logger.error("CSR_ENCRYPTION_KEY env var present but invalid: %s", exc)
logger.warning(
"CSR_ENCRYPTION_KEY not set; deriving the CSR private-key encryption key from SECRET_KEY. "
"Set CSR_ENCRYPTION_KEY to a Fernet key to enable key rotation."
)
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"csr-private-key-v1")
derived = hkdf.derive(SECRET_KEY.encode("utf-8"))
return base64.urlsafe_b64encode(derived)
def _get_fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(_resolve_fernet_key())
return _fernet_instance
def reset_fernet_for_tests() -> None:
"""Test-only hook to force re-resolution after env mutation."""
global _fernet_instance
_fernet_instance = None
def is_encrypted(stored: Optional[str]) -> bool:
"""True when the stored value is a Fernet token rather than a legacy raw PEM.
Single source of the format discriminator: `decrypt_csr_private_key` branches on this, so
the "what does a stored value look like" rule is stated exactly once.
"""
return bool(stored) and _PEM_MARKER not in stored
def encrypt_csr_private_key(pem: str) -> str:
"""Fernet-encrypt a PEM private key to a storable token string."""
return _get_fernet().encrypt(pem.encode("utf-8")).decode("utf-8")
def decrypt_csr_private_key(stored: Optional[str]) -> Optional[str]:
"""Return the PEM private key for a stored value.
Accepts BOTH shapes so an upgrade needs no data migration:
- a raw PEM written before v1.10.1 -> returned unchanged
- a Fernet token -> decrypted
Returns None when the value is empty or cannot be decrypted (e.g. SECRET_KEY rotated without
CSR_ENCRYPTION_KEY). Callers MUST treat None as "this CSR's key is unrecoverable" and tell the
operator to delete it and create a new one; never fall through to a pairing attempt.
"""
if not stored:
return None
if not is_encrypted(stored):
return stored # legacy plaintext row, pre-v1.10.1
try:
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
except InvalidToken:
logger.warning("Failed to decrypt a stored CSR private key (invalid Fernet token)")
return None
except Exception as exc: # noqa: BLE001
logger.error("Unexpected error decrypting a stored CSR private key: %s", exc)
return None