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

@@ -621,6 +621,184 @@ class TestPKCS12Creation:
assert loaded_key is not None
class TestCSRSigning:
"""Test POST /csr endpoint for signing Certificate Signing Requests."""
def _generate_csr(self, common_name: str = "test-client") -> str:
"""Generate a test CSR and return PEM string."""
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID
key = ec.generate_private_key(ec.SECP384R1())
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
.sign(key, hashes.SHA256())
)
return csr.public_bytes(Encoding.PEM).decode("utf-8")
def test_csr_requires_pow(self, client, app):
"""CSR signing fails without PoW when difficulty > 0."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 10
csr_pem = self._generate_csr()
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 400
assert "Proof-of-work required" in response.get_json()["error"]
def test_csr_with_pow_disabled_succeeds(self, client, app):
"""CSR signing succeeds without PoW when difficulty is 0."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
csr_pem = self._generate_csr("my-client")
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 200
assert response.content_type == "application/x-pem-file"
assert b"-----BEGIN CERTIFICATE-----" in response.data
assert b"-----END CERTIFICATE-----" in response.data
assert "X-Fingerprint-SHA1" in response.headers
assert len(response.headers["X-Fingerprint-SHA1"]) == 40
def test_csr_auto_generates_ca(self, client, app):
"""CSR signing auto-generates CA if not present."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
# Verify no CA exists
from app.pki import get_ca_info
assert get_ca_info() is None
csr_pem = self._generate_csr()
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 200
# Verify CA now exists
with app.app_context():
from app.pki import get_ca_info
ca_info = get_ca_info()
assert ca_info is not None
assert ca_info["common_name"] == "FlaskPaste CA"
def test_csr_returns_valid_certificate(self, client, app):
"""CSR signing returns certificate signed by CA."""
from cryptography import x509
from cryptography.x509.oid import NameOID
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
csr_pem = self._generate_csr("verified-client")
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 200
# Parse and verify certificate
cert = x509.load_pem_x509_certificate(response.data)
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
assert cn == "verified-client"
# Verify response headers match certificate
from app.pki import calculate_fingerprint
fingerprint = calculate_fingerprint(cert)
assert response.headers["X-Fingerprint-SHA1"] == fingerprint
def test_csr_without_body_fails(self, client, app):
"""CSR signing fails without CSR in body."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
response = client.post("/csr", data="", content_type="application/x-pem-file")
assert response.status_code == 400
assert "CSR required" in response.get_json()["error"]
def test_csr_with_invalid_pem_fails(self, client, app):
"""CSR signing fails with invalid PEM."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
response = client.post(
"/csr",
data="-----BEGIN CERTIFICATE REQUEST-----\ninvalid\n-----END CERTIFICATE REQUEST-----",
content_type="application/x-pem-file",
)
assert response.status_code == 400
assert "Invalid CSR format" in response.get_json()["error"]
def test_csr_without_common_name_fails(self, client, app):
"""CSR signing fails when CSR lacks Common Name."""
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
# Generate CSR without CN
key = ec.generate_private_key(ec.SECP384R1())
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([])) # Empty subject
.sign(key, hashes.SHA256())
)
csr_pem = csr.public_bytes(Encoding.PEM).decode("utf-8")
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 400
assert "Common Name" in response.get_json()["error"]
def test_csr_without_pki_password_fails(self, client, app):
"""CSR signing fails when PKI_CA_PASSWORD not configured."""
with app.app_context():
app.config["PKI_CA_PASSWORD"] = ""
app.config["REGISTER_POW_DIFFICULTY"] = 0
csr_pem = self._generate_csr()
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 503
assert "not available" in response.get_json()["error"]
def test_csr_first_user_is_admin(self, client, app):
"""First CSR-signed certificate gets admin rights."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
csr_pem = self._generate_csr("admin-client")
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 200
assert response.headers["X-Is-Admin"] == "1"
# Second CSR is not admin
csr_pem2 = self._generate_csr("user-client")
response2 = client.post("/csr", data=csr_pem2, content_type="application/x-pem-file")
assert response2.status_code == 200
assert response2.headers["X-Is-Admin"] == "0"
def test_csr_response_headers(self, client, app):
"""CSR signing returns appropriate headers."""
with app.app_context():
app.config["REGISTER_POW_DIFFICULTY"] = 0
csr_pem = self._generate_csr("header-test")
response = client.post("/csr", data=csr_pem, content_type="application/x-pem-file")
assert response.status_code == 200
# Check all expected headers
assert "X-Fingerprint-SHA1" in response.headers
assert "X-Certificate-Expires" in response.headers
assert "X-Certificate-Serial" in response.headers
assert "X-Is-Admin" in response.headers
assert "Content-Disposition" in response.headers
assert "header-test.crt" in response.headers["Content-Disposition"]
class TestAdminPrivileges:
"""Test admin privileges for list/delete operations."""