Serialize OCSP Redis snapshots with CBOR

This commit is contained in:
woggioni committed 2026-10-28 20:54:19 +00:00
1 parent df98ada177
commit ef64c4a532
12 files changed
+191 -56

No files matched your search

+4 -10
View File
@@ -135,11 +135,11 @@ mod tests {
use bytes::Bytes;
use super::*;
use crate::snapshot::sha256_hex;
use crate::snapshot::{sha256_hex, test_body as body};
use crate::store::{ObjectStore, StoreError, StoredSnapshot};
struct FakeStore {
body: String,
body: Vec<u8>,
expires_at: SystemTime,
calls: AtomicUsize,
fail: AtomicBool,
@@ -147,7 +147,7 @@ mod tests {
}
impl FakeStore {
fn new(body: String, lifetime: Duration) -> Self {
fn new(body: Vec<u8>, lifetime: Duration) -> Self {
Self {
body,
expires_at: SystemTime::now() + lifetime,
@@ -157,7 +157,7 @@ mod tests {
}
}
fn expired(body: String) -> Self {
fn expired(body: Vec<u8>) -> Self {
Self {
body,
expires_at: SystemTime::now() - Duration::from_secs(1),
@@ -185,12 +185,6 @@ mod tests {
}
}
fn body(sha: &str) -> String {
format!(
r#"{{"schema":"kca-ocsp-status/v2","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"#
)
}
#[tokio::test]
async fn single_flight_refreshes_once() {
let sha = sha256_hex(b"ca");
+77 -25
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha1::{Digest as _, Sha1};
use sha2::Sha256;
use x509_cert::ext::pkix::CrlReason;
@@ -28,7 +28,7 @@ pub struct Snapshot {
pub entries: HashMap<Vec<u8>, StatusEntry>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
struct RawSnapshot {
schema: String,
issuer: RawIssuer,
@@ -36,7 +36,7 @@ struct RawSnapshot {
entries: Vec<RawEntry>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
struct RawIssuer {
#[serde(default)]
id: String,
@@ -44,7 +44,7 @@ struct RawIssuer {
sha256: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
struct RawEntry {
serial_hex: String,
status: String,
@@ -61,7 +61,9 @@ impl Snapshot {
expected_sha256: &str,
expected_ca_id: &str,
) -> Result<Self, SnapshotError> {
let raw: RawSnapshot = serde_json::from_slice(bytes)?;
cbor2::validate_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?;
let raw: RawSnapshot =
cbor2::from_slice(bytes).map_err(|error| SnapshotError::Cbor(error.to_string()))?;
if raw.schema != SCHEMA {
return Err(SnapshotError::Schema(raw.schema));
}
@@ -166,8 +168,8 @@ pub fn hash_sha256(bytes: &[u8]) -> Vec<u8> {
#[derive(Debug, thiserror::Error)]
pub enum SnapshotError {
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid CBOR: {0}")]
Cbor(String),
#[error("unsupported snapshot schema {0:?}")]
Schema(String),
#[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")]
@@ -184,27 +186,64 @@ pub enum SnapshotError {
DuplicateSerial(String),
}
#[cfg(test)]
pub(crate) fn test_body(sha: &str) -> Vec<u8> {
let raw = RawSnapshot {
schema: SCHEMA.to_string(),
issuer: RawIssuer {
id: "ca".to_string(),
sha256: sha.to_string(),
},
entries: vec![RawEntry {
serial_hex: "01".to_string(),
status: "valid".to_string(),
revoked_at: None,
reason: None,
}],
};
cbor2::to_vec(&raw).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
fn body(sha: &str) -> String {
format!(
r#"{{
"schema": "kca-ocsp-status/v2",
"issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}},
"entries": [
{{"serial_hex": "01ab", "status": "valid", "not_after": "2027-01-01T00:00:00Z"}},
{{"serial_hex": "00ff", "status": "revoked", "revoked_at": "2026-02-03T04:05:06Z", "reason": "keyCompromise"}}
]
}}"#
)
fn raw_body(sha: &str, schema: &str) -> RawSnapshot {
RawSnapshot {
schema: schema.to_string(),
issuer: RawIssuer {
id: "ca".to_string(),
sha256: sha.to_string(),
},
entries: vec![
RawEntry {
serial_hex: "01ab".to_string(),
status: "valid".to_string(),
revoked_at: None,
reason: None,
},
RawEntry {
serial_hex: "00ff".to_string(),
status: "revoked".to_string(),
revoked_at: Some(
DateTime::parse_from_rfc3339("2026-02-03T04:05:06Z")
.unwrap()
.with_timezone(&Utc),
),
reason: Some("keyCompromise".to_string()),
},
],
}
}
fn body(sha: &str) -> Vec<u8> {
cbor2::to_vec(&raw_body(sha, SCHEMA)).unwrap()
}
#[test]
fn parses_statuses_and_reasons() {
let sha = sha256_hex(b"ca");
let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha, "ca").unwrap();
let snapshot = Snapshot::parse(&body(&sha), &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();
@@ -216,15 +255,28 @@ mod tests {
#[test]
fn rejects_wrong_issuer_schema_and_duplicates() {
let sha = sha256_hex(b"ca");
assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong", "ca").is_err());
assert!(Snapshot::parse(&body(&sha), "wrong", "ca").is_err());
assert!(matches!(
Snapshot::parse(body(&sha).as_bytes(), &sha, "other"),
Snapshot::parse(&body(&sha), &sha, "other"),
Err(SnapshotError::IssuerIdMismatch { .. })
));
let wrong_schema = body(&sha).replace(SCHEMA, "other");
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, "ca").is_err());
let wrong_schema = cbor2::to_vec(&raw_body(&sha, "other")).unwrap();
assert!(Snapshot::parse(&wrong_schema, &sha, "ca").is_err());
let mut duplicate = raw_body(&sha, SCHEMA);
duplicate.entries[1].serial_hex = duplicate.entries[0].serial_hex.clone();
let duplicate = cbor2::to_vec(&duplicate).unwrap();
assert!(Snapshot::parse(&duplicate, &sha, "ca").is_err());
}
#[test]
fn rejects_legacy_json() {
let sha = sha256_hex(b"ca");
let legacy =
br#"{"schema":"kca-ocsp-status/v2","issuer":{"id":"ca","sha256":"sha"},"entries":[]}"#;
assert!(matches!(
Snapshot::parse(legacy, &sha, "ca"),
Err(SnapshotError::Cbor(_))
));
}
#[test]
+5 -3
View File
@@ -133,13 +133,14 @@ mod tests {
prefix: format!("ocsp:test:{}", std::process::id()),
..Default::default()
};
let key = config.key("round-trip");
let key = config.key("ca");
let sha = crate::snapshot::sha256_hex(b"ca");
let store = StatusStore::new(&config).await.unwrap();
let client = redis::Client::open(config.url.as_str()).unwrap();
let mut connection = client.get_connection_manager().await.unwrap();
let _: () = redis::cmd("SET")
.arg(&key)
.arg("snapshot")
.arg(crate::snapshot::test_body(&sha))
.arg("EX")
.arg(30)
.query_async(&mut connection)
@@ -149,7 +150,8 @@ mod tests {
let stored = store.get(&key).await.unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
let snapshot = crate::snapshot::Snapshot::parse(&body, &sha, "ca").unwrap();
assert_eq!(snapshot.entries.len(), 1);
assert!(expires_at > SystemTime::now());
}
StoredSnapshot::Missing => panic!("snapshot should be present"),