Fix cross-vault OCSP snapshot poisoning
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user