mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 23:55:13 +00:00
eee0a4716a
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.
130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""Issue #53 (v1.10.1) — at-rest encryption for the pending CSR private key.
|
|
|
|
Pure logic: no DB, no network. Covers the round-trip, the backward-compatible read of rows
|
|
written before this release, the unrecoverable-key path after a key rotation, and a static
|
|
assertion that the write path can no longer store a raw PEM.
|
|
"""
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-csr-encryption-unit-tests")
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
from utils.csr_key_crypto import (
|
|
decrypt_csr_private_key,
|
|
encrypt_csr_private_key,
|
|
is_encrypted,
|
|
reset_fernet_for_tests,
|
|
)
|
|
|
|
_SAMPLE_PEM = (
|
|
"-----BEGIN PRIVATE KEY-----\n"
|
|
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\n"
|
|
"-----END PRIVATE KEY-----\n"
|
|
)
|
|
|
|
|
|
def test_roundtrip_and_ciphertext_does_not_contain_the_key():
|
|
reset_fernet_for_tests()
|
|
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
|
# The stored form must not be the PEM, and must not leak any recognisable fragment of it.
|
|
assert token != _SAMPLE_PEM
|
|
assert "-----BEGIN" not in token
|
|
assert "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj" not in token
|
|
assert decrypt_csr_private_key(token) == _SAMPLE_PEM
|
|
|
|
|
|
def test_is_encrypted_discriminates_token_from_legacy_pem():
|
|
reset_fernet_for_tests()
|
|
assert is_encrypted(encrypt_csr_private_key(_SAMPLE_PEM)) is True
|
|
assert is_encrypted(_SAMPLE_PEM) is False
|
|
assert is_encrypted("") is False
|
|
assert is_encrypted(None) is False
|
|
|
|
|
|
def test_legacy_plaintext_row_is_read_unchanged():
|
|
# Rows written before v1.10.1 hold a raw PEM. They must keep working with NO data migration,
|
|
# otherwise upgrading would strand every CSR that is out for signature.
|
|
reset_fernet_for_tests()
|
|
assert decrypt_csr_private_key(_SAMPLE_PEM) == _SAMPLE_PEM
|
|
|
|
|
|
def test_empty_or_missing_value_returns_none():
|
|
reset_fernet_for_tests()
|
|
assert decrypt_csr_private_key(None) is None
|
|
assert decrypt_csr_private_key("") is None
|
|
|
|
|
|
def test_key_rotation_makes_the_stored_key_unrecoverable_rather_than_wrong():
|
|
"""After a rotation the caller must get None, never a silently wrong key."""
|
|
reset_fernet_for_tests()
|
|
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
|
|
|
# Rotate: an explicit, different CSR_ENCRYPTION_KEY takes precedence over the derived one.
|
|
previous = os.environ.get("CSR_ENCRYPTION_KEY")
|
|
os.environ["CSR_ENCRYPTION_KEY"] = Fernet.generate_key().decode()
|
|
try:
|
|
reset_fernet_for_tests()
|
|
assert decrypt_csr_private_key(token) is None
|
|
finally:
|
|
if previous is None:
|
|
os.environ.pop("CSR_ENCRYPTION_KEY", None)
|
|
else:
|
|
os.environ["CSR_ENCRYPTION_KEY"] = previous
|
|
reset_fernet_for_tests()
|
|
|
|
|
|
def test_explicit_env_key_is_used_and_survives_reset():
|
|
previous = os.environ.get("CSR_ENCRYPTION_KEY")
|
|
key = Fernet.generate_key().decode()
|
|
os.environ["CSR_ENCRYPTION_KEY"] = key
|
|
try:
|
|
reset_fernet_for_tests()
|
|
token = encrypt_csr_private_key(_SAMPLE_PEM)
|
|
# Decryptable with the same explicit key from a fresh instance...
|
|
reset_fernet_for_tests()
|
|
assert decrypt_csr_private_key(token) == _SAMPLE_PEM
|
|
# ...and independently verifiable with the raw Fernet key.
|
|
assert Fernet(key.encode()).decrypt(token.encode()).decode() == _SAMPLE_PEM
|
|
finally:
|
|
if previous is None:
|
|
os.environ.pop("CSR_ENCRYPTION_KEY", None)
|
|
else:
|
|
os.environ["CSR_ENCRYPTION_KEY"] = previous
|
|
reset_fernet_for_tests()
|
|
|
|
|
|
def test_derivation_uses_its_own_hkdf_info_string():
|
|
"""Each secret class derives an independent key, so rotating one never affects another."""
|
|
src = (Path(__file__).resolve().parent.parent / "utils" / "csr_key_crypto.py").read_text()
|
|
assert b"csr-private-key-v1".decode() in src
|
|
# Must NOT reuse another class's info string.
|
|
for foreign in ("dns-provider-creds-v1", "vip-vrrp-secret-v1", "mfa-totp-secret-v1"):
|
|
assert foreign not in src, f"CSR key derivation must not reuse the {foreign} info string"
|
|
|
|
|
|
def test_write_path_stores_the_encrypted_form_not_the_pem():
|
|
"""Static pin: insert_csr_row must encrypt before the INSERT.
|
|
|
|
A future refactor that passed bundle['private_key_pem'] straight through would silently
|
|
reintroduce plaintext storage, and no unit test with a mocked connection would notice.
|
|
"""
|
|
src = (Path(__file__).resolve().parent.parent / "services" / "csr_service.py").read_text()
|
|
insert_fn = src[src.index("async def insert_csr_row("):]
|
|
insert_fn = insert_fn[: insert_fn.index("\nasync def ")]
|
|
assert "encrypt_csr_private_key(bundle['private_key_pem'])" in insert_fn
|
|
# The raw PEM must not be a bind parameter of the INSERT itself.
|
|
assert not re.search(r"^\s*bundle\['private_key_pem'\],\s*$", insert_fn, re.M)
|
|
|
|
|
|
def test_import_path_decrypts_and_fails_closed_on_unrecoverable_key():
|
|
src = (Path(__file__).resolve().parent.parent / "services" / "csr_service.py").read_text()
|
|
fn = src[src.index("async def import_signed_certificate("):]
|
|
assert "decrypt_csr_private_key(row['private_key_pem'])" in fn
|
|
# A None decrypt must raise rather than fall through to the key-match comparison.
|
|
assert "cannot be decrypted" in fn
|