From dab4b9e0bb49fa78b0e4275973d523ac8dc68eb8 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sat, 24 Oct 2026 22:03:02 +0000 Subject: [PATCH] Fix cross-vault OCSP snapshot poisoning --- README.md | 8 +-- ocsp-responder/README.md | 20 +++++- ocsp-responder/responder.example.yaml | 3 + ocsp-responder/src/cache.rs | 9 ++- ocsp-responder/src/config.rs | 22 +++++- ocsp-responder/src/issuer.rs | 5 +- ocsp-responder/src/snapshot.rs | 30 +++++++-- server/packages/kca-app/src/kca/ocsp.py | 8 +-- server/tests/test_routes_extra.py | 90 ++++++++++++++++++++++++- 9 files changed, 172 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 874fa20..f365119 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ only as SHA-256 digests, and cannot mint/revoke tokens. KCA can publish full per-CA certificate-status snapshots to any S3-compatible bucket and ships a standalone Rust/Axum responder on port `2560`. -- KCA publishes `ocsp/.json` whenever certificates are - issued, renewed, revoked, deleted or imported. Revocation reasons are +- KCA publishes `ocsp/.json` whenever certificates are issued, + renewed, revoked, deleted or imported. Revocation reasons are included; this is a full status snapshot, not a revoked-only list. - The responder has no Postgres/Redis/KCA master-key access. It only reads the configured S3 prefix and uses a delegated OCSP signing certificate/key per @@ -166,8 +166,8 @@ bucket and ships a standalone Rust/Axum responder on port `2560`. Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other `KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a -configuration derived from `ocsp-responder/responder.example.yaml`, or with -Compose: +configuration derived from `ocsp-responder/responder.example.yaml`, setting +each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose: ```sh docker compose --profile ocsp up --build diff --git a/ocsp-responder/README.md b/ocsp-responder/README.md index 138e1aa..7859c48 100644 --- a/ocsp-responder/README.md +++ b/ocsp-responder/README.md @@ -8,7 +8,7 @@ It has **no KCA database access**, no Redis access, and no KCA master key. It only needs: - `s3:GetObject` on the configured bucket/prefix; -- the CA certificate for each issuer; +- the CA certificate and its KCA CA UUID for each issuer; - a delegated OCSP signer certificate and private key for each CA. The responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8, PKCS#1 RSA, or SEC1 EC PEM form. @@ -18,16 +18,30 @@ only needs: KCA publishes objects as: ``` -/.json +/.json ``` +Set each issuer's required `ca_id` to the UUID of its CA certificate row in KCA. +This isolates snapshots when the same public CA certificate exists in multiple +vaults. The responder validates both this ID and the CA certificate fingerprint +against every downloaded snapshot. + KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON contains all certificates issued by that CA, both valid and revoked. The responder never infers status from a partial revoked-only list. +## Upgrading from fingerprint-named snapshots + +Deploy the updated responder with `ca_id` before or together with the updated +KCA publisher. The responder deliberately does not fall back to legacy +`/.json` objects. Until KCA publishes the new +objects, OCSP requests return `tryLater`. After verifying the new snapshots, +the obsolete fingerprint-named objects can be removed. + ## Run locally -1. Create a config based on `responder.example.yaml`. +1. Create a config based on `responder.example.yaml`, setting each issuer's + `ca_id` to its CA certificate UUID in KCA. 2. Put your CA certificates and delegated signer files where the config points. 3. Export standard AWS credentials / endpoint variables as needed. 4. Make the signer key readable by the container user (`65532:65532` in the diff --git a/ocsp-responder/responder.example.yaml b/ocsp-responder/responder.example.yaml index cdf8792..46b337c 100644 --- a/ocsp-responder/responder.example.yaml +++ b/ocsp-responder/responder.example.yaml @@ -15,10 +15,13 @@ s3: issuers: - name: Example Root CA + # UUID of this CA's certificate row in KCA (not its fingerprint). + ca_id: 00000000-0000-0000-0000-000000000000 ca_cert: /etc/ocsp/ca/root.pem signer_cert: /etc/ocsp/signers/root.pem signer_key: /etc/ocsp/signers/root.key # - name: Example Intermediate CA + # ca_id: 00000000-0000-0000-0000-000000000000 # ca_cert: /etc/ocsp/ca/intermediate.pem # signer_cert: /etc/ocsp/signers/intermediate.pem # signer_key: /etc/ocsp/signers/intermediate.key diff --git a/ocsp-responder/src/cache.rs b/ocsp-responder/src/cache.rs index a6277ef..b80b808 100644 --- a/ocsp-responder/src/cache.rs +++ b/ocsp-responder/src/cache.rs @@ -33,6 +33,7 @@ impl SnapshotCache { store: &S, key: &str, expected_sha256: &str, + expected_ca_id: &str, ttl: Duration, stale_grace: Duration, ) -> Result, CacheError> { @@ -58,7 +59,7 @@ impl SnapshotCache { }; match store.get(key).await { - Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256) { + Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) { Ok(snapshot) => { let snapshot = Arc::new(snapshot); *self.cached.write().await = Some(CachedSnapshot { @@ -135,7 +136,7 @@ mod tests { fn body(sha: &str) -> String { format!( - r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"# + r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"# ) } @@ -149,6 +150,7 @@ mod tests { &store, "key", &sha, + "ca", Duration::from_millis(100), Duration::from_secs(1) ), @@ -156,6 +158,7 @@ mod tests { &store, "key", &sha, + "ca", Duration::from_millis(100), Duration::from_secs(1) ) @@ -175,6 +178,7 @@ mod tests { &store, "key", &sha, + "ca", Duration::from_millis(20), Duration::from_secs(1), ) @@ -187,6 +191,7 @@ mod tests { &store, "key", &sha, + "ca", Duration::from_millis(20), Duration::from_secs(1), ) diff --git a/ocsp-responder/src/config.rs b/ocsp-responder/src/config.rs index bb8be89..88852b8 100644 --- a/ocsp-responder/src/config.rs +++ b/ocsp-responder/src/config.rs @@ -20,6 +20,7 @@ pub struct Config { #[derive(Debug, Clone, Deserialize)] pub struct IssuerConfig { pub name: String, + pub ca_id: String, pub ca_cert: PathBuf, pub signer_cert: PathBuf, pub signer_key: PathBuf, @@ -55,9 +56,9 @@ impl Default for S3Config { } impl S3Config { - pub fn object_key(&self, ca_fingerprint_sha256: &str) -> String { + pub fn object_key(&self, ca_id: &str) -> String { let prefix = self.prefix.trim_matches('/'); - let name = format!("{}.json", ca_fingerprint_sha256.to_lowercase()); + let name = format!("{}.json", ca_id.to_lowercase()); if prefix.is_empty() { name } else { @@ -104,3 +105,20 @@ pub fn load_config() -> Result> } Ok(config) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_key_uses_ca_id() { + let config = S3Config { + prefix: "ocsp".to_string(), + ..Default::default() + }; + assert_eq!( + config.object_key("123E4567-E89B-12D3-A456-426614174000"), + "ocsp/123e4567-e89b-12d3-a456-426614174000.json" + ); + } +} diff --git a/ocsp-responder/src/issuer.rs b/ocsp-responder/src/issuer.rs index fc675c4..6a21488 100644 --- a/ocsp-responder/src/issuer.rs +++ b/ocsp-responder/src/issuer.rs @@ -18,6 +18,7 @@ use crate::snapshot::{hash_sha1, hash_sha256, sha256_hex}; pub struct Issuer { pub name: String, + pub ca_id: String, pub signer_cert: Certificate, pub signer: Mutex, pub fingerprint: String, @@ -85,6 +86,7 @@ impl Issuer { Ok(Arc::new(Self { name: config.name.clone(), + ca_id: config.ca_id.clone(), signer_cert, signer: Mutex::new(signer), fingerprint, @@ -111,12 +113,13 @@ impl Issuer { store: &S, config: &crate::config::S3Config, ) -> Result, crate::cache::CacheError> { - let key = config.object_key(&self.fingerprint); + let key = config.object_key(&self.ca_id); self.cache .get( store, &key, &self.fingerprint, + &self.ca_id, Duration::from_millis(config.cache_ttl_ms), Duration::from_secs(config.stale_grace_seconds), ) diff --git a/ocsp-responder/src/snapshot.rs b/ocsp-responder/src/snapshot.rs index 9222960..a424aab 100644 --- a/ocsp-responder/src/snapshot.rs +++ b/ocsp-responder/src/snapshot.rs @@ -38,6 +38,8 @@ struct RawSnapshot { #[derive(Debug, Deserialize)] struct RawIssuer { + #[serde(default)] + id: String, #[serde(default)] sha256: String, } @@ -53,12 +55,22 @@ struct RawEntry { } impl Snapshot { - /// Parse and validate a snapshot against the configured CA fingerprint. - pub fn parse(bytes: &[u8], expected_sha256: &str) -> Result { + /// Parse and validate a snapshot against the configured CA identity. + pub fn parse( + bytes: &[u8], + expected_sha256: &str, + expected_ca_id: &str, + ) -> Result { let raw: RawSnapshot = serde_json::from_slice(bytes)?; if raw.schema != SCHEMA { return Err(SnapshotError::Schema(raw.schema)); } + if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) { + return Err(SnapshotError::IssuerIdMismatch { + expected: expected_ca_id.to_string(), + actual: raw.issuer.id, + }); + } if !raw.issuer.sha256.eq_ignore_ascii_case(expected_sha256) { return Err(SnapshotError::IssuerMismatch { expected: expected_sha256.to_string(), @@ -158,6 +170,8 @@ pub enum SnapshotError { Json(#[from] serde_json::Error), #[error("unsupported snapshot schema {0:?}")] Schema(String), + #[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")] + IssuerIdMismatch { expected: String, actual: String }, #[error("snapshot issuer {actual} does not match configured CA {expected}")] IssuerMismatch { expected: String, actual: String }, #[error("invalid serial {0:?}")] @@ -191,7 +205,7 @@ mod tests { #[test] fn parses_statuses_and_reasons() { let sha = sha256_hex(b"ca"); - let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha).unwrap(); + let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha, "ca").unwrap(); let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap(); assert_eq!(good.status, Status::Good); let revoked = snapshot.status_for_serial(&[0xff]).unwrap(); @@ -203,11 +217,15 @@ mod tests { #[test] fn rejects_wrong_issuer_schema_and_duplicates() { let sha = sha256_hex(b"ca"); - assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong").is_err()); + assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong", "ca").is_err()); + assert!(matches!( + Snapshot::parse(body(&sha).as_bytes(), &sha, "other"), + Err(SnapshotError::IssuerIdMismatch { .. }) + )); let wrong_schema = body(&sha).replace(SCHEMA, "other"); - assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha).is_err()); + assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha, "ca").is_err()); let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\""); - assert!(Snapshot::parse(duplicate.as_bytes(), &sha).is_err()); + assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err()); } #[test] diff --git a/server/packages/kca-app/src/kca/ocsp.py b/server/packages/kca-app/src/kca/ocsp.py index 9e2a43f..f808f1e 100644 --- a/server/packages/kca-app/src/kca/ocsp.py +++ b/server/packages/kca-app/src/kca/ocsp.py @@ -32,10 +32,10 @@ def _iso(value: Optional[datetime]) -> Optional[str]: return value.astimezone(timezone.utc).isoformat() -def object_key(ca_fingerprint_sha256: str) -> str: - """The S3 key for one CA; the responder derives the same key.""" +def object_key(ca_id: str) -> str: + """The S3 key for one CA database row; the responder uses the same CA ID.""" prefix = settings.ocsp_s3_prefix.strip("/") - name = f"{ca_fingerprint_sha256.lower()}.json" + name = f"{ca_id.lower()}.json" return f"{prefix}/{name}" if prefix else name @@ -104,7 +104,7 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = if snapshot is None: snapshot = await snapshot_for_ca(ca) body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8") - key = object_key(ca.fingerprint_sha256) + key = object_key(str(ca.id)) # boto3 is synchronous and credential resolution can block; do both the # client creation and the PUT outside the event loop. diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index 386346d..401ea05 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -496,8 +496,96 @@ class OcspSnapshotTests(unittest.TestCase): assert len(fake.calls) == 1 call = fake.calls[0] assert call["Bucket"] == "test-bucket" - assert call["Key"] == f"ocsp/{ca['fingerprint_sha256']}.json" + assert call["Key"] == f"ocsp/{ca['id']}.json" assert b"kca-ocsp-status/v1" in call["Body"] row = await OcspSnapshot.get(ca_id=ca["id"]) assert row.dirty is False assert row.published_at is not None + + @async_test + async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None: + import json + from dataclasses import replace + from unittest.mock import patch + + from kca import ocsp + from kca.models import Certificate, OcspSnapshot + + class FakeS3: + def __init__(self): + self.objects = {} + + def put_object(self, **kwargs): + self.objects[kwargs["Key"]] = kwargs["Body"] + + fake = FakeS3() + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "target ca") + ca = await _cert(client, vault_id, ca_key["id"], "Target CA", ca=True) + leaf_key = await _key(client, vault_id, "target leaf") + leaf = await _cert( + client, vault_id, leaf_key["id"], "Target Leaf", signer_id=ca["id"] + ) + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", + json={"reason": "keyCompromise"}, + ) + assert response.status_code == 200, response.text + exported = await client.post( + f"/api/vaults/{vault_id}/certificates/{ca['id']}/export", + json={"format": "pem"}, + ) + assert exported.status_code == 200, exported.text + public_ca = exported.json()["content_base64"] + + configured = replace( + ocsp.settings, + ocsp_s3_bucket="test-bucket", + ocsp_s3_prefix="ocsp", + ) + with patch.object(ocsp, "settings", configured), patch.object( + ocsp, "_s3_client", lambda: fake + ): + target = await Certificate.get(id=ca["id"]) + target_key = await ocsp.publish_snapshot(target) + + with oidc_user("bob"): + attacker_vault_id = await _vault(client) + payload = {"filename": "target-ca.crt", "content_base64": public_ca} + response = await client.post( + f"/api/vaults/{attacker_vault_id}/import", json=payload + ) + assert response.status_code == 200, response.text + assert len(response.json()["imported"]) == 1 + imported = await Certificate.get( + vault_id=attacker_vault_id, + fingerprint_sha256=ca["fingerprint_sha256"], + ) + assert imported.id != target.id + assert imported.fingerprint_sha256 == target.fingerprint_sha256 + + row = await OcspSnapshot.get(ca_id=imported.id) + assert row.dirty is True + row.dirty = False + await row.save(update_fields=["dirty", "updated_at"]) + response = await client.post( + f"/api/vaults/{attacker_vault_id}/import", json=payload + ) + assert response.status_code == 200, response.text + assert len(response.json()["skipped"]) == 1 + row = await OcspSnapshot.get(ca_id=imported.id) + assert row.dirty is True + imported_key = await ocsp.publish_snapshot(imported) + + assert target_key == f"ocsp/{ca['id']}.json" + assert imported_key == f"ocsp/{imported.id}.json" + assert imported_key != target_key + assert set(fake.objects) == {target_key, imported_key} + target_snapshot = json.loads(fake.objects[target_key]) + target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]} + assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked" + imported_snapshot = json.loads(fake.objects[imported_key]) + imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]} + assert leaf["serial_hex"].lower() not in imported_serials