288 lines
9.0 KiB
Rust
288 lines
9.0 KiB
Rust
//! Parsed KCA OCSP status snapshots.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
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, Serialize)]
|
|
struct RawSnapshot {
|
|
schema: String,
|
|
issuer: RawIssuer,
|
|
#[serde(default)]
|
|
entries: Vec<RawEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
struct RawIssuer {
|
|
#[serde(default)]
|
|
id: String,
|
|
#[serde(default)]
|
|
sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
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> {
|
|
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));
|
|
}
|
|
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 CBOR: {0}")]
|
|
Cbor(String),
|
|
#[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)]
|
|
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 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), &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), "wrong", "ca").is_err());
|
|
assert!(matches!(
|
|
Snapshot::parse(&body(&sha), &sha, "other"),
|
|
Err(SnapshotError::IssuerIdMismatch { .. })
|
|
));
|
|
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]
|
|
fn normalizes_serials() {
|
|
assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]);
|
|
assert_eq!(normalize_serial(&[0, 0]), Vec::<u8>::new());
|
|
}
|
|
}
|