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
bucket and ships a standalone Rust/Axum responder on port `2560`.
- KCA publishes `ocsp/<CA certificate SHA-256>.json` whenever certificates are
issued, renewed, revoked, deleted or imported. Revocation reasons are
- KCA publishes `ocsp/<KCA CA UUID>.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
+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:
- `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:
```
<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
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
`<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
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
+3
View File
@@ -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
+7 -2
View File
@@ -33,6 +33,7 @@ impl SnapshotCache {
store: &S,
key: &str,
expected_sha256: &str,
expected_ca_id: &str,
ttl: Duration,
stale_grace: Duration,
) -> Result<Arc<Snapshot>, 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),
)
+20 -2
View File
@@ -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<Config, Box<dyn std::error::Error + Send + Sync>>
}
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 name: String,
pub ca_id: String,
pub signer_cert: Certificate,
pub signer: Mutex<SignerKey>,
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<Arc<crate::snapshot::Snapshot>, 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),
)
+24 -6
View File
@@ -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<Self, SnapshotError> {
/// Parse and validate a snapshot against the configured CA identity.
pub fn parse(
bytes: &[u8],
expected_sha256: &str,
expected_ca_id: &str,
) -> Result<Self, SnapshotError> {
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]
+4 -4
View File
@@ -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.
+89 -1
View File
@@ -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