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
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:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user