Fix cross-vault OCSP snapshot poisoning

This commit is contained in:
2026-10-24 22:03:02 +00:00
parent 69d78939a1
commit dab4b9e0bb
9 changed files with 172 additions and 23 deletions
+4 -4
View File
@@ -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 KCA can publish full per-CA certificate-status snapshots to any S3-compatible
bucket and ships a standalone Rust/Axum responder on port `2560`. bucket and ships a standalone Rust/Axum responder on port `2560`.
- KCA publishes `ocsp/<CA certificate SHA-256>.json` whenever certificates are - KCA publishes `ocsp/<KCA CA UUID>.json` whenever certificates are issued,
issued, renewed, revoked, deleted or imported. Revocation reasons are renewed, revoked, deleted or imported. Revocation reasons are
included; this is a full status snapshot, not a revoked-only list. 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 - 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 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 Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a `KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
configuration derived from `ocsp-responder/responder.example.yaml`, or with configuration derived from `ocsp-responder/responder.example.yaml`, setting
Compose: each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose:
```sh ```sh
docker compose --profile ocsp up --build docker compose --profile ocsp up --build
+17 -3
View File
@@ -8,7 +8,7 @@ It has **no KCA database access**, no Redis access, and no KCA master key. It
only needs: only needs:
- `s3:GetObject` on the configured bucket/prefix; - `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 - 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, responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
PKCS#1 RSA, or SEC1 EC PEM form. PKCS#1 RSA, or SEC1 EC PEM form.
@@ -18,16 +18,30 @@ only needs:
KCA publishes objects as: KCA publishes objects as:
``` ```
<prefix>/<CA certificate SHA-256>.json <prefix>/<KCA CA UUID>.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 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 contains all certificates issued by that CA, both valid and revoked. The
responder never infers status from a partial revoked-only list. 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
`<prefix>/<CA certificate SHA-256>.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 ## 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. 2. Put your CA certificates and delegated signer files where the config points.
3. Export standard AWS credentials / endpoint variables as needed. 3. Export standard AWS credentials / endpoint variables as needed.
4. Make the signer key readable by the container user (`65532:65532` in the 4. Make the signer key readable by the container user (`65532:65532` in the
+3
View File
@@ -15,10 +15,13 @@ s3:
issuers: issuers:
- name: Example Root CA - 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 ca_cert: /etc/ocsp/ca/root.pem
signer_cert: /etc/ocsp/signers/root.pem signer_cert: /etc/ocsp/signers/root.pem
signer_key: /etc/ocsp/signers/root.key signer_key: /etc/ocsp/signers/root.key
# - name: Example Intermediate CA # - name: Example Intermediate CA
# ca_id: 00000000-0000-0000-0000-000000000000
# ca_cert: /etc/ocsp/ca/intermediate.pem # ca_cert: /etc/ocsp/ca/intermediate.pem
# signer_cert: /etc/ocsp/signers/intermediate.pem # signer_cert: /etc/ocsp/signers/intermediate.pem
# signer_key: /etc/ocsp/signers/intermediate.key # signer_key: /etc/ocsp/signers/intermediate.key
+7 -2
View File
@@ -33,6 +33,7 @@ impl SnapshotCache {
store: &S, store: &S,
key: &str, key: &str,
expected_sha256: &str, expected_sha256: &str,
expected_ca_id: &str,
ttl: Duration, ttl: Duration,
stale_grace: Duration, stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> { ) -> Result<Arc<Snapshot>, CacheError> {
@@ -58,7 +59,7 @@ impl SnapshotCache {
}; };
match store.get(key).await { 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) => { Ok(snapshot) => {
let snapshot = Arc::new(snapshot); let snapshot = Arc::new(snapshot);
*self.cached.write().await = Some(CachedSnapshot { *self.cached.write().await = Some(CachedSnapshot {
@@ -135,7 +136,7 @@ mod tests {
fn body(sha: &str) -> String { fn body(sha: &str) -> String {
format!( 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, &store,
"key", "key",
&sha, &sha,
"ca",
Duration::from_millis(100), Duration::from_millis(100),
Duration::from_secs(1) Duration::from_secs(1)
), ),
@@ -156,6 +158,7 @@ mod tests {
&store, &store,
"key", "key",
&sha, &sha,
"ca",
Duration::from_millis(100), Duration::from_millis(100),
Duration::from_secs(1) Duration::from_secs(1)
) )
@@ -175,6 +178,7 @@ mod tests {
&store, &store,
"key", "key",
&sha, &sha,
"ca",
Duration::from_millis(20), Duration::from_millis(20),
Duration::from_secs(1), Duration::from_secs(1),
) )
@@ -187,6 +191,7 @@ mod tests {
&store, &store,
"key", "key",
&sha, &sha,
"ca",
Duration::from_millis(20), Duration::from_millis(20),
Duration::from_secs(1), Duration::from_secs(1),
) )
+20 -2
View File
@@ -20,6 +20,7 @@ pub struct Config {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct IssuerConfig { pub struct IssuerConfig {
pub name: String, pub name: String,
pub ca_id: String,
pub ca_cert: PathBuf, pub ca_cert: PathBuf,
pub signer_cert: PathBuf, pub signer_cert: PathBuf,
pub signer_key: PathBuf, pub signer_key: PathBuf,
@@ -55,9 +56,9 @@ impl Default for S3Config {
} }
impl 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 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() { if prefix.is_empty() {
name name
} else { } else {
@@ -104,3 +105,20 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
} }
Ok(config) 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"
);
}
}
+4 -1
View File
@@ -18,6 +18,7 @@ use crate::snapshot::{hash_sha1, hash_sha256, sha256_hex};
pub struct Issuer { pub struct Issuer {
pub name: String, pub name: String,
pub ca_id: String,
pub signer_cert: Certificate, pub signer_cert: Certificate,
pub signer: Mutex<SignerKey>, pub signer: Mutex<SignerKey>,
pub fingerprint: String, pub fingerprint: String,
@@ -85,6 +86,7 @@ impl Issuer {
Ok(Arc::new(Self { Ok(Arc::new(Self {
name: config.name.clone(), name: config.name.clone(),
ca_id: config.ca_id.clone(),
signer_cert, signer_cert,
signer: Mutex::new(signer), signer: Mutex::new(signer),
fingerprint, fingerprint,
@@ -111,12 +113,13 @@ impl Issuer {
store: &S, store: &S,
config: &crate::config::S3Config, config: &crate::config::S3Config,
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> { ) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
let key = config.object_key(&self.fingerprint); let key = config.object_key(&self.ca_id);
self.cache self.cache
.get( .get(
store, store,
&key, &key,
&self.fingerprint, &self.fingerprint,
&self.ca_id,
Duration::from_millis(config.cache_ttl_ms), Duration::from_millis(config.cache_ttl_ms),
Duration::from_secs(config.stale_grace_seconds), Duration::from_secs(config.stale_grace_seconds),
) )
+24 -6
View File
@@ -38,6 +38,8 @@ struct RawSnapshot {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RawIssuer { struct RawIssuer {
#[serde(default)]
id: String,
#[serde(default)] #[serde(default)]
sha256: String, sha256: String,
} }
@@ -53,12 +55,22 @@ struct RawEntry {
} }
impl Snapshot { impl Snapshot {
/// Parse and validate a snapshot against the configured CA fingerprint. /// Parse and validate a snapshot against the configured CA identity.
pub fn parse(bytes: &[u8], expected_sha256: &str) -> Result<Self, SnapshotError> { pub fn parse(
bytes: &[u8],
expected_sha256: &str,
expected_ca_id: &str,
) -> Result<Self, SnapshotError> {
let raw: RawSnapshot = serde_json::from_slice(bytes)?; let raw: RawSnapshot = serde_json::from_slice(bytes)?;
if raw.schema != SCHEMA { if raw.schema != SCHEMA {
return Err(SnapshotError::Schema(raw.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) { if !raw.issuer.sha256.eq_ignore_ascii_case(expected_sha256) {
return Err(SnapshotError::IssuerMismatch { return Err(SnapshotError::IssuerMismatch {
expected: expected_sha256.to_string(), expected: expected_sha256.to_string(),
@@ -158,6 +170,8 @@ pub enum SnapshotError {
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("unsupported snapshot schema {0:?}")] #[error("unsupported snapshot schema {0:?}")]
Schema(String), 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}")] #[error("snapshot issuer {actual} does not match configured CA {expected}")]
IssuerMismatch { expected: String, actual: String }, IssuerMismatch { expected: String, actual: String },
#[error("invalid serial {0:?}")] #[error("invalid serial {0:?}")]
@@ -191,7 +205,7 @@ mod tests {
#[test] #[test]
fn parses_statuses_and_reasons() { fn parses_statuses_and_reasons() {
let sha = sha256_hex(b"ca"); 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(); let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
assert_eq!(good.status, Status::Good); assert_eq!(good.status, Status::Good);
let revoked = snapshot.status_for_serial(&[0xff]).unwrap(); let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
@@ -203,11 +217,15 @@ mod tests {
#[test] #[test]
fn rejects_wrong_issuer_schema_and_duplicates() { fn rejects_wrong_issuer_schema_and_duplicates() {
let sha = sha256_hex(b"ca"); 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"); 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\""); 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] #[test]
+4 -4
View File
@@ -32,10 +32,10 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
return value.astimezone(timezone.utc).isoformat() return value.astimezone(timezone.utc).isoformat()
def object_key(ca_fingerprint_sha256: str) -> str: def object_key(ca_id: str) -> str:
"""The S3 key for one CA; the responder derives the same key.""" """The S3 key for one CA database row; the responder uses the same CA ID."""
prefix = settings.ocsp_s3_prefix.strip("/") 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 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: if snapshot is None:
snapshot = await snapshot_for_ca(ca) snapshot = await snapshot_for_ca(ca)
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8") 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 # boto3 is synchronous and credential resolution can block; do both the
# client creation and the PUT outside the event loop. # client creation and the PUT outside the event loop.
+89 -1
View File
@@ -496,8 +496,96 @@ class OcspSnapshotTests(unittest.TestCase):
assert len(fake.calls) == 1 assert len(fake.calls) == 1
call = fake.calls[0] call = fake.calls[0]
assert call["Bucket"] == "test-bucket" 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"] assert b"kca-ocsp-status/v1" in call["Body"]
row = await OcspSnapshot.get(ca_id=ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False assert row.dirty is False
assert row.published_at is not None 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