add /csr endpoint for CSR signing
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Unit Tests (push) Has been skipped
CI / Memory Leak Check (push) Has been skipped
CI / SBOM Generation (push) Has been skipped
CI / Security Scan (push) Successful in 20s
CI / Security Tests (push) Has been skipped
CI / Advanced Security Tests (push) Has been skipped

Allow clients to submit Certificate Signing Requests instead of
having the server generate private keys. Client keeps key local.

- sign_csr() in pki.py validates and signs CSRs
- POST /csr endpoint with PoW protection
- 10 new tests for CSR functionality
- API documentation updated
This commit is contained in:
Username
2025-12-26 21:44:29 +01:00
parent 6da80aec76
commit 30bd02663b
4 changed files with 562 additions and 0 deletions

View File

@@ -863,6 +863,7 @@ class IndexView(MethodView):
f"DELETE {prefixed_url('/<id>')}": "Delete paste (owner only)",
f"GET {prefixed_url('/register/challenge')}": "Get registration challenge",
f"POST {prefixed_url('/register')}": "Register for client certificate",
f"POST {prefixed_url('/csr')}": "Sign CSR (bring your own key)",
}
if pki_enabled:
@@ -1414,6 +1415,121 @@ class RegisterView(MethodView):
return response
class CSRView(MethodView):
"""CSR signing endpoint for client-generated keys."""
def post(self) -> Response:
"""Sign a Certificate Signing Request.
Accepts a PEM-encoded CSR and returns a signed certificate.
Client keeps their private key - only the CSR is submitted.
Requires PoW to prevent abuse.
Request body: PEM-encoded CSR (Content-Type: application/x-pem-file or text/plain)
Returns:
Signed certificate PEM with headers for fingerprint and expiry
"""
from app.pki import (
CANotFoundError,
PKIError,
generate_ca,
get_ca_info,
sign_csr,
)
# Check PKI configuration
password = current_app.config.get("PKI_CA_PASSWORD", "")
if not password:
return error_response(
"CSR signing not available",
503,
hint="PKI_CA_PASSWORD not configured",
)
# Verify PoW (same difficulty as registration)
register_difficulty = current_app.config.get("REGISTER_POW_DIFFICULTY", 24)
if register_difficulty > 0:
token = request.headers.get("X-PoW-Token", "")
solution = request.headers.get("X-PoW-Solution", "")
if not token or not solution:
return error_response(
"Proof-of-work required",
400,
hint="GET /register/challenge for a challenge",
difficulty=register_difficulty,
)
valid, err = verify_pow(token, solution, min_difficulty=register_difficulty)
if not valid:
current_app.logger.warning("CSR PoW failed: %s from=%s", err, request.remote_addr)
return error_response(f"Proof-of-work failed: {err}", 400)
# Get CSR from request body
csr_pem = request.get_data(as_text=True)
if not csr_pem or "BEGIN CERTIFICATE REQUEST" not in csr_pem:
return error_response(
"CSR required",
400,
hint="Submit PEM-encoded CSR in request body",
)
# Auto-generate CA if needed
ca_info = get_ca_info(skip_enabled_check=True)
if ca_info is None:
ca_days = current_app.config.get("PKI_CA_DAYS", 3650)
try:
ca_info = generate_ca("FlaskPaste CA", password, days=ca_days)
current_app.logger.info(
"CA auto-generated for CSR signing: fingerprint=%s",
ca_info["fingerprint_sha1"][:12],
)
except PKIError as e:
current_app.logger.error("CA auto-generation failed: %s", e)
return error_response("CA generation failed", 500)
# Sign CSR
try:
cert_days = current_app.config.get("PKI_CERT_DAYS", 365)
cert_info = sign_csr(csr_pem, password, days=cert_days)
except CANotFoundError:
return error_response("CA not available", 500)
except PKIError as e:
current_app.logger.warning("CSR signing failed: %s from=%s", e, request.remote_addr)
return error_response(f"CSR signing failed: {e}", 400)
current_app.logger.info(
"CSR signed: cn=%s fingerprint=%s from=%s",
cert_info["common_name"],
cert_info["fingerprint_sha1"][:12],
request.remote_addr,
)
log_event(
AuditEvent.CERT_ISSUED,
AuditOutcome.SUCCESS,
client_id=cert_info["fingerprint_sha1"],
client_ip=request.remote_addr,
details={
"type": "csr",
"common_name": cert_info["common_name"],
"expires_at": cert_info["expires_at"],
},
)
# Return certificate as PEM
response = Response(cert_info["certificate_pem"], mimetype="application/x-pem-file")
response.headers["Content-Disposition"] = (
f'attachment; filename="{cert_info["common_name"]}.crt"'
)
response.headers["X-Fingerprint-SHA1"] = cert_info["fingerprint_sha1"]
response.headers["X-Certificate-Expires"] = str(cert_info["expires_at"])
response.headers["X-Certificate-Serial"] = cert_info["serial"]
response.headers["X-Is-Admin"] = "1" if cert_info.get("is_admin") else "0"
return response
class ClientView(MethodView):
"""CLI client download endpoint."""
@@ -2276,6 +2392,7 @@ bp.add_url_rule(
"/register/challenge", view_func=RegisterChallengeView.as_view("register_challenge")
)
bp.add_url_rule("/register", view_func=RegisterView.as_view("register"))
bp.add_url_rule("/csr", view_func=CSRView.as_view("csr"))
# Paste operations
bp.add_url_rule("/pastes", view_func=PastesListView.as_view("pastes_list"))