#![forbid(unsafe_code)]
use crate::error::WSError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HashAlgorithm {
Sha256,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignatureAlgorithm {
Ecdsa,
Ed25519,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SctEntry {
pub log_id: [u8; 32],
pub timestamp: u64,
pub extensions: Vec<u8>,
pub signature: Vec<u8>,
pub hash_algorithm: HashAlgorithm,
pub signature_algorithm: SignatureAlgorithm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedCtLog {
pub log_id: [u8; 32],
pub public_key: Vec<u8>,
pub description: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SctVerification {
pub log_id: [u8; 32],
pub log_description: String,
pub timestamp: u64,
pub valid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SctMonitorResult {
pub domain: String,
pub sct_count: usize,
pub all_valid: bool,
pub unexpected: bool,
}
pub fn default_trusted_logs() -> Vec<TrustedCtLog> {
log::warn!(
"Using placeholder CT log keys — these are NOT real public keys. \
SCT verification results are meaningless until real keys are configured."
);
vec![
TrustedCtLog {
log_id: [
0xa4, 0xb9, 0x09, 0x90, 0xb4, 0x18, 0x58, 0x14,
0x87, 0xbb, 0x13, 0xa2, 0xcc, 0x67, 0x70, 0x0a,
0x3c, 0x35, 0x98, 0x04, 0xf9, 0x1b, 0xdf, 0xb8,
0xe3, 0x77, 0xcd, 0x0e, 0xc8, 0x0d, 0xdc, 0x10,
],
public_key: vec![
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00, 0x04, 0xde, 0xad, 0xbe, 0xef,
],
description: "Google Argon 2025".to_string(),
url: "https://ct.googleapis.com/logs/argon2025/".to_string(),
},
TrustedCtLog {
log_id: [
0x63, 0xf2, 0xdb, 0xcd, 0xe8, 0x3b, 0xcc, 0x2c,
0xcf, 0x0b, 0x72, 0x84, 0x27, 0x57, 0x6b, 0x33,
0xa4, 0x8d, 0x61, 0x77, 0x8f, 0xbd, 0x75, 0xa6,
0x38, 0xb1, 0xc7, 0x68, 0x54, 0x4b, 0xd8, 0x8d,
],
public_key: vec![
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00, 0x04, 0xca, 0xfe, 0xba, 0xbe,
],
description: "Cloudflare Nimbus 2025".to_string(),
url: "https://ct.cloudflare.com/logs/nimbus2025/".to_string(),
},
TrustedCtLog {
log_id: [
0x56, 0x14, 0x06, 0x9a, 0x2f, 0xd7, 0xc2, 0xec,
0xd3, 0xf5, 0xe1, 0xbd, 0x44, 0xb2, 0x3e, 0xc7,
0x46, 0x76, 0xb9, 0xbc, 0x99, 0x11, 0x5c, 0xc0,
0xef, 0x94, 0x98, 0x55, 0xd6, 0x89, 0xd0, 0xdd,
],
public_key: vec![
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00, 0x04, 0xfe, 0xed, 0xfa, 0xce,
],
description: "DigiCert Yeti 2025".to_string(),
url: "https://yeti2025.ct.digicert.com/log/".to_string(),
},
]
}
pub struct SctVerifier {
trusted_logs: Vec<TrustedCtLog>,
}
impl SctVerifier {
pub fn new(trusted_logs: Vec<TrustedCtLog>) -> Self {
Self { trusted_logs }
}
pub fn find_log(&self, log_id: &[u8; 32]) -> Option<&TrustedCtLog> {
self.trusted_logs.iter().find(|l| &l.log_id == log_id)
}
pub fn verify_sct(
&self,
sct: &SctEntry,
cert_der: &[u8],
) -> Result<SctVerification, WSError> {
let log = self.find_log(&sct.log_id).ok_or_else(|| {
WSError::CertificateError(format!(
"Unknown CT log ID: {}",
hex::encode(sct.log_id)
))
})?;
let mut signed_data = Vec::new();
signed_data.push(0x00);
signed_data.push(0x00);
signed_data.extend_from_slice(&sct.timestamp.to_be_bytes());
signed_data.extend_from_slice(&[0x00, 0x00]);
let cert_len = cert_der.len() as u32;
signed_data.push(((cert_len >> 16) & 0xff) as u8);
signed_data.push(((cert_len >> 8) & 0xff) as u8);
signed_data.push((cert_len & 0xff) as u8);
signed_data.extend_from_slice(cert_der);
let ext_len = sct.extensions.len() as u16;
signed_data.extend_from_slice(&ext_len.to_be_bytes());
signed_data.extend_from_slice(&sct.extensions);
log::warn!(
"SCT verification is structural only — cryptographic signature \
verification is not yet implemented. Do not rely on SCT results \
for security decisions."
);
let valid = !sct.signature.is_empty()
&& !log.public_key.is_empty()
&& sct.signature.len() >= 8;
Ok(SctVerification {
log_id: sct.log_id,
log_description: log.description.clone(),
timestamp: sct.timestamp,
valid,
})
}
pub fn verify_embedded_scts(
&self,
cert_der: &[u8],
) -> Result<Vec<SctVerification>, WSError> {
let (_, cert) = x509_parser::parse_x509_certificate(cert_der)
.map_err(|e| WSError::X509Error(format!("Failed to parse certificate: {:?}", e)))?;
let mut results = Vec::new();
let sct_oid = const_oid::ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.4.2");
for ext in cert.extensions() {
if ext.oid.to_string() == sct_oid.to_string() {
let scts = parse_sct_list(ext.value)?;
for sct in &scts {
match self.verify_sct(sct, cert_der) {
Ok(v) => results.push(v),
Err(_) => {
results.push(SctVerification {
log_id: sct.log_id,
log_description: "Unknown".to_string(),
timestamp: sct.timestamp,
valid: false,
});
}
}
}
}
}
Ok(results)
}
}
pub fn parse_sct_list(data: &[u8]) -> Result<Vec<SctEntry>, WSError> {
if data.len() < 2 {
return Err(WSError::CertificateError(
"SCT list too short".to_string(),
));
}
let list_len = u16::from_be_bytes([data[0], data[1]]) as usize;
if data.len() < 2 + list_len {
return Err(WSError::CertificateError(
"SCT list length exceeds available data".to_string(),
));
}
let mut entries = Vec::new();
let mut offset = 2usize;
let end = 2 + list_len;
while offset + 2 <= end {
let sct_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
offset += 2;
if offset + sct_len > end {
return Err(WSError::CertificateError(
"SCT entry length exceeds list boundary".to_string(),
));
}
let sct_data = &data[offset..offset + sct_len];
let entry = parse_single_sct(sct_data)?;
entries.push(entry);
offset += sct_len;
}
Ok(entries)
}
fn parse_single_sct(data: &[u8]) -> Result<SctEntry, WSError> {
if data.len() < 47 {
return Err(WSError::CertificateError(
"SCT entry too short".to_string(),
));
}
let version = data[0];
if version != 0x00 {
return Err(WSError::CertificateError(format!(
"Unsupported SCT version: {}",
version
)));
}
let mut log_id = [0u8; 32];
log_id.copy_from_slice(&data[1..33]);
let timestamp = u64::from_be_bytes([
data[33], data[34], data[35], data[36],
data[37], data[38], data[39], data[40],
]);
let ext_len = u16::from_be_bytes([data[41], data[42]]) as usize;
let ext_end = 43 + ext_len;
if data.len() < ext_end + 4 {
return Err(WSError::CertificateError(
"SCT entry truncated after extensions".to_string(),
));
}
let extensions = data[43..ext_end].to_vec();
let hash_alg_byte = data[ext_end];
let sig_alg_byte = data[ext_end + 1];
let hash_algorithm = match hash_alg_byte {
4 => HashAlgorithm::Sha256, _ => {
return Err(WSError::CertificateError(format!(
"Unsupported hash algorithm: {}",
hash_alg_byte
)));
}
};
let signature_algorithm = match sig_alg_byte {
3 => SignatureAlgorithm::Ecdsa, 7 => SignatureAlgorithm::Ed25519, _ => {
return Err(WSError::CertificateError(format!(
"Unsupported signature algorithm: {}",
sig_alg_byte
)));
}
};
let sig_len = u16::from_be_bytes([data[ext_end + 2], data[ext_end + 3]]) as usize;
let sig_start = ext_end + 4;
if data.len() < sig_start + sig_len {
return Err(WSError::CertificateError(
"SCT signature truncated".to_string(),
));
}
let signature = data[sig_start..sig_start + sig_len].to_vec();
Ok(SctEntry {
log_id,
timestamp,
extensions,
signature,
hash_algorithm,
signature_algorithm,
})
}
pub fn serialize_sct(sct: &SctEntry) -> Vec<u8> {
let mut out = Vec::new();
out.push(0x00);
out.extend_from_slice(&sct.log_id);
out.extend_from_slice(&sct.timestamp.to_be_bytes());
let ext_len = sct.extensions.len() as u16;
out.extend_from_slice(&ext_len.to_be_bytes());
out.extend_from_slice(&sct.extensions);
let hash_byte: u8 = match sct.hash_algorithm {
HashAlgorithm::Sha256 => 4,
};
out.push(hash_byte);
let sig_alg_byte: u8 = match sct.signature_algorithm {
SignatureAlgorithm::Ecdsa => 3,
SignatureAlgorithm::Ed25519 => 7,
};
out.push(sig_alg_byte);
let sig_len = sct.signature.len() as u16;
out.extend_from_slice(&sig_len.to_be_bytes());
out.extend_from_slice(&sct.signature);
out
}
pub fn serialize_sct_list(scts: &[SctEntry]) -> Vec<u8> {
let mut inner = Vec::new();
for sct in scts {
let encoded = serialize_sct(sct);
let len = encoded.len() as u16;
inner.extend_from_slice(&len.to_be_bytes());
inner.extend_from_slice(&encoded);
}
let mut out = Vec::new();
let list_len = inner.len() as u16;
out.extend_from_slice(&list_len.to_be_bytes());
out.extend_from_slice(&inner);
out
}
pub struct SctMonitor {
expected_domains: Vec<String>,
verifier: SctVerifier,
}
impl SctMonitor {
pub fn new(expected_domains: Vec<String>) -> Self {
Self {
expected_domains,
verifier: SctVerifier::new(default_trusted_logs()),
}
}
pub fn with_logs(expected_domains: Vec<String>, trusted_logs: Vec<TrustedCtLog>) -> Self {
Self {
expected_domains,
verifier: SctVerifier::new(trusted_logs),
}
}
pub fn check_certificate(
&self,
cert_der: &[u8],
) -> Result<SctMonitorResult, WSError> {
let (_, cert) = x509_parser::parse_x509_certificate(cert_der)
.map_err(|e| WSError::X509Error(format!("Failed to parse certificate: {:?}", e)))?;
let mut domains: Vec<String> = Vec::new();
for ext in cert.extensions() {
if let x509_parser::extensions::ParsedExtension::SubjectAlternativeName(san) =
ext.parsed_extension()
{
for name in &san.general_names {
if let x509_parser::extensions::GeneralName::DNSName(dns) = name {
domains.push(dns.to_string());
}
}
}
}
let domain = domains.first().cloned().unwrap_or_else(|| {
cert.subject().to_string()
});
let matches_monitored = domains.iter().any(|d| {
self.expected_domains.iter().any(|exp| {
d == exp || d.ends_with(&format!(".{}", exp))
})
});
let unexpected = matches_monitored;
let verifications = self.verifier.verify_embedded_scts(cert_der)?;
let sct_count = verifications.len();
let all_valid = !verifications.is_empty() && verifications.iter().all(|v| v.valid);
Ok(SctMonitorResult {
domain,
sct_count,
all_valid,
unexpected,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_sct(log_id: [u8; 32], timestamp: u64) -> SctEntry {
SctEntry {
log_id,
timestamp,
extensions: Vec::new(),
signature: vec![0x30, 0x45, 0x02, 0x21, 0x00, 0xab, 0xcd, 0xef, 0x01, 0x02],
hash_algorithm: HashAlgorithm::Sha256,
signature_algorithm: SignatureAlgorithm::Ecdsa,
}
}
fn first_default_log_id() -> [u8; 32] {
default_trusted_logs()[0].log_id
}
#[test]
fn test_sct_entry_fields() {
let log_id = [0xaa; 32];
let sct = make_test_sct(log_id, 1_700_000_000_000);
assert_eq!(sct.log_id, log_id);
assert_eq!(sct.timestamp, 1_700_000_000_000);
assert!(sct.extensions.is_empty());
assert_eq!(sct.hash_algorithm, HashAlgorithm::Sha256);
assert_eq!(sct.signature_algorithm, SignatureAlgorithm::Ecdsa);
}
#[test]
fn test_sct_serialize_roundtrip() {
let sct = make_test_sct([0x11; 32], 1_600_000_000_000);
let bytes = serialize_sct(&sct);
let parsed = parse_single_sct(&bytes).expect("round-trip parse should succeed");
assert_eq!(parsed.log_id, sct.log_id);
assert_eq!(parsed.timestamp, sct.timestamp);
assert_eq!(parsed.extensions, sct.extensions);
assert_eq!(parsed.signature, sct.signature);
assert_eq!(parsed.hash_algorithm, sct.hash_algorithm);
assert_eq!(parsed.signature_algorithm, sct.signature_algorithm);
}
#[test]
fn test_sct_list_roundtrip() {
let scts = vec![
make_test_sct([0x01; 32], 100),
make_test_sct([0x02; 32], 200),
];
let encoded = serialize_sct_list(&scts);
let decoded = parse_sct_list(&encoded).expect("list round-trip should succeed");
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].log_id, [0x01; 32]);
assert_eq!(decoded[1].log_id, [0x02; 32]);
assert_eq!(decoded[0].timestamp, 100);
assert_eq!(decoded[1].timestamp, 200);
}
#[test]
fn test_verify_sct_known_log() {
let log_id = first_default_log_id();
let sct = make_test_sct(log_id, 1_700_000_000_000);
let verifier = SctVerifier::new(default_trusted_logs());
let result = verifier
.verify_sct(&sct, b"fake-cert-der")
.expect("known log should not error");
assert_eq!(result.log_id, log_id);
assert!(result.valid);
assert_eq!(result.timestamp, 1_700_000_000_000);
assert!(!result.log_description.is_empty());
}
#[test]
fn test_verify_sct_unknown_log() {
let sct = make_test_sct([0xff; 32], 1_700_000_000_000);
let verifier = SctVerifier::new(default_trusted_logs());
let result = verifier.verify_sct(&sct, b"fake-cert-der");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown CT log"));
}
#[test]
fn test_find_log_by_id() {
let logs = default_trusted_logs();
let verifier = SctVerifier::new(logs.clone());
for log in &logs {
let found = verifier.find_log(&log.log_id);
assert!(found.is_some(), "log '{}' should be found", log.description);
assert_eq!(found.unwrap().description, log.description);
}
assert!(verifier.find_log(&[0x00; 32]).is_none());
}
#[test]
fn test_default_trusted_logs() {
let logs = default_trusted_logs();
assert!(logs.len() >= 3, "should have at least 3 default logs");
for log in &logs {
assert!(!log.description.is_empty());
assert!(!log.url.is_empty());
assert!(!log.public_key.is_empty());
assert_ne!(log.log_id, [0u8; 32]);
}
}
#[test]
fn test_monitor_configuration() {
let domains = vec!["example.com".to_string(), "test.org".to_string()];
let monitor = SctMonitor::new(domains);
assert_eq!(monitor.expected_domains.len(), 2);
assert_eq!(monitor.expected_domains[0], "example.com");
assert_eq!(monitor.expected_domains[1], "test.org");
}
#[test]
fn test_sct_verification_result() {
let v = SctVerification {
log_id: [0xab; 32],
log_description: "Test Log".to_string(),
timestamp: 42,
valid: true,
};
assert!(v.valid);
assert_eq!(v.log_description, "Test Log");
let v_invalid = SctVerification {
valid: false,
..v.clone()
};
assert!(!v_invalid.valid);
}
#[test]
fn test_sct_monitor_result_fields() {
let r = SctMonitorResult {
domain: "example.com".to_string(),
sct_count: 3,
all_valid: true,
unexpected: false,
};
assert_eq!(r.domain, "example.com");
assert_eq!(r.sct_count, 3);
assert!(r.all_valid);
assert!(!r.unexpected);
}
#[test]
fn test_sct_entry_serde_roundtrip() {
let sct = make_test_sct([0x99; 32], 1_650_000_000_000);
let json = serde_json::to_string(&sct).expect("serialise");
let parsed: SctEntry = serde_json::from_str(&json).expect("deserialise");
assert_eq!(parsed.log_id, sct.log_id);
assert_eq!(parsed.timestamp, sct.timestamp);
assert_eq!(parsed.signature, sct.signature);
assert!(json.contains("logId"), "field should be camelCase: {}", json);
assert!(json.contains("hashAlgorithm"), "field should be camelCase: {}", json);
assert!(json.contains("signatureAlgorithm"), "field should be camelCase: {}", json);
}
#[test]
fn test_parse_sct_list_too_short() {
let result = parse_sct_list(&[0x00]);
assert!(result.is_err());
}
#[test]
fn test_parse_sct_list_length_mismatch() {
let result = parse_sct_list(&[0x00, 0xFF]);
assert!(result.is_err());
}
#[test]
fn test_empty_signature_is_invalid() {
let log_id = first_default_log_id();
let mut sct = make_test_sct(log_id, 1_700_000_000_000);
sct.signature = Vec::new();
let verifier = SctVerifier::new(default_trusted_logs());
let result = verifier
.verify_sct(&sct, b"cert")
.expect("should not error for known log");
assert!(!result.valid, "empty signature should be invalid");
}
#[test]
fn test_ed25519_sct_roundtrip() {
let mut sct = make_test_sct([0x77; 32], 500);
sct.signature_algorithm = SignatureAlgorithm::Ed25519;
let bytes = serialize_sct(&sct);
let parsed = parse_single_sct(&bytes).expect("parse Ed25519 SCT");
assert_eq!(parsed.signature_algorithm, SignatureAlgorithm::Ed25519);
}
#[test]
fn test_monitor_with_custom_logs() {
let log = TrustedCtLog {
log_id: [0xcc; 32],
public_key: vec![0x01, 0x02, 0x03],
description: "Custom Log".to_string(),
url: "https://custom.example.com/ct/".to_string(),
};
let monitor = SctMonitor::with_logs(
vec!["mysite.com".to_string()],
vec![log.clone()],
);
assert_eq!(monitor.expected_domains, vec!["mysite.com"]);
let found = monitor.verifier.find_log(&[0xcc; 32]);
assert!(found.is_some());
assert_eq!(found.unwrap().description, "Custom Log");
}
#[test]
fn test_trusted_ct_log_serde() {
let log = TrustedCtLog {
log_id: [0xdd; 32],
public_key: vec![0xfe, 0xed],
description: "Serde Test".to_string(),
url: "https://log.example.com/".to_string(),
};
let json = serde_json::to_string(&log).expect("serialise");
let parsed: TrustedCtLog = serde_json::from_str(&json).expect("deserialise");
assert_eq!(parsed.log_id, log.log_id);
assert_eq!(parsed.public_key, log.public_key);
assert!(json.contains("logId"));
assert!(json.contains("publicKey"));
}
}