mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +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.
462 lines
19 KiB
Python
462 lines
19 KiB
Python
"""
|
|
csr_service: CSR (Certificate Signing Request) generation + signed-certificate
|
|
import (v1.9.0).
|
|
|
|
Flow:
|
|
1. `generate_csr_bundle` builds a private key + CSR locally (pure crypto,
|
|
no DB/IO — callers MUST run it via `asyncio.to_thread`: RSA-4096
|
|
generation takes seconds and would stall the single-worker event loop).
|
|
2. The bundle is persisted to `ssl_csrs` (`insert_csr_row`); the operator
|
|
downloads the CSR PEM and has it signed by an external CA.
|
|
3. `import_signed_certificate` pairs the CA response with the stored key,
|
|
creates a normal `ssl_certificates` row (source='csr',
|
|
last_config_status='PENDING' — agents never see it before Apply) and
|
|
NULLs the key copy on the CSR row.
|
|
|
|
The CSR builder generalises the in-repo ACME reference
|
|
(services/acme_service.py finalize_order): PEM output instead of DER, full
|
|
subject instead of CN-only, ECDSA support, same PKCS8/NoEncryption key
|
|
serialisation (the agent concatenates cert+key+chain into one PEM and HAProxy
|
|
cannot read passphrase-protected keys).
|
|
|
|
Private keys are ENCRYPTED AT REST from v1.10.1 (Issue #53): the Fernet token
|
|
replaces the PEM in the same `ssl_csrs.private_key_pem` column, so there is no
|
|
schema change and no SCHEMA_VERSION bump. Rows written earlier hold a raw PEM
|
|
and are still read transparently — see utils/csr_key_crypto.py for the format
|
|
discriminator and the key-rotation caveat. The pending CSR key is the one key
|
|
in the system worth encrypting: it sits idle for the whole signing window and
|
|
is never transmitted, unlike ssl_certificates.private_key_content and the ACME
|
|
order keys, which agents must receive in plaintext on every poll.
|
|
The key is NEVER returned by any CSR API response — `csr_row_to_dict` strips
|
|
it unconditionally.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
from types import SimpleNamespace
|
|
|
|
import asyncpg
|
|
from fastapi import HTTPException
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
from services import ssl_service
|
|
from utils.csr_key_crypto import decrypt_csr_private_key, encrypt_csr_private_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_KEY_FACTORIES = {
|
|
'rsa-2048': lambda: rsa.generate_private_key(public_exponent=65537, key_size=2048),
|
|
'rsa-4096': lambda: rsa.generate_private_key(public_exponent=65537, key_size=4096),
|
|
'ecdsa-p256': lambda: ec.generate_private_key(ec.SECP256R1()),
|
|
'ecdsa-p384': lambda: ec.generate_private_key(ec.SECP384R1()),
|
|
}
|
|
|
|
# (payload attribute, x509 OID, subject-JSON key)
|
|
_SUBJECT_OID_MAP = [
|
|
('organization', NameOID.ORGANIZATION_NAME, 'O'),
|
|
('organizational_unit', NameOID.ORGANIZATIONAL_UNIT_NAME, 'OU'),
|
|
('locality', NameOID.LOCALITY_NAME, 'L'),
|
|
('state', NameOID.STATE_OR_PROVINCE_NAME, 'ST'),
|
|
('country', NameOID.COUNTRY_NAME, 'C'),
|
|
('email', NameOID.EMAIL_ADDRESS, 'emailAddress'),
|
|
]
|
|
|
|
|
|
def generate_csr_bundle(payload: Any) -> Dict[str, Any]:
|
|
"""Generate a private key + CSR for a validated SSLCSRCreate payload.
|
|
|
|
Pure CPU-bound crypto — no DB, no network. Callers must offload via
|
|
`asyncio.to_thread` (see module docstring).
|
|
|
|
Returns {'csr_pem', 'private_key_pem', 'sans', 'subject'}.
|
|
"""
|
|
key = _KEY_FACTORIES[payload.key_algorithm]()
|
|
|
|
attrs = [x509.NameAttribute(NameOID.COMMON_NAME, payload.common_name)]
|
|
subject_json: Dict[str, str] = {}
|
|
for attr_name, oid, json_key in _SUBJECT_OID_MAP:
|
|
value = getattr(payload, attr_name, None)
|
|
if value and str(value).strip():
|
|
cleaned = str(value).strip()
|
|
attrs.append(x509.NameAttribute(oid, cleaned))
|
|
subject_json[json_key] = cleaned
|
|
|
|
# CN always first in the SAN list, then the extra names, deduped with
|
|
# order preserved (mirrors the ACME flow where domains[0] is the CN).
|
|
sans = list(dict.fromkeys([payload.common_name, *(payload.sans or [])]))
|
|
|
|
builder = (
|
|
x509.CertificateSigningRequestBuilder()
|
|
.subject_name(x509.Name(attrs))
|
|
.add_extension(
|
|
x509.SubjectAlternativeName([x509.DNSName(d) for d in sans]),
|
|
critical=False,
|
|
)
|
|
)
|
|
csr = builder.sign(key, hashes.SHA256())
|
|
|
|
return {
|
|
'csr_pem': csr.public_bytes(serialization.Encoding.PEM).decode('utf-8'),
|
|
'private_key_pem': key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode('utf-8'),
|
|
'sans': sans,
|
|
'subject': subject_json,
|
|
}
|
|
|
|
|
|
def diff_domains(csr_sans: Optional[List[str]], cert_domains: Optional[List[str]]) -> List[str]:
|
|
"""Human-readable warnings for SAN drift between the CSR and the signed
|
|
certificate (case-insensitive set diff). CAs legitimately add/normalise
|
|
SANs, so drift is WARN-only — the hard gate is the key match."""
|
|
csr_set = {d.lower() for d in (csr_sans or []) if d}
|
|
cert_set = {d.lower() for d in (cert_domains or []) if d}
|
|
warnings: List[str] = []
|
|
added = sorted(cert_set - csr_set)
|
|
dropped = sorted(csr_set - cert_set)
|
|
if added:
|
|
warnings.append(
|
|
f"The CA added domains that were not in the CSR: {', '.join(added)}"
|
|
)
|
|
if dropped:
|
|
warnings.append(
|
|
f"The CA dropped domains that were requested in the CSR: {', '.join(dropped)}"
|
|
)
|
|
return warnings
|
|
|
|
|
|
def _maybe_json_list(value: Any) -> List[str]:
|
|
"""asyncpg returns JSONB columns as str unless a codec is registered."""
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, list) else []
|
|
except Exception:
|
|
return []
|
|
return list(value) if value else []
|
|
|
|
|
|
def csr_row_to_dict(row: Any, include_pem: bool = False) -> Dict[str, Any]:
|
|
"""Row → API dict. ALWAYS strips private_key_pem — the key never leaves
|
|
the server via a CSR endpoint. csr_pem included only on demand
|
|
(detail/create responses, not lists)."""
|
|
d = dict(row)
|
|
d.pop('private_key_pem', None)
|
|
if not include_pem:
|
|
d.pop('csr_pem', None)
|
|
for key in ('subject', 'sans'):
|
|
if key in d and isinstance(d[key], str):
|
|
try:
|
|
d[key] = json.loads(d[key])
|
|
except Exception:
|
|
pass
|
|
return d
|
|
|
|
|
|
async def assert_csr_name_available(conn, name: str) -> None:
|
|
"""Reject a CSR name that is already taken by an ACTIVE certificate or
|
|
another PENDING CSR. Called BEFORE key generation (cheap fail-fast) and
|
|
re-run inside `insert_csr_row` (the unique index closes the race)."""
|
|
existing_cert = await conn.fetchval(
|
|
"SELECT id FROM ssl_certificates WHERE name = $1 AND is_active = TRUE",
|
|
name,
|
|
)
|
|
if existing_cert:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"An active SSL certificate named '{name}' already exists. "
|
|
"The CSR name becomes the certificate name at import — choose "
|
|
"a different name or remove the existing certificate first."
|
|
),
|
|
)
|
|
existing_csr = await conn.fetchval(
|
|
"SELECT id FROM ssl_csrs WHERE name = $1 AND status = 'pending'",
|
|
name,
|
|
)
|
|
if existing_csr:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"A pending CSR named '{name}' already exists (id={existing_csr}). "
|
|
"Import or delete it first, or choose a different name."
|
|
),
|
|
)
|
|
|
|
|
|
async def insert_csr_row(conn, payload: Any, bundle: Dict[str, Any], user_id: Optional[int]) -> int:
|
|
"""Persist a freshly generated CSR bundle. Returns the new csr id.
|
|
|
|
Issue #53 (v1.10.1): the private key is Fernet-encrypted before it is stored. The token goes
|
|
into the SAME private_key_pem TEXT column — no schema change — and is only ever decrypted
|
|
in-process by import_signed_certificate. No CSR endpoint returns the column either way.
|
|
"""
|
|
await assert_csr_name_available(conn, payload.name)
|
|
stored_key = encrypt_csr_private_key(bundle['private_key_pem'])
|
|
try:
|
|
csr_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO ssl_csrs
|
|
(name, common_name, subject, sans, key_algorithm, csr_pem,
|
|
private_key_pem, status, created_by)
|
|
VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, $7, 'pending', $8)
|
|
RETURNING id
|
|
""",
|
|
payload.name,
|
|
payload.common_name,
|
|
json.dumps(bundle['subject']),
|
|
json.dumps(bundle['sans']),
|
|
payload.key_algorithm,
|
|
bundle['csr_pem'],
|
|
stored_key,
|
|
user_id,
|
|
)
|
|
except asyncpg.exceptions.UniqueViolationError:
|
|
# uq_ssl_csrs_name_pending — a concurrent request won the name.
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"A pending CSR named '{payload.name}' was just created by a "
|
|
"concurrent request — choose a different name."
|
|
),
|
|
)
|
|
return csr_id
|
|
|
|
|
|
async def import_signed_certificate(conn, csr_id: int, imp: Any, user_id: Optional[int]) -> Dict[str, Any]:
|
|
"""Pair the CA-signed certificate with the stored CSR key and create the
|
|
ssl_certificates row. Atomic: cert row + CSR state change commit together.
|
|
|
|
Returns {'certificate_id', 'certificate_name', 'primary_domain',
|
|
'warnings', 'reactivated'}. Raises HTTPException on every failure
|
|
(404 missing, 409 already completed, 400 validation).
|
|
"""
|
|
async with conn.transaction():
|
|
# Row lock serialises concurrent imports AND a concurrent DELETE of
|
|
# the same CSR; works across multiple uvicorn workers (DB-level lock).
|
|
row = await conn.fetchrow(
|
|
"SELECT * FROM ssl_csrs WHERE id = $1 FOR UPDATE", csr_id
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="CSR not found")
|
|
if row['status'] == 'completed':
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
f"CSR '{row['name']}' is already completed — certificate "
|
|
f"id {row['ssl_certificate_id']} was imported from it. "
|
|
"Create a new CSR to reissue."
|
|
),
|
|
)
|
|
if not row['private_key_pem']:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=(
|
|
"Stored CSR private key is missing — the CSR row is "
|
|
"corrupt. Delete it and create a new CSR."
|
|
),
|
|
)
|
|
# Issue #53: the column holds a Fernet token from v1.10.1 on, and a raw PEM for rows
|
|
# written before it. decrypt_csr_private_key accepts both, so no data migration is
|
|
# needed. A None here means the token cannot be decrypted — SECRET_KEY was rotated
|
|
# without CSR_ENCRYPTION_KEY set. Fail loudly: the key is gone, so the CA's certificate
|
|
# can never be paired with it, and silently falling through would surface as the far
|
|
# more confusing "certificate does not match this CSR's private key".
|
|
stored_key = decrypt_csr_private_key(row['private_key_pem'])
|
|
if not stored_key:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=(
|
|
f"The stored private key for CSR '{row['name']}' cannot be decrypted. This "
|
|
"happens when SECRET_KEY was rotated while CSR_ENCRYPTION_KEY was not set. "
|
|
"The key is unrecoverable, so this CSR can no longer be completed — delete "
|
|
"it and create a new one (then have the new CSR signed)."
|
|
),
|
|
)
|
|
|
|
effective_name = getattr(imp, 'name', None) or row['name']
|
|
|
|
# Parse the pasted certificate FIRST so a malformed/truncated CA
|
|
# response gets the manual flow's 400, not a 500 from the key-match
|
|
# step below (verify_certificate_key_match reports an unparseable
|
|
# cert as match=None, which we treat as an integrity failure).
|
|
from utils.ssl_parser import parse_ssl_certificate, verify_certificate_key_match
|
|
precheck = parse_ssl_certificate(imp.certificate_content)
|
|
if precheck.get('error'):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid SSL certificate: {precheck['error']}",
|
|
)
|
|
|
|
# THE defining check of this feature: the CA response must match the
|
|
# key we generated. Deliberately stricter than create_cert_row's
|
|
# lenient fallback — we generated this key ourselves, so an
|
|
# unverifiable pair is an integrity failure, not operator input.
|
|
match_result = verify_certificate_key_match(imp.certificate_content, stored_key)
|
|
if match_result.get('match') is False:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"The signed certificate does not match this CSR's private "
|
|
"key — the CA response likely belongs to a different "
|
|
"CSR/key. Verify you pasted the certificate that was "
|
|
"issued for this exact CSR."
|
|
),
|
|
)
|
|
if match_result.get('match') is not True:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=(
|
|
"Could not verify the certificate/key pair: "
|
|
f"{match_result.get('reason', 'unknown')}"
|
|
),
|
|
)
|
|
|
|
# Full parse/validation pipeline shared with the manual + wizard
|
|
# flows: invalid PEM, bad chain and already-expired certs all 400.
|
|
payload = SimpleNamespace(
|
|
name=effective_name,
|
|
certificate_content=imp.certificate_content,
|
|
private_key_content=stored_key,
|
|
chain_content=getattr(imp, 'chain_content', None),
|
|
usage_type=getattr(imp, 'usage_type', 'frontend') or 'frontend',
|
|
)
|
|
fields = ssl_service._prepare_cert_fields(payload)
|
|
|
|
# Global name uniqueness (ssl_certificates.cluster_id is always NULL
|
|
# under the R38 schema, so name is effectively a global namespace).
|
|
existing = await conn.fetchrow(
|
|
"SELECT id, is_active FROM ssl_certificates WHERE name = $1 LIMIT 1",
|
|
effective_name,
|
|
)
|
|
if existing and existing['is_active']:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"An active SSL certificate named '{effective_name}' "
|
|
"already exists (created after this CSR). Delete or "
|
|
"rename it, or pass a different `name` in the import "
|
|
"request — the CSR stays pending and can be re-imported."
|
|
),
|
|
)
|
|
|
|
reactivated = False
|
|
if existing and not existing['is_active']:
|
|
# Reactivate the soft-deleted row (mirrors create_cert_row):
|
|
# preserves the row id so historical references keep working.
|
|
await conn.execute(
|
|
"DELETE FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1",
|
|
existing['id'],
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
UPDATE ssl_certificates
|
|
SET is_active = TRUE,
|
|
last_config_status = 'PENDING',
|
|
certificate_content = $2,
|
|
private_key_content = $3,
|
|
chain_content = $4,
|
|
primary_domain = $5,
|
|
all_domains = $6::jsonb,
|
|
expiry_date = $7,
|
|
usage_type = $8,
|
|
issuer = $9,
|
|
fingerprint = $10,
|
|
status = $11,
|
|
days_until_expiry = $12,
|
|
source = 'csr',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""",
|
|
existing['id'],
|
|
fields['cert_content'],
|
|
fields['private_key_content'],
|
|
fields['chain_content'],
|
|
fields['primary_domain'],
|
|
json.dumps(fields['all_domains']),
|
|
fields['expiry_date'],
|
|
fields['usage_type'],
|
|
fields['issuer'],
|
|
fields['fingerprint'],
|
|
fields['status'],
|
|
fields['days_until_expiry'],
|
|
)
|
|
cert_id = existing['id']
|
|
reactivated = True
|
|
logger.info(
|
|
f"csr_service.import_signed_certificate: reactivated "
|
|
f"soft-deleted cert '{effective_name}' (id={cert_id}) for CSR {csr_id}"
|
|
)
|
|
else:
|
|
cert_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO ssl_certificates (
|
|
name, primary_domain, certificate_content, private_key_content,
|
|
chain_content, expiry_date, issuer, fingerprint, status,
|
|
days_until_expiry, all_domains, is_active, cluster_id,
|
|
last_config_status, usage_type, source
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
|
|
TRUE, NULL, 'PENDING', $12, 'csr'
|
|
)
|
|
RETURNING id
|
|
""",
|
|
effective_name,
|
|
fields['primary_domain'],
|
|
fields['cert_content'],
|
|
fields['private_key_content'],
|
|
fields['chain_content'],
|
|
fields['expiry_date'],
|
|
fields['issuer'],
|
|
fields['fingerprint'],
|
|
fields['status'],
|
|
fields['days_until_expiry'],
|
|
json.dumps(fields['all_domains']),
|
|
fields['usage_type'],
|
|
)
|
|
|
|
# Cluster bindings: global = zero junction rows (existing convention).
|
|
if not getattr(imp, 'is_global', False):
|
|
for cluster_id in (getattr(imp, 'cluster_ids', None) or []):
|
|
await ssl_service.ensure_cluster_junction(conn, cert_id, cluster_id)
|
|
|
|
# Complete the CSR and destroy the key copy — the key now lives on
|
|
# the certificate row only, like every other key in the system.
|
|
await conn.execute(
|
|
"""
|
|
UPDATE ssl_csrs
|
|
SET status = 'completed',
|
|
ssl_certificate_id = $2,
|
|
private_key_pem = NULL,
|
|
completed_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""",
|
|
csr_id,
|
|
cert_id,
|
|
)
|
|
|
|
warnings = diff_domains(_maybe_json_list(row['sans']), fields['all_domains'])
|
|
if reactivated:
|
|
warnings.append(
|
|
f"A soft-deleted certificate named '{effective_name}' was "
|
|
f"reactivated (row id {cert_id}) — existing entities that still "
|
|
"reference that id now serve the newly imported certificate."
|
|
)
|
|
|
|
return {
|
|
'certificate_id': cert_id,
|
|
'certificate_name': effective_name,
|
|
'primary_domain': fields['primary_domain'],
|
|
'warnings': warnings,
|
|
'reactivated': reactivated,
|
|
}
|