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