Files
kca/ocsp-responder/src/snapshot.rs
T

236 lines
7.4 KiB
Rust

//! 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<DateTime<Utc>>,
pub reason: Option<CrlReason>,
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub entries: HashMap<Vec<u8>, StatusEntry>,
}
#[derive(Debug, Deserialize)]
struct RawSnapshot {
schema: String,
issuer: RawIssuer,
#[serde(default)]
entries: Vec<RawEntry>,
}
#[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<DateTime<Utc>>,
#[serde(default)]
reason: Option<String>,
}
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<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(),
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<u8> {
let first = serial
.iter()
.position(|byte| *byte != 0)
.unwrap_or(serial.len());
serial[first..].to_vec()
}
fn parse_serial(value: &str) -> Result<Vec<u8>, 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<u8> {
Sha1::digest(bytes).to_vec()
}
pub fn hash_sha256(bytes: &[u8]) -> Vec<u8> {
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::<u8>::new());
}
}