use crate::ccr::decode::{CcrDecodeError, decode_content_info}; use crate::ccr::encode::{ encode_aspa_payload_state_payload_der, encode_manifest_state_payload_der, encode_roa_payload_state_payload_der, encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der, }; use crate::ccr::hash::verify_state_hash; use crate::ccr::model::{CcrContentInfo, RouterKeyState, TrustAnchorState}; use crate::storage::{RocksStore, VcirArtifactRole}; use serde::Serialize; use std::collections::BTreeSet; use std::path::Path; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct CcrVerifySummary { pub content_type_oid: String, pub version: u32, pub produced_at_rfc3339_utc: String, pub state_hashes_ok: bool, pub manifest_instances: usize, pub roa_payload_sets: usize, pub roa_vrp_count: usize, pub aspa_payload_sets: usize, pub trust_anchor_ski_count: usize, pub router_key_sets: usize, pub router_key_count: usize, } #[derive(Debug, thiserror::Error)] pub enum CcrVerifyError { #[error("CCR decode failed: {0}")] Decode(#[from] CcrDecodeError), #[error("ManifestState hash mismatch")] ManifestHashMismatch, #[error("ROAPayloadState hash mismatch")] RoaHashMismatch, #[error("ASPAPayloadState hash mismatch")] AspaHashMismatch, #[error("TrustAnchorState hash mismatch")] TrustAnchorHashMismatch, #[error("RouterKeyState hash mismatch")] RouterKeyHashMismatch, #[error("read report json failed: {0}: {1}")] ReportRead(String, String), #[error("parse report json failed: {0}")] ReportParse(String), #[error("VRP set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")] ReportVrpMismatch { only_in_ccr: usize, only_in_report: usize, }, #[error("ASPA set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")] ReportAspaMismatch { only_in_ccr: usize, only_in_report: usize, }, #[error("open RocksDB failed: {0}")] OpenStore(String), #[error("list VCIRs failed: {0}")] ListVcirs(String), #[error("VCIR manifest set mismatch: only_in_ccr={only_in_ccr} only_in_vcir={only_in_vcir}")] VcirManifestMismatch { only_in_ccr: usize, only_in_vcir: usize, }, } pub fn verify_content_info_bytes(der: &[u8]) -> Result { let content_info = decode_content_info(der)?; verify_content_info(&content_info) } pub fn verify_content_info( content_info: &CcrContentInfo, ) -> Result { content_info.validate().map_err(CcrDecodeError::Validate)?; let state_hashes_ok = true; let mut manifest_instances = 0usize; let mut roa_payload_sets = 0usize; let mut roa_vrp_count = 0usize; let mut aspa_payload_sets = 0usize; let mut trust_anchor_ski_count = 0usize; let mut router_key_sets = 0usize; let mut router_key_count = 0usize; if let Some(mfts) = &content_info.content.mfts { let payload_der = encode_manifest_state_payload_der(&mfts.mis) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; if !verify_state_hash(&mfts.hash, &payload_der) { return Err(CcrVerifyError::ManifestHashMismatch); } manifest_instances = mfts.mis.len(); } if let Some(vrps) = &content_info.content.vrps { let payload_der = encode_roa_payload_state_payload_der(&vrps.rps) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; if !verify_state_hash(&vrps.hash, &payload_der) { return Err(CcrVerifyError::RoaHashMismatch); } roa_payload_sets = vrps.rps.len(); roa_vrp_count = vrps .rps .iter() .map(|set| count_roa_block_entries(&set.ip_addr_blocks)) .sum(); } if let Some(vaps) = &content_info.content.vaps { let payload_der = encode_aspa_payload_state_payload_der(&vaps.aps) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; if !verify_state_hash(&vaps.hash, &payload_der) { return Err(CcrVerifyError::AspaHashMismatch); } aspa_payload_sets = vaps.aps.len(); } if let Some(tas) = &content_info.content.tas { verify_trust_anchor_state_hash(tas)?; trust_anchor_ski_count = tas.skis.len(); } if let Some(rks) = &content_info.content.rks { verify_router_key_state_hash(rks)?; router_key_sets = rks.rksets.len(); router_key_count = rks.rksets.iter().map(|set| set.router_keys.len()).sum(); } let produced_at_rfc3339_utc = content_info .content .produced_at .to_offset(time::UtcOffset::UTC) .format(&time::format_description::well_known::Rfc3339) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; Ok(CcrVerifySummary { content_type_oid: content_info.content_type_oid.clone(), version: content_info.content.version, produced_at_rfc3339_utc, state_hashes_ok, manifest_instances, roa_payload_sets, roa_vrp_count, aspa_payload_sets, trust_anchor_ski_count, router_key_sets, router_key_count, }) } pub fn verify_against_report_json_path( content_info: &CcrContentInfo, report_json_path: &Path, ) -> Result<(), CcrVerifyError> { let bytes = std::fs::read(report_json_path).map_err(|e| { CcrVerifyError::ReportRead(report_json_path.display().to_string(), e.to_string()) })?; let json: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| CcrVerifyError::ReportParse(e.to_string()))?; let report_vrps = report_vrp_keys(&json)?; let ccr_vrps = extract_vrp_rows(content_info)?; let only_in_ccr = ccr_vrps.difference(&report_vrps).count(); let only_in_report = report_vrps.difference(&ccr_vrps).count(); if only_in_ccr != 0 || only_in_report != 0 { return Err(CcrVerifyError::ReportVrpMismatch { only_in_ccr, only_in_report, }); } let report_aspas = report_aspa_keys(&json)?; let ccr_aspas = ccr_aspa_keys(content_info)?; let only_in_ccr = ccr_aspas.difference(&report_aspas).count(); let only_in_report = report_aspas.difference(&ccr_aspas).count(); if only_in_ccr != 0 || only_in_report != 0 { return Err(CcrVerifyError::ReportAspaMismatch { only_in_ccr, only_in_report, }); } Ok(()) } pub fn verify_against_vcir_store_path( content_info: &CcrContentInfo, db_path: &Path, ) -> Result<(), CcrVerifyError> { let store = RocksStore::open(db_path).map_err(|e| CcrVerifyError::OpenStore(e.to_string()))?; verify_against_vcir_store(content_info, &store) } pub fn verify_against_vcir_store( content_info: &CcrContentInfo, store: &RocksStore, ) -> Result<(), CcrVerifyError> { let Some(mfts) = &content_info.content.mfts else { return Ok(()); }; let vcirs = store .list_vcirs() .map_err(|e| CcrVerifyError::ListVcirs(e.to_string()))?; let mut vcir_hashes = BTreeSet::new(); for vcir in vcirs { if let Some(artifact) = vcir.related_artifacts.iter().find(|artifact| { artifact.artifact_role == VcirArtifactRole::Manifest && artifact.uri.as_deref() == Some(vcir.current_manifest_rsync_uri.as_str()) }) { if let Ok(bytes) = hex::decode(&artifact.sha256) { vcir_hashes.insert(bytes); } } } let ccr_hashes = mfts .mis .iter() .map(|mi| mi.hash.clone()) .collect::>(); let only_in_ccr = ccr_hashes.difference(&vcir_hashes).count(); let only_in_vcir = vcir_hashes.difference(&ccr_hashes).count(); if only_in_ccr != 0 || only_in_vcir != 0 { return Err(CcrVerifyError::VcirManifestMismatch { only_in_ccr, only_in_vcir, }); } Ok(()) } fn verify_trust_anchor_state_hash(state: &TrustAnchorState) -> Result<(), CcrVerifyError> { let payload_der = encode_trust_anchor_state_payload_der(&state.skis) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; if !verify_state_hash(&state.hash, &payload_der) { return Err(CcrVerifyError::TrustAnchorHashMismatch); } Ok(()) } fn verify_router_key_state_hash(state: &RouterKeyState) -> Result<(), CcrVerifyError> { let payload_der = encode_router_key_state_payload_der(&state.rksets) .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?; if !verify_state_hash(&state.hash, &payload_der) { return Err(CcrVerifyError::RouterKeyHashMismatch); } Ok(()) } fn report_vrp_keys( json: &serde_json::Value, ) -> Result, CcrVerifyError> { let mut out = BTreeSet::new(); let Some(items) = json.get("vrps").and_then(|v| v.as_array()) else { return Ok(out); }; for item in items { let asn = item .get("asn") .and_then(|v| v.as_u64()) .ok_or_else(|| CcrVerifyError::ReportParse("vrps[].asn missing".into()))? as u32; let prefix = item .get("prefix") .and_then(|v| v.as_str()) .ok_or_else(|| CcrVerifyError::ReportParse("vrps[].prefix missing".into()))? .to_string(); let max_length = item .get("max_length") .and_then(|v| v.as_u64()) .ok_or_else(|| CcrVerifyError::ReportParse("vrps[].max_length missing".into()))? as u16; out.insert((asn, prefix, max_length)); } Ok(out) } fn report_aspa_keys(json: &serde_json::Value) -> Result)>, CcrVerifyError> { let mut out = BTreeSet::new(); let Some(items) = json.get("aspas").and_then(|v| v.as_array()) else { return Ok(out); }; for item in items { let customer = item .get("customer_as_id") .and_then(|v| v.as_u64()) .ok_or_else(|| CcrVerifyError::ReportParse("aspas[].customer_as_id missing".into()))? as u32; let mut providers = item .get("provider_as_ids") .and_then(|v| v.as_array()) .ok_or_else(|| CcrVerifyError::ReportParse("aspas[].provider_as_ids missing".into()))? .iter() .map(|v| { v.as_u64() .ok_or_else(|| CcrVerifyError::ReportParse("provider_as_ids[] invalid".into())) .map(|v| v as u32) }) .collect::, _>>()?; providers.sort_unstable(); providers.dedup(); out.insert((customer, providers)); } Ok(out) } pub fn extract_vrp_rows( content_info: &CcrContentInfo, ) -> Result, CcrVerifyError> { let mut out = BTreeSet::new(); let Some(vrps) = &content_info.content.vrps else { return Ok(out); }; for set in &vrps.rps { for block in &set.ip_addr_blocks { let (afi, entries) = decode_roa_family_block(block)?; for (prefix_len, addr_bytes, max_len) in entries { let prefix = format_prefix(afi, &addr_bytes, prefix_len)?; out.insert((set.as_id, prefix, max_len.unwrap_or(prefix_len as u16))); } } } Ok(out) } fn ccr_aspa_keys( content_info: &CcrContentInfo, ) -> Result)>, CcrVerifyError> { let mut out = BTreeSet::new(); let Some(vaps) = &content_info.content.vaps else { return Ok(out); }; for set in &vaps.aps { out.insert((set.customer_as_id, set.providers.clone())); } Ok(out) } fn decode_roa_family_block( block: &[u8], ) -> Result<(u16, Vec<(u8, Vec, Option)>), CcrVerifyError> { let mut top = crate::data_model::common::DerReader::new(block); let mut seq = top .take_sequence() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?; if !top.is_empty() { return Err(CcrVerifyError::Decode(CcrDecodeError::Parse( "trailing bytes after ROAIPAddressFamily".into(), ))); } let afi_bytes = seq .take_octet_string() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?; let afi = u16::from_be_bytes([afi_bytes[0], afi_bytes[1]]); let mut addrs = seq .take_sequence() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?; let mut entries = Vec::new(); while !addrs.is_empty() { let mut addr_seq = addrs .take_sequence() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?; let (unused_bits, content) = addr_seq .take_bit_string() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?; let prefix_len = (content.len() * 8) as u8 - unused_bits; let max_len = if addr_seq.is_empty() { None } else { Some( addr_seq .take_uint_u64() .map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))? as u16, ) }; entries.push((prefix_len, content.to_vec(), max_len)); } Ok((afi, entries)) } fn format_prefix(afi: u16, addr_bytes: &[u8], prefix_len: u8) -> Result { match afi { 1 => { let mut full = [0u8; 4]; full[..addr_bytes.len()].copy_from_slice(addr_bytes); Ok(format!("{}/{prefix_len}", std::net::Ipv4Addr::from(full))) } 2 => { let mut full = [0u8; 16]; full[..addr_bytes.len()].copy_from_slice(addr_bytes); Ok(format!("{}/{prefix_len}", std::net::Ipv6Addr::from(full))) } other => Err(CcrVerifyError::Decode(CcrDecodeError::Parse(format!( "unsupported AFI {other}" )))), } } fn count_roa_block_entries(blocks: &[Vec]) -> usize { blocks .iter() .map(|block| { decode_roa_family_block(block) .map(|(_, entries)| entries.len()) .unwrap_or(0) }) .sum() } #[cfg(test)] mod tests { use super::*; use crate::ccr::build::{build_aspa_payload_state, build_roa_payload_state}; use crate::ccr::encode::{ encode_manifest_state_payload_der, encode_roa_payload_state_payload_der, encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der, }; use crate::ccr::model::{ CcrDigestAlgorithm, ManifestInstance, ManifestState, RouterKey, RouterKeySet, RpkiCanonicalCacheRepresentation, }; use crate::data_model::common::BigUnsigned; use crate::data_model::roa::{IpPrefix, RoaAfi}; use crate::storage::{ PackTime, ValidatedCaInstanceResult, ValidatedManifestMeta, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, VcirCcrManifestProjection, VcirChildEntry, VcirInstanceGate, VcirRelatedArtifact, VcirSummary, }; use crate::validation::objects::{AspaAttestation, Vrp}; fn sample_time() -> time::OffsetDateTime { time::OffsetDateTime::parse( "2026-03-24T00:00:00Z", &time::format_description::well_known::Rfc3339, ) .expect("time") } fn sample_content_info() -> CcrContentInfo { let mis = vec![ManifestInstance { hash: vec![0x10; 32], size: 2048, aki: vec![0x20; 20], manifest_number: BigUnsigned { bytes_be: vec![1] }, this_update: sample_time(), locations: vec![vec![0x30, 0x00]], subordinates: vec![vec![0x30; 20]], }]; let mfts = ManifestState { most_recent_update: sample_time(), hash: crate::ccr::compute_state_hash(&encode_manifest_state_payload_der(&mis).unwrap()), mis, }; let vrps = build_roa_payload_state(&[Vrp { asn: 64496, prefix: IpPrefix { afi: RoaAfi::Ipv4, prefix_len: 0, addr: [0; 16], }, max_length: 0, }]) .expect("build roa state"); let vaps = build_aspa_payload_state(&[AspaAttestation { customer_as_id: 64496, provider_as_ids: vec![64497], }]) .expect("build aspa state"); let skis = vec![vec![0x11; 20]]; let tas = TrustAnchorState { hash: crate::ccr::compute_state_hash( &encode_trust_anchor_state_payload_der(&skis).unwrap(), ), skis, }; let rksets = vec![RouterKeySet { as_id: 64496, router_keys: vec![RouterKey { ski: vec![0x22; 20], spki_der: vec![0x30, 0x00], }], }]; let rks = RouterKeyState { hash: crate::ccr::compute_state_hash( &encode_router_key_state_payload_der(&rksets).unwrap(), ), rksets, }; CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, hash_alg: CcrDigestAlgorithm::Sha256, produced_at: sample_time(), mfts: Some(mfts), vrps: Some(vrps), vaps: Some(vaps), tas: Some(tas), rks: Some(rks), }) } fn sample_ccr_manifest_projection(manifest_rsync_uri: &str) -> VcirCcrManifestProjection { VcirCcrManifestProjection { manifest_rsync_uri: manifest_rsync_uri.to_string(), manifest_sha256: vec![0x44; 32], manifest_size: 2048, manifest_ee_aki: vec![0x55; 20], manifest_number_be: vec![1], manifest_this_update: PackTime::from_utc_offset_datetime(sample_time()), manifest_sia_locations_der: vec![vec![ 0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, b'r', b's', b'y', b'n', b'c', ]], subordinate_skis: vec![vec![0x33; 20]], } } #[test] fn verify_detects_each_state_hash_mismatch() { let mut ci = sample_content_info(); ci.content.vrps.as_mut().unwrap().hash[0] ^= 0x01; assert!(matches!( verify_content_info(&ci), Err(CcrVerifyError::RoaHashMismatch) )); let mut ci = sample_content_info(); ci.content.vaps.as_mut().unwrap().hash[0] ^= 0x01; assert!(matches!( verify_content_info(&ci), Err(CcrVerifyError::AspaHashMismatch) )); let mut ci = sample_content_info(); ci.content.tas.as_mut().unwrap().hash[0] ^= 0x01; assert!(matches!( verify_content_info(&ci), Err(CcrVerifyError::TrustAnchorHashMismatch) )); let mut ci = sample_content_info(); ci.content.rks.as_mut().unwrap().hash[0] ^= 0x01; assert!(matches!( verify_content_info(&ci), Err(CcrVerifyError::RouterKeyHashMismatch) )); } #[test] fn verify_against_report_json_accepts_matching_report_and_rejects_parse_errors() { let td = tempfile::tempdir().expect("tempdir"); let report = serde_json::json!({ "vrps": [{"asn": 64496, "prefix": "0.0.0.0/0", "max_length": 0}], "aspas": [{"customer_as_id": 64496, "provider_as_ids": [64497]}] }); let report_path = td.path().join("report.json"); std::fs::write(&report_path, serde_json::to_vec(&report).unwrap()).unwrap(); verify_against_report_json_path(&sample_content_info(), &report_path) .expect("matching report"); let bad_path = td.path().join("bad.json"); std::fs::write(&bad_path, b"not-json").unwrap(); assert!(matches!( verify_against_report_json_path(&sample_content_info(), &bad_path), Err(CcrVerifyError::ReportParse(_)) )); } #[test] fn verify_against_report_json_rejects_missing_fields_and_aspa_mismatch() { let td = tempfile::tempdir().expect("tempdir"); let missing = serde_json::json!({"vrps":[{"prefix":"0.0.0.0/0","max_length":0}],"aspas":[]}); let missing_path = td.path().join("missing.json"); std::fs::write(&missing_path, serde_json::to_vec(&missing).unwrap()).unwrap(); assert!(matches!( verify_against_report_json_path(&sample_content_info(), &missing_path), Err(CcrVerifyError::ReportParse(_)) )); let mismatch = serde_json::json!({ "vrps": [{"asn": 64496, "prefix": "0.0.0.0/0", "max_length": 0}], "aspas": [{"customer_as_id": 64496, "provider_as_ids": [65000]}] }); let mismatch_path = td.path().join("mismatch.json"); std::fs::write(&mismatch_path, serde_json::to_vec(&mismatch).unwrap()).unwrap(); assert!(matches!( verify_against_report_json_path(&sample_content_info(), &mismatch_path), Err(CcrVerifyError::ReportAspaMismatch { .. }) )); } #[test] fn verify_against_vcir_store_rejects_mismatched_manifest_hashes() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open db"); let vcir = ValidatedCaInstanceResult { manifest_rsync_uri: "rsync://example.test/current.mft".to_string(), parent_manifest_rsync_uri: None, tal_id: "apnic".to_string(), ca_subject_name: "CN=test".to_string(), ca_ski: "11".repeat(20), issuer_ski: "22".repeat(20), last_successful_validation_time: PackTime::from_utc_offset_datetime(sample_time()), current_manifest_rsync_uri: "rsync://example.test/current.mft".to_string(), current_crl_rsync_uri: "rsync://example.test/current.crl".to_string(), validated_manifest_meta: ValidatedManifestMeta { validated_manifest_number: vec![1], validated_manifest_this_update: PackTime::from_utc_offset_datetime(sample_time()), validated_manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()), }, ccr_manifest_projection: sample_ccr_manifest_projection( "rsync://example.test/current.mft", ), instance_gate: VcirInstanceGate { manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()), current_crl_next_update: PackTime::from_utc_offset_datetime(sample_time()), self_ca_not_after: PackTime::from_utc_offset_datetime(sample_time()), instance_effective_until: PackTime::from_utc_offset_datetime(sample_time()), }, child_entries: vec![VcirChildEntry { child_manifest_rsync_uri: "rsync://example.test/child.mft".to_string(), child_cert_rsync_uri: "rsync://example.test/child.cer".to_string(), child_cert_hash: "aa".repeat(32), child_ski: "33".repeat(20), child_rsync_base_uri: "rsync://example.test/".to_string(), child_publication_point_rsync_uri: "rsync://example.test/".to_string(), child_rrdp_notification_uri: None, child_effective_ip_resources: None, child_effective_as_resources: None, accepted_at_validation_time: PackTime::from_utc_offset_datetime(sample_time()), }], local_outputs: Vec::new(), related_artifacts: vec![VcirRelatedArtifact { artifact_role: VcirArtifactRole::Manifest, artifact_kind: VcirArtifactKind::Mft, uri: Some("rsync://example.test/current.mft".to_string()), sha256: "ff".repeat(32), object_type: Some("mft".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, reject_reason: None, }], summary: VcirSummary { local_vrp_count: 0, local_aspa_count: 0, local_router_key_count: 0, child_count: 1, accepted_object_count: 1, rejected_object_count: 0, }, audit_summary: VcirAuditSummary { failed_fetch_eligible: true, last_failed_fetch_reason: None, warning_count: 0, audit_flags: Vec::new(), }, }; store.put_vcir(&vcir).unwrap(); assert!(matches!( verify_against_vcir_store(&sample_content_info(), &store), Err(CcrVerifyError::VcirManifestMismatch { .. }) )); } #[test] fn verify_vrp_helpers_reject_bad_afi_and_count_invalid_block_as_zero() { let block = vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x63, 0x30, 0x02, 0x03, 0x00]; let ci = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, hash_alg: CcrDigestAlgorithm::Sha256, produced_at: sample_time(), mfts: None, vrps: Some(crate::ccr::model::RoaPayloadState { rps: vec![crate::ccr::model::RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![block.clone()], }], hash: crate::ccr::compute_state_hash( &encode_roa_payload_state_payload_der(&[crate::ccr::model::RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![block], }]) .unwrap(), ), }), vaps: None, tas: None, rks: None, }); assert!(matches!( extract_vrp_rows(&ci), Err(CcrVerifyError::Decode(_)) )); let bad_count = count_roa_block_entries(&[vec![0x04, 0x00]]); assert_eq!(bad_count, 0); } }