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
+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]