//! Parsed KCA OCSP status snapshots. use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::Deserialize; use sha1::{Digest as _, Sha1}; use sha2::Sha256; use x509_cert::ext::pkix::CrlReason; pub const SCHEMA: &str = "kca-ocsp-status/v2"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Good, Revoked, } #[derive(Debug, Clone)] pub struct StatusEntry { pub status: Status, pub revoked_at: Option>, pub reason: Option, } #[derive(Debug, Clone, Default)] pub struct Snapshot { pub entries: HashMap, StatusEntry>, } #[derive(Debug, Deserialize)] struct RawSnapshot { schema: String, issuer: RawIssuer, #[serde(default)] entries: Vec, } #[derive(Debug, Deserialize)] struct RawIssuer { #[serde(default)] id: String, #[serde(default)] sha256: String, } #[derive(Debug, Deserialize)] struct RawEntry { serial_hex: String, status: String, #[serde(default)] revoked_at: Option>, #[serde(default)] reason: Option, } impl Snapshot { /// Parse and validate a snapshot against the configured CA identity. pub fn parse( bytes: &[u8], expected_sha256: &str, expected_ca_id: &str, ) -> Result { 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(), actual: raw.issuer.sha256, }); } let mut entries = HashMap::with_capacity(raw.entries.len()); for entry in raw.entries { let serial = parse_serial(&entry.serial_hex)?; let status = match entry.status.as_str() { "valid" => Status::Good, "revoked" => Status::Revoked, other => return Err(SnapshotError::Status(other.to_string())), }; if status == Status::Revoked && entry.revoked_at.is_none() { return Err(SnapshotError::MissingRevocationTime(entry.serial_hex)); } let reason = if status == Status::Revoked { Some(parse_reason( entry.reason.as_deref().unwrap_or("unspecified"), )) } else { None }; if entries .insert( serial, StatusEntry { status, revoked_at: entry.revoked_at, reason, }, ) .is_some() { return Err(SnapshotError::DuplicateSerial(entry.serial_hex)); } } let _ = raw.issuer.sha256; Ok(Self { entries }) } pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> { self.entries.get(&normalize_serial(serial)) } } pub fn normalize_serial(serial: &[u8]) -> Vec { let first = serial .iter() .position(|byte| *byte != 0) .unwrap_or(serial.len()); serial[first..].to_vec() } fn parse_serial(value: &str) -> Result, SnapshotError> { let text = value.trim(); let text = text.strip_prefix("0x").unwrap_or(text); let decoded = hex::decode(text).map_err(|_| SnapshotError::Serial(value.to_string()))?; Ok(normalize_serial(&decoded)) } fn parse_reason(value: &str) -> CrlReason { match value.to_ascii_lowercase().as_str() { "keycompromise" => CrlReason::KeyCompromise, "cacompromise" => CrlReason::CaCompromise, "affiliationchanged" => CrlReason::AffiliationChanged, "superseded" => CrlReason::Superseded, "cessationofoperation" => CrlReason::CessationOfOperation, "certificatehold" => CrlReason::CertificateHold, "removefromcrl" => CrlReason::RemoveFromCRL, "privilegewithdrawn" => CrlReason::PrivilegeWithdrawn, "aacompromise" => CrlReason::AaCompromise, _ => CrlReason::Unspecified, } } pub fn sha256_hex(bytes: &[u8]) -> String { use sha2::Digest as _; hex::encode(Sha256::digest(bytes)) } /// OCSP CertID hashes are SHA-1 in the common case, with SHA-256 as an /// alternative. The responder supports both. pub fn hash_sha1(bytes: &[u8]) -> Vec { Sha1::digest(bytes).to_vec() } pub fn hash_sha256(bytes: &[u8]) -> Vec { use sha2::Digest as _; Sha256::digest(bytes).to_vec() } #[derive(Debug, thiserror::Error)] pub enum SnapshotError { #[error("invalid JSON: {0}")] 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:?}")] Serial(String), #[error("unknown status {0:?}")] Status(String), #[error("revoked entry {0:?} has no revocation time")] MissingRevocationTime(String), #[error("duplicate serial {0:?}")] DuplicateSerial(String), } #[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"}} ] }}"# ) } #[test] fn parses_statuses_and_reasons() { let sha = sha256_hex(b"ca"); 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(); assert_eq!(revoked.status, Status::Revoked); assert_eq!(revoked.reason, Some(CrlReason::KeyCompromise)); assert!(revoked.revoked_at.is_some()); } #[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!(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, "ca").is_err()); let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\""); assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err()); } #[test] fn normalizes_serials() { assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]); assert_eq!(normalize_serial(&[0, 0]), Vec::::new()); } }