diff --git a/src/audit.rs b/src/audit.rs index e161f69..9707f61 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -9,6 +9,7 @@ pub enum AuditObjectKind { Manifest, Crl, Certificate, + RouterCertificate, Roa, Aspa, Other, diff --git a/src/audit_trace.rs b/src/audit_trace.rs index 8ab5f64..112a1b3 100644 --- a/src/audit_trace.rs +++ b/src/audit_trace.rs @@ -249,6 +249,7 @@ fn raw_ref_from_entry(sha256_hex: &str, entry: Option<&RawByHashEntry>) -> Audit #[cfg(test)] mod tests { use super::*; + use base64::Engine as _; use crate::audit::sha256_hex; use crate::storage::{ PackTime, ValidatedManifestMeta, VcirAuditSummary, VcirChildEntry, VcirInstanceGate, @@ -311,6 +312,10 @@ mod tests { .iter() .filter(|output| output.output_type == VcirOutputType::Aspa) .count() as u32, + local_router_key_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::RouterKey) + .count() as u32, child_count: 1, accepted_object_count: related_artifacts.len() as u32, rejected_object_count: 0, @@ -477,6 +482,55 @@ mod tests { ); } + #[test] + fn trace_rule_to_root_supports_router_key_rules() { + let store_dir = tempfile::tempdir().expect("store dir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let manifest = "rsync://example.test/router/leaf.mft"; + let mut local = sample_local_output(manifest); + local.output_type = VcirOutputType::RouterKey; + local.source_object_uri = "rsync://example.test/router/router.cer".to_string(); + local.source_object_type = "router_key".to_string(); + local.payload_json = serde_json::json!({ + "as_id": 64496, + "ski_hex": "11".repeat(20), + "spki_der_base64": base64::engine::general_purpose::STANDARD.encode([0x30u8, 0x00]), + }).to_string(); + let mut vcir = sample_vcir( + manifest, + None, + "test-tal", + Some(local), + sample_artifacts(manifest, &sha256_hex(b"router-object")), + ); + vcir.local_outputs[0].output_type = VcirOutputType::RouterKey; + vcir.local_outputs[0].source_object_uri = "rsync://example.test/router/router.cer".to_string(); + vcir.local_outputs[0].source_object_type = "router_key".to_string(); + vcir.local_outputs[0].payload_json = serde_json::json!({ + "as_id": 64496, + "ski_hex": "11".repeat(20), + "spki_der_base64": base64::engine::general_purpose::STANDARD.encode([0x30u8, 0x00]), + }).to_string(); + vcir.summary.local_vrp_count = 0; + vcir.summary.local_router_key_count = 1; + store.put_vcir(&vcir).expect("put vcir"); + let rule_entry = AuditRuleIndexEntry { + kind: AuditRuleKind::RouterKey, + rule_hash: vcir.local_outputs[0].rule_hash.clone(), + manifest_rsync_uri: manifest.to_string(), + source_object_uri: vcir.local_outputs[0].source_object_uri.clone(), + source_object_hash: vcir.local_outputs[0].source_object_hash.clone(), + output_id: vcir.local_outputs[0].output_id.clone(), + item_effective_until: vcir.local_outputs[0].item_effective_until.clone(), + }; + store.put_audit_rule_index_entry(&rule_entry).expect("put rule"); + let trace = trace_rule_to_root(&store, AuditRuleKind::RouterKey, &rule_entry.rule_hash) + .expect("trace rule") + .expect("trace exists"); + assert_eq!(trace.rule.kind, AuditRuleKind::RouterKey); + assert_eq!(trace.resolved_output.output_type, VcirOutputType::RouterKey); + } + #[test] fn trace_rule_to_root_returns_none_for_missing_rule_index() { let store_dir = tempfile::tempdir().expect("store dir"); diff --git a/src/bin/ccr_dump.rs b/src/bin/ccr_dump.rs new file mode 100644 index 0000000..182bf34 --- /dev/null +++ b/src/bin/ccr_dump.rs @@ -0,0 +1,59 @@ +use rpki::ccr::dump::dump_content_info_json_value; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Args { + ccr_path: Option, +} + +fn usage() -> &'static str { + "Usage: ccr_dump --ccr " +} + +fn parse_args(argv: &[String]) -> Result { + let mut args = Args::default(); + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--help" | "-h" => return Err(usage().to_string()), + "--ccr" => { + i += 1; + let v = argv.get(i).ok_or("--ccr requires a value")?; + args.ccr_path = Some(v.into()); + } + other => return Err(format!("unknown argument: {other}\n{}", usage())), + } + i += 1; + } + if args.ccr_path.is_none() { + return Err(format!("--ccr is required\n{}", usage())); + } + Ok(args) +} + +fn main() -> Result<(), String> { + let args = parse_args(&std::env::args().collect::>())?; + let ccr_path = args.ccr_path.as_ref().unwrap(); + let bytes = std::fs::read(ccr_path).map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?; + let json = dump_content_info_json_value(&bytes).map_err(|e| e.to_string())?; + println!("{}", serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_accepts_ccr_path() { + let argv = vec!["ccr_dump".to_string(), "--ccr".to_string(), "a.ccr".to_string()]; + let args = parse_args(&argv).expect("parse"); + assert_eq!(args.ccr_path.as_deref(), Some(std::path::Path::new("a.ccr"))); + } + + #[test] + fn parse_args_rejects_missing_required_ccr() { + let argv = vec!["ccr_dump".to_string()]; + let err = parse_args(&argv).unwrap_err(); + assert!(err.contains("--ccr is required"), "{err}"); + } +} diff --git a/src/bin/ccr_to_routinator_csv.rs b/src/bin/ccr_to_routinator_csv.rs new file mode 100644 index 0000000..8628611 --- /dev/null +++ b/src/bin/ccr_to_routinator_csv.rs @@ -0,0 +1,109 @@ +use rpki::ccr::{decode_content_info, extract_vrp_rows}; +use std::io::Write; + +#[derive(Default, Debug)] +struct Args { + ccr_path: Option, + out_path: Option, + trust_anchor: String, +} + +fn usage() -> &'static str { + "Usage: ccr_to_routinator_csv --ccr --out [--trust-anchor ]" +} + +fn parse_args(argv: &[String]) -> Result { + let mut args = Args { + trust_anchor: "unknown".to_string(), + ..Args::default() + }; + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--ccr" => { + i += 1; + let v = argv.get(i).ok_or("--ccr requires a value")?; + args.ccr_path = Some(v.into()); + } + "--out" => { + i += 1; + let v = argv.get(i).ok_or("--out requires a value")?; + args.out_path = Some(v.into()); + } + "--trust-anchor" => { + i += 1; + let v = argv.get(i).ok_or("--trust-anchor requires a value")?; + args.trust_anchor = v.clone(); + } + "-h" | "--help" => return Err(usage().to_string()), + other => return Err(format!("unknown argument: {other}\n{}", usage())), + } + i += 1; + } + if args.ccr_path.is_none() { + return Err(format!("--ccr is required\n{}", usage())); + } + if args.out_path.is_none() { + return Err(format!("--out is required\n{}", usage())); + } + Ok(args) +} + +fn collect_vrp_rows(bytes: &[u8]) -> Result, String> { + let content_info = decode_content_info(bytes).map_err(|e| e.to_string())?; + extract_vrp_rows(&content_info).map_err(|e| e.to_string()) +} + +fn main() -> Result<(), String> { + let argv: Vec = std::env::args().collect(); + let args = parse_args(&argv)?; + let ccr_path = args.ccr_path.as_ref().unwrap(); + let out_path = args.out_path.as_ref().unwrap(); + let bytes = std::fs::read(ccr_path) + .map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?; + let rows = collect_vrp_rows(&bytes)?; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?; + } + let mut file = std::io::BufWriter::new( + std::fs::File::create(out_path) + .map_err(|e| format!("create output failed: {}: {e}", out_path.display()))?, + ); + writeln!(file, "ASN,IP Prefix,Max Length,Trust Anchor").map_err(|e| e.to_string())?; + for (asn, prefix, max_len) in rows { + writeln!(file, "AS{asn},{prefix},{max_len},{}", args.trust_anchor) + .map_err(|e| e.to_string())?; + } + println!("{}", out_path.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_accepts_required_flags() { + let argv = vec![ + "ccr_to_routinator_csv".to_string(), + "--ccr".to_string(), + "a.ccr".to_string(), + "--out".to_string(), + "out.csv".to_string(), + "--trust-anchor".to_string(), + "apnic".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert_eq!(args.ccr_path.as_deref(), Some(std::path::Path::new("a.ccr"))); + assert_eq!(args.out_path.as_deref(), Some(std::path::Path::new("out.csv"))); + assert_eq!(args.trust_anchor, "apnic"); + } + + #[test] + fn parse_args_rejects_missing_required_flags() { + let argv = vec!["ccr_to_routinator_csv".to_string(), "--ccr".to_string(), "a.ccr".to_string()]; + let err = parse_args(&argv).unwrap_err(); + assert!(err.contains("--out is required"), "{err}"); + } +} diff --git a/src/bin/ccr_verify.rs b/src/bin/ccr_verify.rs new file mode 100644 index 0000000..b24e62a --- /dev/null +++ b/src/bin/ccr_verify.rs @@ -0,0 +1,88 @@ +use rpki::ccr::{decode_content_info, verify::verify_content_info, verify_against_report_json_path, verify_against_vcir_store_path}; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Args { + ccr_path: Option, + report_json: Option, + db_path: Option, +} + +fn usage() -> &'static str { + "Usage: ccr_verify --ccr [--report-json ] [--db ]" +} + +fn parse_args(argv: &[String]) -> Result { + let mut args = Args::default(); + let mut i = 1usize; + while i < argv.len() { + match argv[i].as_str() { + "--help" | "-h" => return Err(usage().to_string()), + "--ccr" => { + i += 1; + let v = argv.get(i).ok_or("--ccr requires a value")?; + args.ccr_path = Some(v.into()); + } + "--report-json" => { + i += 1; + let v = argv.get(i).ok_or("--report-json requires a value")?; + args.report_json = Some(v.into()); + } + "--db" => { + i += 1; + let v = argv.get(i).ok_or("--db requires a value")?; + args.db_path = Some(v.into()); + } + other => return Err(format!("unknown argument: {other}\n{}", usage())), + } + i += 1; + } + if args.ccr_path.is_none() { + return Err(format!("--ccr is required\n{}", usage())); + } + Ok(args) +} + +fn main() -> Result<(), String> { + let args = parse_args(&std::env::args().collect::>())?; + let ccr_path = args.ccr_path.as_ref().unwrap(); + let bytes = std::fs::read(ccr_path).map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?; + let ci = decode_content_info(&bytes).map_err(|e| e.to_string())?; + let summary = verify_content_info(&ci).map_err(|e| e.to_string())?; + if let Some(report_json) = args.report_json.as_ref() { + verify_against_report_json_path(&ci, report_json).map_err(|e| e.to_string())?; + } + if let Some(db_path) = args.db_path.as_ref() { + verify_against_vcir_store_path(&ci, db_path).map_err(|e| e.to_string())?; + } + println!("{}", serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_accepts_all_flags() { + let argv = vec![ + "ccr_verify".to_string(), + "--ccr".to_string(), + "a.ccr".to_string(), + "--report-json".to_string(), + "report.json".to_string(), + "--db".to_string(), + "db".to_string(), + ]; + let args = parse_args(&argv).expect("parse"); + assert_eq!(args.ccr_path.as_deref(), Some(std::path::Path::new("a.ccr"))); + assert_eq!(args.report_json.as_deref(), Some(std::path::Path::new("report.json"))); + assert_eq!(args.db_path.as_deref(), Some(std::path::Path::new("db"))); + } + + #[test] + fn parse_args_rejects_missing_required_ccr() { + let argv = vec!["ccr_verify".to_string()]; + let err = parse_args(&argv).unwrap_err(); + assert!(err.contains("--ccr is required"), "{err}"); + } +} diff --git a/src/ccr/build.rs b/src/ccr/build.rs new file mode 100644 index 0000000..ddab4fc --- /dev/null +++ b/src/ccr/build.rs @@ -0,0 +1,800 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use sha2::Digest; + +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::compute_state_hash; +use crate::ccr::model::{ + AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet, + RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState, +}; +use crate::data_model::manifest::ManifestObject; +use crate::data_model::rc::{AccessDescription, SubjectInfoAccess}; +use crate::data_model::roa::RoaAfi; +use crate::data_model::router_cert::BgpsecRouterCertificate; +use crate::data_model::ta::TrustAnchor; +use crate::storage::{RocksStore, VcirArtifactRole, ValidatedCaInstanceResult}; +use crate::validation::objects::{AspaAttestation, Vrp}; + +#[derive(Debug, thiserror::Error)] +pub enum CcrBuildError { + #[error("trust anchor set must not be empty")] + EmptyTrustAnchors, + + #[error("trust anchor certificate missing SubjectKeyIdentifier")] + MissingTrustAnchorSki, + + #[error("ROA payload state encoding failed: {0}")] + RoaEncode(String), + + #[error("ASPA payload state encoding failed: {0}")] + AspaEncode(String), + + #[error("TrustAnchor state encoding failed: {0}")] + TrustAnchorEncode(String), + + #[error("manifest artifact missing in VCIR: {0}")] + MissingManifestArtifact(String), + + #[error("manifest raw bytes missing in store for {manifest_rsync_uri}: {sha256_hex}")] + MissingManifestRawBytes { manifest_rsync_uri: String, sha256_hex: String }, + + #[error("manifest raw bytes load failed for {manifest_rsync_uri}: {detail}")] + LoadManifestRawBytes { manifest_rsync_uri: String, detail: String }, + + #[error("manifest decode failed for {manifest_rsync_uri}: {detail}")] + ManifestDecode { manifest_rsync_uri: String, detail: String }, + + #[error("manifest EE certificate missing AuthorityKeyIdentifier: {0}")] + ManifestEeMissingAki(String), + + #[error("manifest EE certificate missing Subject Information Access extension: {0}")] + ManifestEeMissingSia(String), + + #[error("manifest EE certificate SIA is not the EE form: {0}")] + ManifestEeSiaWrongVariant(String), + + #[error("manifest child SKI is not valid lowercase hex or not 20 bytes: {0}")] + InvalidChildSki(String), + + #[error("manifest AccessDescription contains unsupported accessMethod OID: {0}")] + UnsupportedAccessMethodOid(String), + + #[error("manifest state encoding failed: {0}")] + ManifestEncode(String), + + #[error("duplicate manifest hash with conflicting content for URI: {0}")] + DuplicateManifestHashConflict(String), + + #[error("router key state encoding failed: {0}")] + RouterKeyEncode(String), +} + +pub fn build_roa_payload_state(vrps: &[Vrp]) -> Result { + let mut grouped: BTreeMap> = BTreeMap::new(); + for vrp in vrps { + grouped + .entry(vrp.asn) + .or_default() + .insert(RoaPayloadKey::from_vrp(vrp)); + } + + let mut rps = Vec::with_capacity(grouped.len()); + for (asn, entries) in grouped { + let mut families: BTreeMap> = BTreeMap::new(); + for entry in entries { + families.entry(entry.afi).or_default().push(entry); + } + let mut ip_addr_blocks = Vec::with_capacity(families.len()); + for (afi, entries) in families { + ip_addr_blocks.push(encode_roa_ip_address_family(afi, &entries)); + } + rps.push(RoaPayloadSet { + as_id: asn, + ip_addr_blocks, + }); + } + + let payload_der = + encode_roa_payload_state_payload_der(&rps).map_err(|e| CcrBuildError::RoaEncode(e.to_string()))?; + Ok(RoaPayloadState { + rps, + hash: compute_state_hash(&payload_der), + }) +} + +pub fn build_aspa_payload_state( + attestations: &[AspaAttestation], +) -> Result { + let mut grouped: BTreeMap> = BTreeMap::new(); + for attestation in attestations { + grouped + .entry(attestation.customer_as_id) + .or_default() + .extend(attestation.provider_as_ids.iter().copied()); + } + + let aps: Vec = grouped + .into_iter() + .map(|(customer_as_id, providers)| AspaPayloadSet { + customer_as_id, + providers: providers.into_iter().collect(), + }) + .collect(); + + let payload_der = encode_aspa_payload_state_payload_der(&aps) + .map_err(|e| CcrBuildError::AspaEncode(e.to_string()))?; + Ok(AspaPayloadState { + aps, + hash: compute_state_hash(&payload_der), + }) +} + +pub fn build_trust_anchor_state( + trust_anchors: &[TrustAnchor], +) -> Result { + if trust_anchors.is_empty() { + return Err(CcrBuildError::EmptyTrustAnchors); + } + let mut skis: BTreeSet> = BTreeSet::new(); + for ta in trust_anchors { + let ski = ta + .ta_certificate + .rc_ca + .tbs + .extensions + .subject_key_identifier + .clone() + .ok_or(CcrBuildError::MissingTrustAnchorSki)?; + skis.insert(ski); + } + let skis: Vec> = skis.into_iter().collect(); + let payload_der = encode_trust_anchor_state_payload_der(&skis) + .map_err(|e| CcrBuildError::TrustAnchorEncode(e.to_string()))?; + Ok(TrustAnchorState { + skis, + hash: compute_state_hash(&payload_der), + }) +} + +pub fn build_manifest_state_from_vcirs( + store: &RocksStore, + vcirs: &[ValidatedCaInstanceResult], +) -> Result { + let mut mis_by_hash: BTreeMap, ManifestInstance> = BTreeMap::new(); + let mut most_recent_update = time::OffsetDateTime::UNIX_EPOCH; + + for vcir in vcirs { + let manifest_artifact = vcir + .related_artifacts + .iter() + .find(|artifact| { + artifact.artifact_role == VcirArtifactRole::Manifest + && artifact.uri.as_deref() == Some(vcir.current_manifest_rsync_uri.as_str()) + }) + .ok_or_else(|| CcrBuildError::MissingManifestArtifact(vcir.current_manifest_rsync_uri.clone()))?; + + let raw_entry = store + .get_raw_by_hash_entry(&manifest_artifact.sha256) + .map_err(|e| CcrBuildError::LoadManifestRawBytes { + manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + detail: e.to_string(), + })? + .ok_or_else(|| CcrBuildError::MissingManifestRawBytes { + manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + sha256_hex: manifest_artifact.sha256.clone(), + })?; + + let manifest = ManifestObject::decode_der(&raw_entry.bytes).map_err(|e| CcrBuildError::ManifestDecode { + manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + detail: e.to_string(), + })?; + + let ee = &manifest.signed_object.signed_data.certificates[0].resource_cert; + let aki = ee + .tbs + .extensions + .authority_key_identifier + .clone() + .ok_or_else(|| CcrBuildError::ManifestEeMissingAki(vcir.current_manifest_rsync_uri.clone()))?; + let sia = ee + .tbs + .extensions + .subject_info_access + .as_ref() + .ok_or_else(|| CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone()))?; + let locations = match sia { + SubjectInfoAccess::Ee(ee_sia) => ee_sia + .access_descriptions + .iter() + .map(encode_access_description_der) + .collect::, _>>()?, + SubjectInfoAccess::Ca(_) => { + return Err(CcrBuildError::ManifestEeSiaWrongVariant( + vcir.current_manifest_rsync_uri.clone(), + )) + } + }; + + let subordinates = collect_subordinate_ski_bytes(vcir)?; + let this_update = vcir + .validated_manifest_meta + .validated_manifest_this_update + .parse() + .map_err(|e| CcrBuildError::ManifestDecode { + manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + detail: format!("invalid validated_manifest_this_update: {e}"), + })?; + if this_update > most_recent_update { + most_recent_update = this_update; + } + + let instance = ManifestInstance { + hash: sha2::Sha256::digest(&raw_entry.bytes).to_vec(), + size: raw_entry.bytes.len() as u64, + aki, + manifest_number: crate::data_model::common::BigUnsigned { + bytes_be: vcir.validated_manifest_meta.validated_manifest_number.clone(), + }, + this_update, + locations, + subordinates, + }; + + match mis_by_hash.get(instance.hash.as_slice()) { + Some(existing) if existing != &instance => { + return Err(CcrBuildError::DuplicateManifestHashConflict( + vcir.current_manifest_rsync_uri.clone(), + )); + } + Some(_) => {} + None => { + mis_by_hash.insert(instance.hash.clone(), instance); + } + } + } + + let mis: Vec = mis_by_hash.into_values().collect(); + let payload_der = encode_manifest_state_payload_der(&mis) + .map_err(|e| CcrBuildError::ManifestEncode(e.to_string()))?; + Ok(ManifestState { + mis, + most_recent_update, + hash: compute_state_hash(&payload_der), + }) +} + +fn collect_subordinate_ski_bytes( + vcir: &ValidatedCaInstanceResult, +) -> Result>, CcrBuildError> { + let mut skis = BTreeSet::new(); + for child in &vcir.child_entries { + let bytes = hex::decode(&child.child_ski) + .map_err(|_| CcrBuildError::InvalidChildSki(child.child_ski.clone()))?; + if bytes.len() != 20 { + return Err(CcrBuildError::InvalidChildSki(child.child_ski.clone())); + } + skis.insert(bytes); + } + Ok(skis.into_iter().collect()) +} + +fn encode_access_description_der(ad: &AccessDescription) -> Result, CcrBuildError> { + let oid = encode_oid_from_string(&ad.access_method_oid)?; + let uri = encode_tlv(0x86, ad.access_location.as_bytes().to_vec()); + Ok(encode_sequence(&[oid, uri])) +} + +fn encode_oid_from_string(oid: &str) -> Result, CcrBuildError> { + let arcs = oid + .split('.') + .map(|part| part.parse::().map_err(|_| CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()))) + .collect::, _>>()?; + if arcs.len() < 2 { + return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string())); + } + if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) { + return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string())); + } + let mut body = Vec::new(); + body.push((arcs[0] * 40 + arcs[1]) as u8); + for arc in &arcs[2..] { + encode_base128(*arc, &mut body); + } + Ok(encode_tlv(0x06, body)) +} + +fn encode_base128(mut value: u64, out: &mut Vec) { + let mut tmp = vec![(value & 0x7F) as u8]; + value >>= 7; + while value > 0 { + tmp.push(((value & 0x7F) as u8) | 0x80); + value >>= 7; + } + tmp.reverse(); + out.extend_from_slice(&tmp); +} + +pub fn build_router_key_state( + router_certs: &[BgpsecRouterCertificate], +) -> Result { + let mut grouped: BTreeMap> = BTreeMap::new(); + for cert in router_certs { + let key = RouterKey { + ski: cert.subject_key_identifier.clone(), + spki_der: cert.spki_der.clone(), + }; + for asn in &cert.asns { + grouped.entry(*asn).or_default().insert(key.clone()); + } + } + + let rksets: Vec = grouped + .into_iter() + .map(|(as_id, router_keys)| RouterKeySet { + as_id, + router_keys: router_keys.into_iter().collect(), + }) + .collect(); + + let payload_der = encode_router_key_state_payload_der(&rksets) + .map_err(|e| CcrBuildError::RouterKeyEncode(e.to_string()))?; + Ok(RouterKeyState { + rksets, + hash: compute_state_hash(&payload_der), + }) +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct RoaPayloadKey { + afi: u16, + addr: Vec, + prefix_len: u8, + max_length: u8, +} + +impl RoaPayloadKey { + fn from_vrp(vrp: &Vrp) -> Self { + let afi = match vrp.prefix.afi { + RoaAfi::Ipv4 => 1, + RoaAfi::Ipv6 => 2, + }; + Self { + afi, + addr: vrp.prefix.addr.to_vec(), + prefix_len: vrp.prefix.prefix_len as u8, + max_length: vrp.max_length as u8, + } + } +} + +fn encode_roa_ip_address_family(afi: u16, entries: &[RoaPayloadKey]) -> Vec { + let address_family = afi.to_be_bytes(); + let addresses = entries + .iter() + .map(encode_roa_ip_address) + .collect::>(); + encode_sequence(&[ + encode_octet_string(&address_family), + encode_sequence(&addresses), + ]) +} + +fn encode_roa_ip_address(entry: &RoaPayloadKey) -> Vec { + let (unused_bits, content) = encode_prefix_bit_string(&entry.addr, entry.prefix_len); + let mut fields = vec![encode_bit_string(unused_bits, &content)]; + if entry.max_length != entry.prefix_len { + fields.push(encode_integer_u8(entry.max_length)); + } + encode_sequence(&fields) +} + +fn encode_prefix_bit_string(addr: &[u8], prefix_len: u8) -> (u8, Vec) { + if prefix_len == 0 { + return (0, Vec::new()); + } + let octets = ((prefix_len as usize) + 7) / 8; + let mut content = addr[..octets].to_vec(); + let rem = prefix_len % 8; + let unused = if rem == 0 { 0 } else { 8 - rem }; + if unused > 0 { + let mask = 0xFFu8 << unused; + let last = content.last_mut().expect("octets > 0"); + *last &= mask; + } + (unused, content) +} + +fn encode_integer_u8(v: u8) -> Vec { + encode_integer_bytes(vec![v]) +} + +fn encode_integer_bytes(mut bytes: Vec) -> Vec { + if bytes.is_empty() { + bytes.push(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + encode_tlv(0x02, bytes) +} + +fn encode_bit_string(unused_bits: u8, content: &[u8]) -> Vec { + let mut value = Vec::with_capacity(content.len() + 1); + value.push(unused_bits); + value.extend_from_slice(content); + encode_tlv(0x03, value) +} + +fn encode_octet_string(bytes: &[u8]) -> Vec { + encode_tlv(0x04, bytes.to_vec()) +} + +fn encode_sequence(elements: &[Vec]) -> Vec { + let total_len: usize = elements.iter().map(Vec::len).sum(); + let mut buf = Vec::with_capacity(total_len); + for element in elements { + buf.extend_from_slice(element); + } + encode_tlv(0x30, buf) +} + +fn encode_tlv(tag: u8, value: Vec) -> Vec { + let mut out = Vec::with_capacity(1 + 9 + value.len()); + out.push(tag); + encode_length(value.len(), &mut out); + out.extend_from_slice(&value); + out +} + +fn encode_length(len: usize, out: &mut Vec) { + if len < 0x80 { + out.push(len as u8); + return; + } + let mut bytes = Vec::new(); + let mut value = len; + while value > 0 { + bytes.push((value & 0xFF) as u8); + value >>= 8; + } + bytes.reverse(); + out.push(0x80 | (bytes.len() as u8)); + out.extend_from_slice(&bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ccr::decode::decode_content_info; + use crate::ccr::encode::{encode_aspa_payload_state, encode_content_info, encode_trust_anchor_state}; + use crate::ccr::model::{CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation}; + use crate::data_model::roa::{IpPrefix, RoaAfi}; + use crate::data_model::ta::TrustAnchor; + use crate::data_model::tal::Tal; + + fn sample_vrp_v4(asn: u32, a: [u8; 4], prefix_len: u16, max_length: u16) -> Vrp { + Vrp { + asn, + prefix: IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len, + addr: [a[0], a[1], a[2], a[3], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + max_length, + } + } + + fn sample_vrp_v6(asn: u32, a: [u8; 16], prefix_len: u16, max_length: u16) -> Vrp { + Vrp { + asn, + prefix: IpPrefix { + afi: RoaAfi::Ipv6, + prefix_len, + addr: a, + }, + max_length, + } + } + + fn decode_family_block(block: &[u8]) -> (u16, Vec<(u8, Vec, Option)>) { + let mut top = crate::data_model::common::DerReader::new(block); + let mut seq = top.take_sequence().expect("family seq"); + assert!(top.is_empty()); + let afi_bytes = seq.take_octet_string().expect("afi octet string"); + let afi = u16::from_be_bytes([afi_bytes[0], afi_bytes[1]]); + let mut addrs = seq.take_sequence().expect("addresses seq"); + let mut entries = Vec::new(); + while !addrs.is_empty() { + let mut addr_seq = addrs.take_sequence().expect("roa ip addr seq"); + let (unused_bits, content) = addr_seq.take_bit_string().expect("bit string"); + 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().expect("maxlen") as u8) + }; + entries.push((prefix_len, content.to_vec(), max_len)); + } + (afi, entries) + } + + fn sample_trust_anchor(path_tal: &str, path_ta: &str) -> TrustAnchor { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let tal_bytes = std::fs::read(base.join(path_tal)).expect("read tal"); + let ta_der = std::fs::read(base.join(path_ta)).expect("read ta"); + let tal = Tal::decode_bytes(&tal_bytes).expect("decode tal"); + TrustAnchor::bind_der(tal, &ta_der, None).expect("bind ta") + } + + fn sample_router_cert(asns: Vec, ski_fill: u8, spki_fill: u8) -> BgpsecRouterCertificate { + let ta = sample_trust_anchor( + "tests/fixtures/tal/apnic-rfc7730-https.tal", + "tests/fixtures/ta/apnic-ta.cer", + ); + BgpsecRouterCertificate { + raw_der: vec![0x01, 0x02], + resource_cert: ta.ta_certificate.rc_ca, + subject_key_identifier: vec![ski_fill; 20], + spki_der: vec![0x30, 0x03, 0x03, 0x01, spki_fill], + asns, + } + } + + fn sample_manifest_vcir( + manifest_uri: &str, + manifest_der: &[u8], + child_ski_hex: &str, + ) -> ValidatedCaInstanceResult { + let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture"); + let hash = hex::encode(sha2::Sha256::digest(manifest_der)); + let now = manifest.manifest.this_update; + let next = manifest.manifest.next_update; + crate::storage::ValidatedCaInstanceResult { + manifest_rsync_uri: manifest_uri.to_string(), + parent_manifest_rsync_uri: None, + tal_id: "test-tal".to_string(), + ca_subject_name: "CN=test".to_string(), + ca_ski: "11".repeat(20), + issuer_ski: "22".repeat(20), + last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime(now), + current_manifest_rsync_uri: manifest_uri.to_string(), + current_crl_rsync_uri: format!("{manifest_uri}.crl"), + validated_manifest_meta: crate::storage::ValidatedManifestMeta { + validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), + validated_manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime(now), + validated_manifest_next_update: crate::storage::PackTime::from_utc_offset_datetime(next), + }, + instance_gate: crate::storage::VcirInstanceGate { + manifest_next_update: crate::storage::PackTime::from_utc_offset_datetime(next), + current_crl_next_update: crate::storage::PackTime::from_utc_offset_datetime(next), + self_ca_not_after: crate::storage::PackTime::from_utc_offset_datetime(next), + instance_effective_until: crate::storage::PackTime::from_utc_offset_datetime(next), + }, + child_entries: vec![crate::storage::VcirChildEntry { + child_manifest_rsync_uri: format!("{manifest_uri}/child.mft"), + child_cert_rsync_uri: format!("{manifest_uri}/child.cer"), + child_cert_hash: "aa".repeat(32), + child_ski: child_ski_hex.to_string(), + child_rsync_base_uri: format!("{manifest_uri}/"), + child_publication_point_rsync_uri: format!("{manifest_uri}/"), + child_rrdp_notification_uri: None, + child_effective_ip_resources: None, + child_effective_as_resources: None, + accepted_at_validation_time: crate::storage::PackTime::from_utc_offset_datetime(now), + }], + local_outputs: Vec::new(), + related_artifacts: vec![crate::storage::VcirRelatedArtifact { + artifact_role: crate::storage::VcirArtifactRole::Manifest, + artifact_kind: crate::storage::VcirArtifactKind::Mft, + uri: Some(manifest_uri.to_string()), + sha256: hash, + object_type: Some("mft".to_string()), + validation_status: crate::storage::VcirArtifactValidationStatus::Accepted, + }], + summary: crate::storage::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: crate::storage::VcirAuditSummary { + failed_fetch_eligible: true, + last_failed_fetch_reason: None, + warning_count: 0, + audit_flags: Vec::new(), + }, + } + } + + #[test] + fn build_manifest_state_from_vcirs_collects_current_manifests_and_hashes_payload() { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_a = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest a"); + let manifest_b = std::fs::read(base.join("tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft")).expect("read manifest b"); + let vcir_a = sample_manifest_vcir("rsync://example.test/a.mft", &manifest_a, &"33".repeat(20)); + let vcir_b = sample_manifest_vcir("rsync://example.test/b.mft", &manifest_b, &"44".repeat(20)); + let store_dir = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] { + let artifact = &vcir.related_artifacts[0]; + let mut raw = crate::storage::RawByHashEntry::from_bytes(artifact.sha256.clone(), bytes.to_vec()); + raw.origin_uris.push(vcir.current_manifest_rsync_uri.clone()); + raw.object_type = Some("mft".to_string()); + raw.encoding = Some("der".to_string()); + store.put_raw_by_hash_entry(&raw).expect("put raw manifest"); + } + + let state = build_manifest_state_from_vcirs(&store, &[vcir_a.clone(), vcir_b.clone()]).expect("build manifest state"); + assert_eq!(state.mis.len(), 2); + assert!(state.mis[0].hash < state.mis[1].hash); + let expected_subordinates = [ + hex::decode(vcir_a.child_entries[0].child_ski.clone()).unwrap(), + hex::decode(vcir_b.child_entries[0].child_ski.clone()).unwrap(), + ]; + let actual_subordinates = state + .mis + .iter() + .map(|mi| { + assert_eq!(mi.subordinates.len(), 1); + assert!(!mi.locations.is_empty()); + mi.subordinates[0].clone() + }) + .collect::>(); + let expected_subordinates = expected_subordinates.into_iter().collect::>(); + assert_eq!(actual_subordinates, expected_subordinates); + let payload_der = encode_manifest_state_payload_der(&state.mis).expect("encode mis payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + let max_time = [ + vcir_a.validated_manifest_meta.validated_manifest_this_update.parse().unwrap(), + vcir_b.validated_manifest_meta.validated_manifest_this_update.parse().unwrap(), + ] + .into_iter() + .max() + .unwrap(); + assert_eq!(state.most_recent_update, max_time); + } + + #[test] + fn build_manifest_state_from_vcirs_empty_uses_epoch() { + let store_dir = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); + let state = build_manifest_state_from_vcirs(&store, &[]).expect("empty manifest state"); + assert!(state.mis.is_empty()); + assert_eq!(state.most_recent_update, time::OffsetDateTime::UNIX_EPOCH); + let payload_der = encode_manifest_state_payload_der(&state.mis).expect("encode mis payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + } + + #[test] + fn build_router_key_state_groups_dedupes_and_sorts() { + let certs = vec![ + sample_router_cert(vec![64497], 0x21, 0xA1), + sample_router_cert(vec![64496, 64497], 0x11, 0xA0), + sample_router_cert(vec![64496], 0x11, 0xA0), + sample_router_cert(vec![64496], 0x11, 0xA2), + ]; + let state = build_router_key_state(&certs).expect("build router key state"); + assert_eq!(state.rksets.len(), 2); + assert_eq!(state.rksets[0].as_id, 64496); + assert_eq!(state.rksets[1].as_id, 64497); + assert_eq!(state.rksets[0].router_keys.len(), 2); + assert_eq!(state.rksets[1].router_keys.len(), 2); + assert!(state.rksets[0].router_keys[0].ski <= state.rksets[0].router_keys[1].ski); + let payload_der = encode_router_key_state_payload_der(&state.rksets).expect("encode rk payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + } + + #[test] + fn build_router_key_state_empty_is_valid_and_hashes_empty_sequence() { + let state = build_router_key_state(&[]).expect("empty router key state"); + assert!(state.rksets.is_empty()); + let payload_der = encode_router_key_state_payload_der(&state.rksets).expect("encode rk payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + } + + #[test] + fn build_roa_payload_state_groups_dedupes_and_sorts() { + let vrps = vec![ + sample_vrp_v4(64497, [10, 1, 0, 0], 16, 16), + sample_vrp_v4(64496, [10, 0, 0, 0], 8, 8), + sample_vrp_v4(64496, [10, 0, 0, 0], 8, 8), + sample_vrp_v4(64496, [10, 1, 0, 0], 16, 24), + sample_vrp_v6(64496, [0x20,0x01,0x0d,0xb8,0,0,0,0,0,0,0,0,0,0,0,0], 32, 48), + ]; + let state = build_roa_payload_state(&vrps).expect("build roa state"); + assert_eq!(state.rps.len(), 2); + assert_eq!(state.rps[0].as_id, 64496); + assert_eq!(state.rps[1].as_id, 64497); + assert_eq!(state.rps[0].ip_addr_blocks.len(), 2); + let (afi4, entries4) = decode_family_block(&state.rps[0].ip_addr_blocks[0]); + let (afi6, entries6) = decode_family_block(&state.rps[0].ip_addr_blocks[1]); + assert_eq!(afi4, 1); + assert_eq!(afi6, 2); + assert_eq!(entries4.len(), 2); + assert_eq!(entries4[0], (8, vec![10], None)); + assert_eq!(entries4[1], (16, vec![10, 1], Some(24))); + assert_eq!(entries6.len(), 1); + assert_eq!(entries6[0], (32, vec![0x20,0x01,0x0d,0xb8], Some(48))); + let payload_der = encode_roa_payload_state_payload_der(&state.rps).expect("encode payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + } + + #[test] + fn build_roa_payload_state_empty_is_valid_and_hashes_empty_sequence() { + let state = build_roa_payload_state(&[]).expect("empty roa state"); + assert!(state.rps.is_empty()); + let payload_der = encode_roa_payload_state_payload_der(&state.rps).expect("encode payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + } + + #[test] + fn build_aspa_payload_state_merges_and_sorts() { + let aspas = vec![ + AspaAttestation { customer_as_id: 64497, provider_as_ids: vec![65002] }, + AspaAttestation { customer_as_id: 64496, provider_as_ids: vec![65003, 65001] }, + AspaAttestation { customer_as_id: 64496, provider_as_ids: vec![65002, 65001] }, + ]; + let state = build_aspa_payload_state(&aspas).expect("build aspa state"); + assert_eq!(state.aps.len(), 2); + assert_eq!(state.aps[0].customer_as_id, 64496); + assert_eq!(state.aps[0].providers, vec![65001, 65002, 65003]); + assert_eq!(state.aps[1].customer_as_id, 64497); + let encoded = encode_aspa_payload_state(&state).expect("encode aspa state"); + let decoded = decode_content_info(&encode_content_info(&CcrContentInfo::new( + RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: time::OffsetDateTime::now_utc(), + mfts: None, + vrps: None, + vaps: Some(state.clone()), + tas: None, + rks: None, + }, + )) + .expect("encode ccr")).expect("decode ccr"); + assert_eq!(decoded.content.vaps, Some(state)); + assert!(!encoded.is_empty()); + } + + #[test] + fn build_trust_anchor_state_collects_sorted_unique_skis() { + let apnic = sample_trust_anchor( + "tests/fixtures/tal/apnic-rfc7730-https.tal", + "tests/fixtures/ta/apnic-ta.cer", + ); + let arin = sample_trust_anchor( + "tests/fixtures/tal/arin.tal", + "tests/fixtures/ta/arin-ta.cer", + ); + let state = build_trust_anchor_state(&[apnic.clone(), arin.clone(), apnic]) + .expect("build ta state"); + assert_eq!(state.skis.len(), 2); + assert!(state.skis[0] < state.skis[1]); + let payload_der = encode_trust_anchor_state_payload_der(&state.skis).expect("encode ta payload"); + assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der)); + let encoded = encode_trust_anchor_state(&state).expect("encode ta state"); + assert!(!encoded.is_empty()); + } + + #[test] + fn build_trust_anchor_state_rejects_empty_and_missing_ski() { + let err = build_trust_anchor_state(&[]).expect_err("empty TAs must fail"); + assert!(err.to_string().contains("trust anchor set"), "{err}"); + + let mut ta = sample_trust_anchor( + "tests/fixtures/tal/apnic-rfc7730-https.tal", + "tests/fixtures/ta/apnic-ta.cer", + ); + ta.ta_certificate.rc_ca.tbs.extensions.subject_key_identifier = None; + let err = build_trust_anchor_state(&[ta]).expect_err("missing ski must fail"); + assert!(err.to_string().contains("SubjectKeyIdentifier"), "{err}"); + } +} diff --git a/src/ccr/decode.rs b/src/ccr/decode.rs new file mode 100644 index 0000000..58783af --- /dev/null +++ b/src/ccr/decode.rs @@ -0,0 +1,382 @@ +use crate::ccr::model::{ + AspaPayloadSet, AspaPayloadState, CCR_VERSION_V0, CcrContentInfo, CcrDigestAlgorithm, + ManifestInstance, ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, + RouterKeyState, RpkiCanonicalCacheRepresentation, TrustAnchorState, +}; +use crate::data_model::common::{BigUnsigned, DerReader}; +use crate::data_model::oid::{OID_CT_RPKI_CCR, OID_CT_RPKI_CCR_RAW, OID_SHA256, OID_SHA256_RAW}; +use der_parser::der::parse_der_oid; + +#[derive(Debug, thiserror::Error)] +pub enum CcrDecodeError { + #[error("DER parse error: {0}")] + Parse(String), + + #[error("unexpected contentType OID: expected {expected}, got {actual}")] + UnexpectedContentType { expected: &'static str, actual: String }, + + #[error("unexpected digest algorithm OID: expected {expected}, got {actual}")] + UnexpectedDigestAlgorithm { expected: &'static str, actual: String }, + + #[error("CCR model validation failed after decode: {0}")] + Validate(String), +} + +pub fn decode_content_info(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ContentInfo".into())); + } + let content_type_raw = seq.take_tag(0x06).map_err(CcrDecodeError::Parse)?; + if content_type_raw != OID_CT_RPKI_CCR_RAW { + return Err(CcrDecodeError::UnexpectedContentType { + expected: OID_CT_RPKI_CCR, + actual: oid_string(content_type_raw)?, + }); + } + let inner = seq.take_tag(0xA0).map_err(CcrDecodeError::Parse)?; + if !seq.is_empty() { + return Err(CcrDecodeError::Parse("trailing fields in ContentInfo".into())); + } + let content = decode_ccr(inner)?; + let ci = CcrContentInfo::new(content); + ci.validate().map_err(CcrDecodeError::Validate)?; + Ok(ci) +} + +pub fn decode_ccr(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after CCR".into())); + } + + let version = if !seq.is_empty() && seq.peek_tag().map_err(CcrDecodeError::Parse)? == 0xA0 { + let explicit = seq.take_tag(0xA0).map_err(CcrDecodeError::Parse)?; + let mut inner = DerReader::new(explicit); + let version = inner.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32; + if !inner.is_empty() { + return Err(CcrDecodeError::Parse( + "trailing bytes inside CCR version EXPLICIT".into(), + )); + } + version + } else { + CCR_VERSION_V0 + }; + + let hash_alg = decode_digest_algorithm(seq.take_sequence().map_err(CcrDecodeError::Parse)?)?; + let produced_at = parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?; + + let mut mfts = None; + let mut vrps = None; + let mut vaps = None; + let mut tas = None; + let mut rks = None; + while !seq.is_empty() { + let tag = seq.peek_tag().map_err(CcrDecodeError::Parse)?; + let (tag_read, value) = seq.take_any().map_err(CcrDecodeError::Parse)?; + debug_assert_eq!(tag, tag_read); + match tag { + 0xA1 => mfts = Some(decode_manifest_state(value)?), + 0xA2 => vrps = Some(decode_roa_payload_state(value)?), + 0xA3 => vaps = Some(decode_aspa_payload_state(value)?), + 0xA4 => tas = Some(decode_trust_anchor_state(value)?), + 0xA5 => rks = Some(decode_router_key_state(value)?), + _ => { + return Err(CcrDecodeError::Parse(format!( + "unexpected CCR field tag 0x{tag:02X}" + ))) + } + } + } + + let ccr = RpkiCanonicalCacheRepresentation { + version, + hash_alg, + produced_at, + mfts, + vrps, + vaps, + tas, + rks, + }; + ccr.validate().map_err(CcrDecodeError::Validate)?; + Ok(ccr) +} + +fn decode_manifest_state(explicit_der: &[u8]) -> Result { + let mut outer = DerReader::new(explicit_der); + let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?; + if !outer.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ManifestState".into())); + } + let mis_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut mis_reader = DerReader::new(mis_der); + let mut mis = Vec::new(); + while !mis_reader.is_empty() { + let (_tag, full, _value) = mis_reader.take_any_full().map_err(CcrDecodeError::Parse)?; + mis.push(decode_manifest_instance(full)?); + } + let most_recent_update = parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?; + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + if !seq.is_empty() { + return Err(CcrDecodeError::Parse("trailing fields in ManifestState".into())); + } + Ok(ManifestState { mis, most_recent_update, hash }) +} + +fn decode_manifest_instance(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ManifestInstance".into())); + } + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + let size = seq.take_uint_u64().map_err(CcrDecodeError::Parse)?; + let aki = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + let manifest_number = decode_big_unsigned(seq.take_tag(0x02).map_err(CcrDecodeError::Parse)?)?; + let this_update = parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?; + let locations_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut locations_reader = DerReader::new(locations_der); + let mut locations = Vec::new(); + while !locations_reader.is_empty() { + let (_tag, full, _value) = locations_reader.take_any_full().map_err(CcrDecodeError::Parse)?; + locations.push(full.to_vec()); + } + let subordinates = if !seq.is_empty() { + let subordinate_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(subordinate_der); + let mut out = Vec::new(); + while !reader.is_empty() { + out.push(reader.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec()); + } + out + } else { + Vec::new() + }; + Ok(ManifestInstance { hash, size, aki, manifest_number, this_update, locations, subordinates }) +} + +fn decode_roa_payload_state(explicit_der: &[u8]) -> Result { + let mut outer = DerReader::new(explicit_der); + let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?; + if !outer.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ROAPayloadState".into())); + } + let payload_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(payload_der); + let mut rps = Vec::new(); + while !reader.is_empty() { + let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?; + rps.push(decode_roa_payload_set(full)?); + } + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + Ok(RoaPayloadState { rps, hash }) +} + +fn decode_roa_payload_set(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ROAPayloadSet".into())); + } + let as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32; + let blocks_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(blocks_der); + let mut ip_addr_blocks = Vec::new(); + while !reader.is_empty() { + let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?; + ip_addr_blocks.push(full.to_vec()); + } + Ok(RoaPayloadSet { as_id, ip_addr_blocks }) +} + +fn decode_aspa_payload_state(explicit_der: &[u8]) -> Result { + let mut outer = DerReader::new(explicit_der); + let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?; + if !outer.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ASPAPayloadState".into())); + } + let payload_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(payload_der); + let mut aps = Vec::new(); + while !reader.is_empty() { + let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?; + aps.push(decode_aspa_payload_set(full)?); + } + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + Ok(AspaPayloadState { aps, hash }) +} + +fn decode_aspa_payload_set(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after ASPAPayloadSet".into())); + } + let customer_as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32; + let providers_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(providers_der); + let mut providers = Vec::new(); + while !reader.is_empty() { + providers.push(reader.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32); + } + Ok(AspaPayloadSet { customer_as_id, providers }) +} + +fn decode_trust_anchor_state(explicit_der: &[u8]) -> Result { + let mut outer = DerReader::new(explicit_der); + let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?; + if !outer.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after TrustAnchorState".into())); + } + let skis_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(skis_der); + let mut skis = Vec::new(); + while !reader.is_empty() { + skis.push(reader.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec()); + } + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + Ok(TrustAnchorState { skis, hash }) +} + +fn decode_router_key_state(explicit_der: &[u8]) -> Result { + let mut outer = DerReader::new(explicit_der); + let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?; + if !outer.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after RouterKeyState".into())); + } + let sets_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(sets_der); + let mut rksets = Vec::new(); + while !reader.is_empty() { + let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?; + rksets.push(decode_router_key_set(full)?); + } + let hash = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + Ok(RouterKeyState { rksets, hash }) +} + +fn decode_router_key_set(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after RouterKeySet".into())); + } + let as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32; + let keys_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?; + let mut reader = DerReader::new(keys_der); + let mut router_keys = Vec::new(); + while !reader.is_empty() { + let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?; + router_keys.push(decode_router_key(full)?); + } + Ok(RouterKeySet { as_id, router_keys }) +} + +fn decode_router_key(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?; + if !top.is_empty() { + return Err(CcrDecodeError::Parse("trailing bytes after RouterKey".into())); + } + let ski = seq.take_octet_string().map_err(CcrDecodeError::Parse)?.to_vec(); + let (_tag, full, _value) = seq.take_any_full().map_err(CcrDecodeError::Parse)?; + if !seq.is_empty() { + return Err(CcrDecodeError::Parse("trailing fields in RouterKey".into())); + } + Ok(RouterKey { ski, spki_der: full.to_vec() }) +} + +fn decode_digest_algorithm(mut seq: DerReader<'_>) -> Result { + let oid_raw = seq.take_tag(0x06).map_err(CcrDecodeError::Parse)?; + if oid_raw != OID_SHA256_RAW { + return Err(CcrDecodeError::UnexpectedDigestAlgorithm { + expected: OID_SHA256, + actual: oid_string(oid_raw)?, + }); + } + if !seq.is_empty() { + let tag = seq.peek_tag().map_err(CcrDecodeError::Parse)?; + if tag == 0x05 { + let null = seq.take_tag(0x05).map_err(CcrDecodeError::Parse)?; + if !null.is_empty() { + return Err(CcrDecodeError::Parse( + "AlgorithmIdentifier NULL parameters must be empty".into(), + )); + } + } + } + if !seq.is_empty() { + return Err(CcrDecodeError::Parse( + "trailing fields in DigestAlgorithmIdentifier".into(), + )); + } + Ok(CcrDigestAlgorithm::Sha256) +} + +fn oid_string(raw_body: &[u8]) -> Result { + let der = { + let mut out = Vec::with_capacity(raw_body.len() + 2); + out.push(0x06); + if raw_body.len() < 0x80 { + out.push(raw_body.len() as u8); + } else { + return Err(CcrDecodeError::Parse("OID too long".into())); + } + out.extend_from_slice(raw_body); + out + }; + let (_rem, oid) = parse_der_oid(&der).map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + let oid = oid + .as_oid_val() + .map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + Ok(oid.to_string()) +} + +fn parse_generalized_time(bytes: &[u8]) -> Result { + let s = std::str::from_utf8(bytes).map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + if s.len() != 15 || !s.ends_with('Z') { + return Err(CcrDecodeError::Parse( + "GeneralizedTime must be YYYYMMDDHHMMSSZ".into(), + )); + } + let parse = |range: std::ops::Range| -> Result { + s[range] + .parse::() + .map_err(|e| CcrDecodeError::Parse(e.to_string())) + }; + let year = parse(0..4)? as i32; + let month = parse(4..6)? as u8; + let day = parse(6..8)? as u8; + let hour = parse(8..10)? as u8; + let minute = parse(10..12)? as u8; + let second = parse(12..14)? as u8; + let month = time::Month::try_from(month) + .map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + let date = time::Date::from_calendar_date(year, month, day) + .map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + let timev = time::Time::from_hms(hour, minute, second) + .map_err(|e| CcrDecodeError::Parse(e.to_string()))?; + Ok(time::PrimitiveDateTime::new(date, timev).assume_utc()) +} + +fn decode_big_unsigned(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(CcrDecodeError::Parse("INTEGER has empty content".into())); + } + if bytes[0] & 0x80 != 0 { + return Err(CcrDecodeError::Parse("INTEGER must be non-negative".into())); + } + if bytes.len() > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) == 0 { + return Err(CcrDecodeError::Parse("INTEGER not minimally encoded".into())); + } + let bytes_be = if bytes.len() > 1 && bytes[0] == 0x00 { + bytes[1..].to_vec() + } else { + bytes.to_vec() + }; + Ok(BigUnsigned { bytes_be }) +} diff --git a/src/ccr/dump.rs b/src/ccr/dump.rs new file mode 100644 index 0000000..79a6461 --- /dev/null +++ b/src/ccr/dump.rs @@ -0,0 +1,88 @@ +use crate::ccr::decode::{CcrDecodeError, decode_content_info}; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum CcrDumpError { + #[error("CCR decode failed: {0}")] + Decode(#[from] CcrDecodeError), + + #[error("format time failed: {0}")] + FormatTime(String), +} + +pub fn dump_content_info_json_value(der: &[u8]) -> Result { + let content_info = decode_content_info(der)?; + dump_content_info_json(&content_info) +} + +pub fn dump_content_info_json( + content_info: &crate::ccr::model::CcrContentInfo, +) -> Result { + let produced_at = content_info + .content + .produced_at + .to_offset(time::UtcOffset::UTC) + .format(&time::format_description::well_known::Rfc3339) + .map_err(|e| CcrDumpError::FormatTime(e.to_string()))?; + + let mfts = content_info.content.mfts.as_ref().map(|state| { + json!({ + "present": true, + "manifest_instances": state.mis.len(), + "most_recent_update_rfc3339_utc": state.most_recent_update.to_offset(time::UtcOffset::UTC).format(&time::format_description::well_known::Rfc3339).unwrap(), + "hash_hex": hex::encode(&state.hash), + }) + }).unwrap_or_else(|| json!({"present": false})); + + let vrps_total = content_info.content.vrps.as_ref().map(|state| { + state.rps.iter().map(|set| set.ip_addr_blocks.len()).sum::() + }).unwrap_or(0); + let vrps = content_info.content.vrps.as_ref().map(|state| { + json!({ + "present": true, + "payload_sets": state.rps.len(), + "hash_hex": hex::encode(&state.hash), + "ip_addr_block_count": vrps_total, + }) + }).unwrap_or_else(|| json!({"present": false})); + + let vaps = content_info.content.vaps.as_ref().map(|state| { + json!({ + "present": true, + "payload_sets": state.aps.len(), + "hash_hex": hex::encode(&state.hash), + "provider_count": state.aps.iter().map(|set| set.providers.len()).sum::(), + }) + }).unwrap_or_else(|| json!({"present": false})); + + let tas = content_info.content.tas.as_ref().map(|state| { + json!({ + "present": true, + "ski_count": state.skis.len(), + "hash_hex": hex::encode(&state.hash), + }) + }).unwrap_or_else(|| json!({"present": false})); + + let rks = content_info.content.rks.as_ref().map(|state| { + json!({ + "present": true, + "router_key_sets": state.rksets.len(), + "router_key_count": state.rksets.iter().map(|set| set.router_keys.len()).sum::(), + "hash_hex": hex::encode(&state.hash), + }) + }).unwrap_or_else(|| json!({"present": false})); + + Ok(json!({ + "content_type_oid": content_info.content_type_oid, + "version": content_info.content.version, + "hash_alg": content_info.content.hash_alg.oid(), + "produced_at_rfc3339_utc": produced_at, + "state_aspects": { + "mfts": mfts, + "vrps": vrps, + "vaps": vaps, + "tas": tas, + "rks": rks, + } + })) +} diff --git a/src/ccr/encode.rs b/src/ccr/encode.rs new file mode 100644 index 0000000..863c018 --- /dev/null +++ b/src/ccr/encode.rs @@ -0,0 +1,304 @@ +use crate::ccr::model::{ + AspaPayloadSet, AspaPayloadState, CCR_VERSION_V0, CcrContentInfo, CcrDigestAlgorithm, + ManifestInstance, ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, + RouterKeyState, RpkiCanonicalCacheRepresentation, TrustAnchorState, +}; +use crate::data_model::common::BigUnsigned; +use crate::data_model::oid::{OID_CT_RPKI_CCR_RAW, OID_SHA256_RAW}; + +#[derive(Debug, thiserror::Error)] +pub enum CcrEncodeError { + #[error("CCR model validation failed: {0}")] + Validate(String), + + #[error("GeneralizedTime formatting failed: {0}")] + ProducedAtFormat(String), +} + +pub fn encode_content_info(content_info: &CcrContentInfo) -> Result, CcrEncodeError> { + content_info.validate().map_err(CcrEncodeError::Validate)?; + let content_der = encode_ccr(&content_info.content)?; + Ok(encode_sequence(&[ + encode_oid(OID_CT_RPKI_CCR_RAW), + encode_explicit(0, &content_der), + ])) +} + +pub fn encode_ccr( + ccr: &RpkiCanonicalCacheRepresentation, +) -> Result, CcrEncodeError> { + ccr.validate().map_err(CcrEncodeError::Validate)?; + let mut fields = Vec::new(); + if ccr.version != CCR_VERSION_V0 { + fields.push(encode_explicit(0, &encode_integer_u32(ccr.version))); + } + fields.push(encode_digest_algorithm(&ccr.hash_alg)); + fields.push(encode_generalized_time(ccr.produced_at)?); + if let Some(mfts) = &ccr.mfts { + fields.push(encode_explicit(1, &encode_manifest_state(mfts)?)); + } + if let Some(vrps) = &ccr.vrps { + fields.push(encode_explicit(2, &encode_roa_payload_state(vrps)?)); + } + if let Some(vaps) = &ccr.vaps { + fields.push(encode_explicit(3, &encode_aspa_payload_state(vaps)?)); + } + if let Some(tas) = &ccr.tas { + fields.push(encode_explicit(4, &encode_trust_anchor_state(tas)?)); + } + if let Some(rks) = &ccr.rks { + fields.push(encode_explicit(5, &encode_router_key_state(rks)?)); + } + Ok(encode_sequence(&fields)) +} + +pub fn encode_manifest_state(state: &ManifestState) -> Result, CcrEncodeError> { + state.validate().map_err(CcrEncodeError::Validate)?; + let mis = encode_manifest_state_payload_der(&state.mis)?; + Ok(encode_sequence(&[ + mis, + encode_generalized_time(state.most_recent_update)?, + encode_octet_string(&state.hash), + ])) +} + +pub fn encode_manifest_state_payload_der( + instances: &[ManifestInstance], +) -> Result, CcrEncodeError> { + Ok(encode_sequence( + &instances + .iter() + .map(encode_manifest_instance) + .collect::, _>>()?, + )) +} + +fn encode_manifest_instance(instance: &ManifestInstance) -> Result, CcrEncodeError> { + instance.validate().map_err(CcrEncodeError::Validate)?; + let mut fields = vec![ + encode_octet_string(&instance.hash), + encode_integer_u64(instance.size), + encode_octet_string(&instance.aki), + encode_integer_bigunsigned(&instance.manifest_number), + encode_generalized_time(instance.this_update)?, + encode_sequence(&instance.locations), + ]; + if !instance.subordinates.is_empty() { + fields.push(encode_sequence( + &instance + .subordinates + .iter() + .map(|ski| encode_octet_string(ski)) + .collect::>(), + )); + } + Ok(encode_sequence(&fields)) +} + +pub fn encode_roa_payload_state(state: &RoaPayloadState) -> Result, CcrEncodeError> { + state.validate().map_err(CcrEncodeError::Validate)?; + let rps = encode_roa_payload_state_payload_der(&state.rps)?; + Ok(encode_sequence(&[rps, encode_octet_string(&state.hash)])) +} + +pub fn encode_roa_payload_state_payload_der( + sets: &[RoaPayloadSet], +) -> Result, CcrEncodeError> { + Ok(encode_sequence( + &sets + .iter() + .map(encode_roa_payload_set) + .collect::, _>>()?, + )) +} + +fn encode_roa_payload_set(set: &RoaPayloadSet) -> Result, CcrEncodeError> { + set.validate().map_err(CcrEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_integer_u32(set.as_id), + encode_sequence(&set.ip_addr_blocks), + ])) +} + +pub fn encode_aspa_payload_state(state: &AspaPayloadState) -> Result, CcrEncodeError> { + state.validate().map_err(CcrEncodeError::Validate)?; + let aps = encode_aspa_payload_state_payload_der(&state.aps)?; + Ok(encode_sequence(&[aps, encode_octet_string(&state.hash)])) +} + +pub fn encode_aspa_payload_state_payload_der( + sets: &[AspaPayloadSet], +) -> Result, CcrEncodeError> { + Ok(encode_sequence( + &sets + .iter() + .map(encode_aspa_payload_set) + .collect::, _>>()?, + )) +} + +fn encode_aspa_payload_set(set: &AspaPayloadSet) -> Result, CcrEncodeError> { + set.validate().map_err(CcrEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_integer_u32(set.customer_as_id), + encode_sequence( + &set.providers + .iter() + .map(|provider| encode_integer_u32(*provider)) + .collect::>(), + ), + ])) +} + +pub fn encode_trust_anchor_state(state: &TrustAnchorState) -> Result, CcrEncodeError> { + state.validate().map_err(CcrEncodeError::Validate)?; + let skis = encode_trust_anchor_state_payload_der(&state.skis)?; + Ok(encode_sequence(&[skis, encode_octet_string(&state.hash)])) +} + +pub fn encode_trust_anchor_state_payload_der( + skis: &[Vec], +) -> Result, CcrEncodeError> { + Ok(encode_sequence( + &skis.iter().map(|ski| encode_octet_string(ski)).collect::>(), + )) +} + +pub fn encode_router_key_state(state: &RouterKeyState) -> Result, CcrEncodeError> { + state.validate().map_err(CcrEncodeError::Validate)?; + let rksets = encode_router_key_state_payload_der(&state.rksets)?; + Ok(encode_sequence(&[rksets, encode_octet_string(&state.hash)])) +} + +pub fn encode_router_key_state_payload_der( + sets: &[RouterKeySet], +) -> Result, CcrEncodeError> { + Ok(encode_sequence( + &sets + .iter() + .map(encode_router_key_set) + .collect::, _>>()?, + )) +} + +fn encode_router_key_set(set: &RouterKeySet) -> Result, CcrEncodeError> { + set.validate().map_err(CcrEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_integer_u32(set.as_id), + encode_sequence( + &set.router_keys + .iter() + .map(encode_router_key) + .collect::, _>>()?, + ), + ])) +} + +fn encode_router_key(key: &RouterKey) -> Result, CcrEncodeError> { + key.validate().map_err(CcrEncodeError::Validate)?; + Ok(encode_sequence(&[ + encode_octet_string(&key.ski), + key.spki_der.clone(), + ])) +} + +fn encode_digest_algorithm(alg: &CcrDigestAlgorithm) -> Vec { + match alg { + CcrDigestAlgorithm::Sha256 => encode_sequence(&[encode_oid(OID_SHA256_RAW)]), + } +} + +fn encode_generalized_time(t: time::OffsetDateTime) -> Result, CcrEncodeError> { + let t = t.to_offset(time::UtcOffset::UTC); + let s = format!( + "{:04}{:02}{:02}{:02}{:02}{:02}Z", + t.year(), + u8::from(t.month()), + t.day(), + t.hour(), + t.minute(), + t.second() + ); + Ok(encode_tlv(0x18, s.into_bytes())) +} + +fn encode_integer_u32(v: u32) -> Vec { + encode_integer_bytes(unsigned_integer_bytes(v as u64)) +} + +fn encode_integer_u64(v: u64) -> Vec { + encode_integer_bytes(unsigned_integer_bytes(v)) +} + +fn encode_integer_bigunsigned(v: &BigUnsigned) -> Vec { + encode_integer_bytes(v.bytes_be.clone()) +} + +fn encode_integer_bytes(mut bytes: Vec) -> Vec { + if bytes.is_empty() { + bytes.push(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + encode_tlv(0x02, bytes) +} + +fn unsigned_integer_bytes(v: u64) -> Vec { + if v == 0 { + return vec![0]; + } + let mut out = Vec::new(); + let mut n = v; + while n > 0 { + out.push((n & 0xFF) as u8); + n >>= 8; + } + out.reverse(); + out +} + +fn encode_oid(raw_body: &[u8]) -> Vec { + encode_tlv(0x06, raw_body.to_vec()) +} + +fn encode_octet_string(bytes: &[u8]) -> Vec { + encode_tlv(0x04, bytes.to_vec()) +} + +fn encode_explicit(tag_number: u8, inner_der: &[u8]) -> Vec { + encode_tlv(0xA0 + tag_number, inner_der.to_vec()) +} + +fn encode_sequence(elements: &[Vec]) -> Vec { + let total_len: usize = elements.iter().map(Vec::len).sum(); + let mut buf = Vec::with_capacity(total_len); + for element in elements { + buf.extend_from_slice(element); + } + encode_tlv(0x30, buf) +} + +fn encode_tlv(tag: u8, value: Vec) -> Vec { + let mut out = Vec::with_capacity(1 + 9 + value.len()); + out.push(tag); + encode_length(value.len(), &mut out); + out.extend_from_slice(&value); + out +} + +fn encode_length(len: usize, out: &mut Vec) { + if len < 0x80 { + out.push(len as u8); + return; + } + let mut bytes = Vec::new(); + let mut value = len; + while value > 0 { + bytes.push((value & 0xFF) as u8); + value >>= 8; + } + bytes.reverse(); + out.push(0x80 | (bytes.len() as u8)); + out.extend_from_slice(&bytes); +} diff --git a/src/ccr/export.rs b/src/ccr/export.rs new file mode 100644 index 0000000..9e7011c --- /dev/null +++ b/src/ccr/export.rs @@ -0,0 +1,234 @@ +use crate::ccr::build::{ + CcrBuildError, build_aspa_payload_state, build_manifest_state_from_vcirs, + build_roa_payload_state, build_trust_anchor_state, +}; +use crate::ccr::encode::{CcrEncodeError, encode_content_info}; +use crate::ccr::model::{CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation}; +use crate::data_model::ta::TrustAnchor; +use crate::storage::RocksStore; +use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum CcrExportError { + #[error("list VCIRs failed: {0}")] + ListVcirs(String), + + #[error("build CCR state failed: {0}")] + Build(#[from] CcrBuildError), + + #[error("encode CCR failed: {0}")] + Encode(#[from] CcrEncodeError), + + #[error("write CCR file failed: {0}: {1}")] + Write(String, String), +} + +pub fn build_ccr_from_run( + store: &RocksStore, + trust_anchors: &[TrustAnchor], + vrps: &[Vrp], + aspas: &[AspaAttestation], + router_keys: &[RouterKeyPayload], + produced_at: time::OffsetDateTime, +) -> Result { + let vcirs = store + .list_vcirs() + .map_err(|e| CcrExportError::ListVcirs(e.to_string()))?; + + let mfts = Some(build_manifest_state_from_vcirs(store, &vcirs)?); + let vrps = Some(build_roa_payload_state(vrps)?); + let vaps = Some(build_aspa_payload_state(aspas)?); + let tas = Some(build_trust_anchor_state(trust_anchors)?); + let rks = Some(build_router_key_state_from_runtime(router_keys)?); + + Ok(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at, + mfts, + vrps, + vaps, + tas, + rks, + }) +} + + +fn build_router_key_state_from_runtime( + router_keys: &[RouterKeyPayload], +) -> Result { + use crate::ccr::model::{RouterKey, RouterKeySet}; + use std::collections::{BTreeMap, BTreeSet}; + + let mut grouped: BTreeMap> = BTreeMap::new(); + for router_key in router_keys { + grouped + .entry(router_key.as_id) + .or_default() + .insert(RouterKey { + ski: router_key.ski.clone(), + spki_der: router_key.spki_der.clone(), + }); + } + let rksets = grouped + .into_iter() + .map(|(as_id, router_keys)| RouterKeySet { + as_id, + router_keys: router_keys.into_iter().collect(), + }) + .collect::>(); + build_router_key_state_from_sets(&rksets) +} + +fn build_router_key_state_from_sets( + rksets: &[crate::ccr::model::RouterKeySet], +) -> Result { + let der = crate::ccr::encode::encode_router_key_state_payload_der(rksets) + .map_err(|e| CcrBuildError::RouterKeyEncode(e.to_string()))?; + Ok(crate::ccr::model::RouterKeyState { + rksets: rksets.to_vec(), + hash: crate::ccr::compute_state_hash(&der), + }) +} + +pub fn write_ccr_file( + path: &Path, + ccr: &RpkiCanonicalCacheRepresentation, +) -> Result<(), CcrExportError> { + let der = encode_content_info(&CcrContentInfo::new(ccr.clone()))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| CcrExportError::Write(path.display().to_string(), e.to_string()))?; + } + std::fs::write(path, der) + .map_err(|e| CcrExportError::Write(path.display().to_string(), e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ccr::decode::decode_content_info; + use crate::data_model::ta::TrustAnchor; + use crate::data_model::tal::Tal; + use crate::storage::{RawByHashEntry, RocksStore, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, VcirChildEntry, VcirInstanceGate, VcirRelatedArtifact, VcirSummary, ValidatedCaInstanceResult, ValidatedManifestMeta, PackTime}; + use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp}; + use crate::data_model::manifest::ManifestObject; + use crate::data_model::roa::{IpPrefix, RoaAfi}; + use sha2::Digest; + + fn sample_trust_anchor() -> TrustAnchor { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal")).expect("read tal"); + let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).expect("read ta"); + let tal = Tal::decode_bytes(&tal_bytes).expect("decode tal"); + TrustAnchor::bind_der(tal, &ta_der, None).expect("bind ta") + } + + fn sample_vcir_and_raw(store: &RocksStore) -> ValidatedCaInstanceResult { + let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_der = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest"); + let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest"); + let hash = hex::encode(sha2::Sha256::digest(&manifest_der)); + let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone()); + raw.origin_uris.push("rsync://example.test/repo/current.mft".to_string()); + raw.object_type = Some("mft".to_string()); + raw.encoding = Some("der".to_string()); + store.put_raw_by_hash_entry(&raw).expect("put raw"); + ValidatedCaInstanceResult { + manifest_rsync_uri: "rsync://example.test/repo/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(manifest.manifest.this_update), + current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(), + validated_manifest_meta: ValidatedManifestMeta { + validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), + validated_manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update), + validated_manifest_next_update: PackTime::from_utc_offset_datetime(manifest.manifest.next_update), + }, + instance_gate: VcirInstanceGate { + manifest_next_update: PackTime::from_utc_offset_datetime(manifest.manifest.next_update), + current_crl_next_update: PackTime::from_utc_offset_datetime(manifest.manifest.next_update), + self_ca_not_after: PackTime::from_utc_offset_datetime(manifest.manifest.next_update), + instance_effective_until: PackTime::from_utc_offset_datetime(manifest.manifest.next_update), + }, + child_entries: vec![VcirChildEntry { + child_manifest_rsync_uri: "rsync://example.test/repo/child.mft".to_string(), + child_cert_rsync_uri: "rsync://example.test/repo/child.cer".to_string(), + child_cert_hash: "aa".repeat(32), + child_ski: "33".repeat(20), + child_rsync_base_uri: "rsync://example.test/repo/".to_string(), + child_publication_point_rsync_uri: "rsync://example.test/repo/".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(manifest.manifest.this_update), + }], + local_outputs: Vec::new(), + related_artifacts: vec![VcirRelatedArtifact { + artifact_role: VcirArtifactRole::Manifest, + artifact_kind: VcirArtifactKind::Mft, + uri: Some("rsync://example.test/repo/current.mft".to_string()), + sha256: hash, + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }], + 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(), + }, + } + } + + #[test] + fn build_and_write_ccr_from_run_exports_der_content_info() { + let td = tempfile::tempdir().expect("tempdir"); + let db_path = td.path().join("db"); + let store = RocksStore::open(&db_path).expect("open rocksdb"); + let vcir = sample_vcir_and_raw(&store); + store.put_vcir(&vcir).expect("put vcir"); + let trust_anchor = sample_trust_anchor(); + let vrps = vec![Vrp { + asn: 64496, + prefix: IpPrefix { afi: RoaAfi::Ipv4, prefix_len: 8, addr: [10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] }, + max_length: 8, + }]; + let aspas = vec![AspaAttestation { customer_as_id: 64496, provider_as_ids: vec![64497] }]; + let router_keys = vec![RouterKeyPayload { + as_id: 64496, + ski: vec![0x11; 20], + spki_der: vec![0x30, 0x00], + source_object_uri: "rsync://example.test/repo/router.cer".to_string(), + source_object_hash: hex::encode([0x11; 32]), + source_ee_cert_hash: hex::encode([0x11; 32]), + item_effective_until: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc() + time::Duration::hours(1)), + }]; + let ccr = build_ccr_from_run(&store, &[trust_anchor], &vrps, &aspas, &router_keys, time::OffsetDateTime::now_utc()).expect("build ccr"); + assert!(ccr.mfts.is_some()); + assert!(ccr.vrps.is_some()); + assert!(ccr.vaps.is_some()); + assert!(ccr.tas.is_some()); + assert!(ccr.rks.is_some()); + assert_eq!(ccr.rks.as_ref().unwrap().rksets.len(), 1); + let out = td.path().join("out/example.ccr"); + write_ccr_file(&out, &ccr).expect("write ccr"); + let der = std::fs::read(&out).expect("read ccr file"); + let decoded = decode_content_info(&der).expect("decode ccr"); + assert_eq!(decoded.content.version, 0); + assert!(decoded.content.mfts.is_some()); + } +} diff --git a/src/ccr/hash.rs b/src/ccr/hash.rs new file mode 100644 index 0000000..23b203a --- /dev/null +++ b/src/ccr/hash.rs @@ -0,0 +1,9 @@ +use sha2::Digest; + +pub fn compute_state_hash(payload_der: &[u8]) -> Vec { + sha2::Sha256::digest(payload_der).to_vec() +} + +pub fn verify_state_hash(expected: &[u8], payload_der: &[u8]) -> bool { + compute_state_hash(payload_der).as_slice() == expected +} diff --git a/src/ccr/mod.rs b/src/ccr/mod.rs new file mode 100644 index 0000000..3beab8b --- /dev/null +++ b/src/ccr/mod.rs @@ -0,0 +1,25 @@ +pub mod build; +pub mod decode; +pub mod encode; +pub mod export; +pub mod verify; +pub mod dump; +pub mod hash; +pub mod model; + +pub use build::{ + CcrBuildError, build_aspa_payload_state, build_manifest_state_from_vcirs, + build_roa_payload_state, build_trust_anchor_state, +}; +pub use decode::{CcrDecodeError, decode_content_info}; +pub use encode::{CcrEncodeError, encode_content_info}; +pub use export::{CcrExportError, build_ccr_from_run, write_ccr_file}; +pub use dump::{CcrDumpError, dump_content_info_json, dump_content_info_json_value}; +pub use verify::{CcrVerifyError, CcrVerifySummary, extract_vrp_rows, verify_against_report_json_path, verify_against_vcir_store, verify_against_vcir_store_path, verify_content_info, verify_content_info_bytes}; +pub use hash::{compute_state_hash, verify_state_hash}; +pub use model::{ + AspaPayloadSet, AspaPayloadState, CcrContentInfo, CcrDigestAlgorithm, + ManifestInstance, ManifestState, RoaPayloadSet, RoaPayloadState, + RouterKey, RouterKeySet, RouterKeyState, RpkiCanonicalCacheRepresentation, + TrustAnchorState, +}; diff --git a/src/ccr/model.rs b/src/ccr/model.rs new file mode 100644 index 0000000..bf04be4 --- /dev/null +++ b/src/ccr/model.rs @@ -0,0 +1,395 @@ +use crate::data_model::common::{BigUnsigned, der_take_tlv}; +use crate::data_model::oid::{OID_CT_RPKI_CCR, OID_SHA256}; + +pub const CCR_VERSION_V0: u32 = 0; +pub const DIGEST_LEN_SHA256: usize = 32; +pub const KEY_IDENTIFIER_LEN_SHA1: usize = 20; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CcrDigestAlgorithm { + Sha256, +} + +impl CcrDigestAlgorithm { + pub fn oid(&self) -> &'static str { + match self { + Self::Sha256 => OID_SHA256, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CcrContentInfo { + pub content_type_oid: String, + pub content: RpkiCanonicalCacheRepresentation, +} + +impl CcrContentInfo { + pub fn new(content: RpkiCanonicalCacheRepresentation) -> Self { + Self { + content_type_oid: OID_CT_RPKI_CCR.to_string(), + content, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.content_type_oid != OID_CT_RPKI_CCR { + return Err(format!( + "contentType must be {OID_CT_RPKI_CCR}, got {}", + self.content_type_oid + )); + } + self.content.validate() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RpkiCanonicalCacheRepresentation { + pub version: u32, + pub hash_alg: CcrDigestAlgorithm, + pub produced_at: time::OffsetDateTime, + pub mfts: Option, + pub vrps: Option, + pub vaps: Option, + pub tas: Option, + pub rks: Option, +} + +impl RpkiCanonicalCacheRepresentation { + pub fn validate(&self) -> Result<(), String> { + if self.version != CCR_VERSION_V0 { + return Err(format!("CCR version must be 0, got {}", self.version)); + } + if !matches!(self.hash_alg, CcrDigestAlgorithm::Sha256) { + return Err("CCR hashAlg must be SHA-256".into()); + } + if self.mfts.is_none() + && self.vrps.is_none() + && self.vaps.is_none() + && self.tas.is_none() + && self.rks.is_none() + { + return Err("at least one of mfts/vrps/vaps/tas/rks must be present".into()); + } + if let Some(mfts) = &self.mfts { + mfts.validate()?; + } + if let Some(vrps) = &self.vrps { + vrps.validate()?; + } + if let Some(vaps) = &self.vaps { + vaps.validate()?; + } + if let Some(tas) = &self.tas { + tas.validate()?; + } + if let Some(rks) = &self.rks { + rks.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestState { + pub mis: Vec, + pub most_recent_update: time::OffsetDateTime, + pub hash: Vec, +} + +impl ManifestState { + pub fn validate(&self) -> Result<(), String> { + validate_sha256_digest("ManifestState.hash", &self.hash)?; + validate_sorted_unique_by( + &self.mis, + |item| item.hash.as_slice(), + "ManifestState.mis must be sorted by hash and unique", + )?; + for instance in &self.mis { + instance.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestInstance { + pub hash: Vec, + pub size: u64, + pub aki: Vec, + pub manifest_number: BigUnsigned, + pub this_update: time::OffsetDateTime, + pub locations: Vec>, + pub subordinates: Vec>, +} + +impl ManifestInstance { + pub fn validate(&self) -> Result<(), String> { + validate_sha256_digest("ManifestInstance.hash", &self.hash)?; + if self.size < 1000 { + return Err(format!( + "ManifestInstance.size must be >= 1000, got {}", + self.size + )); + } + validate_key_identifier("ManifestInstance.aki", &self.aki)?; + validate_big_unsigned_bytes("ManifestInstance.manifest_number", &self.manifest_number.bytes_be)?; + if self.locations.is_empty() { + return Err("ManifestInstance.locations must contain at least one AccessDescription".into()); + } + for location in &self.locations { + validate_full_der_with_tag( + "ManifestInstance.locations[]", + location, + Some(0x30), + )?; + } + if !self.subordinates.is_empty() { + validate_sorted_unique_bytes( + &self.subordinates, + KEY_IDENTIFIER_LEN_SHA1, + "ManifestInstance.subordinates must be sorted/unique 20-byte SKIs", + )?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoaPayloadState { + pub rps: Vec, + pub hash: Vec, +} + +impl RoaPayloadState { + pub fn validate(&self) -> Result<(), String> { + validate_sha256_digest("ROAPayloadState.hash", &self.hash)?; + validate_sorted_unique_by( + &self.rps, + |item| &item.as_id, + "ROAPayloadState.rps must be sorted by asID and unique", + )?; + for set in &self.rps { + set.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoaPayloadSet { + pub as_id: u32, + pub ip_addr_blocks: Vec>, +} + +impl RoaPayloadSet { + pub fn validate(&self) -> Result<(), String> { + if self.ip_addr_blocks.is_empty() || self.ip_addr_blocks.len() > 2 { + return Err(format!( + "ROAPayloadSet.ip_addr_blocks must contain 1..=2 entries, got {}", + self.ip_addr_blocks.len() + )); + } + for block in &self.ip_addr_blocks { + validate_full_der_with_tag("ROAPayloadSet.ip_addr_blocks[]", block, Some(0x30))?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AspaPayloadState { + pub aps: Vec, + pub hash: Vec, +} + +impl AspaPayloadState { + pub fn validate(&self) -> Result<(), String> { + validate_sha256_digest("ASPAPayloadState.hash", &self.hash)?; + validate_sorted_unique_by( + &self.aps, + |item| &item.customer_as_id, + "ASPAPayloadState.aps must be sorted by customerASID and unique", + )?; + for set in &self.aps { + set.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AspaPayloadSet { + pub customer_as_id: u32, + pub providers: Vec, +} + +impl AspaPayloadSet { + pub fn validate(&self) -> Result<(), String> { + if self.providers.is_empty() { + return Err("ASPAPayloadSet.providers must be non-empty".into()); + } + validate_sorted_unique_by( + &self.providers, + |provider| provider, + "ASPAPayloadSet.providers must be sorted ascending and unique", + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TrustAnchorState { + pub skis: Vec>, + pub hash: Vec, +} + +impl TrustAnchorState { + pub fn validate(&self) -> Result<(), String> { + if self.skis.is_empty() { + return Err("TrustAnchorState.skis must be non-empty".into()); + } + validate_sha256_digest("TrustAnchorState.hash", &self.hash)?; + validate_sorted_unique_bytes( + &self.skis, + KEY_IDENTIFIER_LEN_SHA1, + "TrustAnchorState.skis must be sorted/unique 20-byte SKIs", + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouterKeyState { + pub rksets: Vec, + pub hash: Vec, +} + +impl RouterKeyState { + pub fn validate(&self) -> Result<(), String> { + validate_sha256_digest("RouterKeyState.hash", &self.hash)?; + validate_sorted_unique_by( + &self.rksets, + |item| &item.as_id, + "RouterKeyState.rksets must be sorted by asID and unique", + )?; + for rkset in &self.rksets { + rkset.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouterKeySet { + pub as_id: u32, + pub router_keys: Vec, +} + +impl RouterKeySet { + pub fn validate(&self) -> Result<(), String> { + if self.router_keys.is_empty() { + return Err("RouterKeySet.router_keys must be non-empty".into()); + } + validate_sorted_unique_by( + &self.router_keys, + |key| key, + "RouterKeySet.router_keys must be sorted by SKI and unique by (SKI, SPKI DER)", + )?; + for key in &self.router_keys { + key.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RouterKey { + pub ski: Vec, + pub spki_der: Vec, +} + +impl RouterKey { + pub fn validate(&self) -> Result<(), String> { + validate_key_identifier("RouterKey.ski", &self.ski)?; + validate_full_der_with_tag("RouterKey.spki_der", &self.spki_der, Some(0x30)) + } +} + +fn validate_sha256_digest(field: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() != DIGEST_LEN_SHA256 { + return Err(format!( + "{field} must be {DIGEST_LEN_SHA256} bytes, got {}", + bytes.len() + )); + } + Ok(()) +} + +fn validate_key_identifier(field: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() != KEY_IDENTIFIER_LEN_SHA1 { + return Err(format!( + "{field} must be {KEY_IDENTIFIER_LEN_SHA1} bytes, got {}", + bytes.len() + )); + } + Ok(()) +} + +fn validate_big_unsigned_bytes(field: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.is_empty() { + return Err(format!("{field} must not be empty")); + } + if bytes.len() > 1 && bytes[0] == 0x00 { + return Err(format!("{field} must be minimally encoded as an unsigned integer")); + } + Ok(()) +} + +fn validate_sorted_unique_by( + values: &[T], + key_fn: impl Fn(&T) -> &K, + message: &str, +) -> Result<(), String> { + for window in values.windows(2) { + if key_fn(&window[0]) >= key_fn(&window[1]) { + return Err(message.to_string()); + } + } + Ok(()) +} + +fn validate_sorted_unique_bytes( + values: &[Vec], + expected_len: usize, + message: &str, +) -> Result<(), String> { + for value in values { + if value.len() != expected_len { + return Err(message.to_string()); + } + } + for window in values.windows(2) { + if window[0] >= window[1] { + return Err(message.to_string()); + } + } + Ok(()) +} + +fn validate_full_der_with_tag( + field: &str, + der: &[u8], + expected_tag: Option, +) -> Result<(), String> { + let (tag, _value, rem) = der_take_tlv(der).map_err(|e| format!("{field}: {e}"))?; + if !rem.is_empty() { + return Err(format!("{field}: trailing bytes after DER object")); + } + if let Some(expected_tag) = expected_tag { + if tag != expected_tag { + return Err(format!( + "{field}: unexpected tag 0x{tag:02X}, expected 0x{expected_tag:02X}" + )); + } + } + Ok(()) +} diff --git a/src/ccr/verify.rs b/src/ccr/verify.rs new file mode 100644 index 0000000..6c045d2 --- /dev/null +++ b/src/ccr/verify.rs @@ -0,0 +1,569 @@ +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::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::build::{build_aspa_payload_state, build_roa_payload_state}; + use crate::ccr::model::{ + CcrDigestAlgorithm, ManifestInstance, ManifestState, + RouterKey, RouterKeySet, RpkiCanonicalCacheRepresentation, + }; + use crate::data_model::roa::{IpPrefix, RoaAfi}; + use crate::validation::objects::{AspaAttestation, Vrp}; + use crate::data_model::common::BigUnsigned; + use crate::storage::{ + PackTime, ValidatedCaInstanceResult, ValidatedManifestMeta, VcirArtifactKind, + VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, VcirChildEntry, + VcirInstanceGate, VcirRelatedArtifact, VcirSummary, + }; + + 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), + }) + } + + #[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()), + }, + 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, + }], + 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); + } +} diff --git a/src/cli.rs b/src/cli.rs index dcc1fb2..aaa7a75 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use crate::ccr::{build_ccr_from_run, write_ccr_file}; use std::path::{Path, PathBuf}; use crate::analysis::timing::{TimingHandle, TimingMeta, TimingMetaUpdate}; @@ -30,6 +31,7 @@ pub struct CliArgs { pub db_path: PathBuf, pub policy_path: Option, pub report_json_path: Option, + pub ccr_out_path: Option, pub payload_replay_archive: Option, pub payload_replay_locks: Option, pub payload_base_archive: Option, @@ -64,6 +66,7 @@ Options: --db RocksDB directory path (required) --policy Policy TOML path (optional) --report-json Write full audit report as JSON (optional) + --ccr-out Write CCR DER ContentInfo to this path (optional) --payload-replay-archive Use local payload replay archive root (offline replay mode) --payload-replay-locks Use local payload replay locks.json (offline replay mode) --payload-base-archive Use local base payload archive root (offline delta replay) @@ -99,6 +102,7 @@ pub fn parse_args(argv: &[String]) -> Result { let mut db_path: Option = None; let mut policy_path: Option = None; let mut report_json_path: Option = None; + let mut ccr_out_path: Option = None; let mut payload_replay_archive: Option = None; let mut payload_replay_locks: Option = None; let mut payload_base_archive: Option = None; @@ -152,6 +156,11 @@ pub fn parse_args(argv: &[String]) -> Result { let v = argv.get(i).ok_or("--report-json requires a value")?; report_json_path = Some(PathBuf::from(v)); } + "--ccr-out" => { + i += 1; + let v = argv.get(i).ok_or("--ccr-out requires a value")?; + ccr_out_path = Some(PathBuf::from(v)); + } "--payload-replay-archive" => { i += 1; let v = argv @@ -367,6 +376,7 @@ pub fn parse_args(argv: &[String]) -> Result { db_path, policy_path, report_json_path, + ccr_out_path, payload_replay_archive, payload_replay_locks, payload_base_archive, @@ -856,6 +866,20 @@ pub fn run(argv: &[String]) -> Result<(), String> { None }; + if let Some(path) = args.ccr_out_path.as_deref() { + let ccr = build_ccr_from_run( + &store, + &[out.discovery.trust_anchor.clone()], + &out.tree.vrps, + &out.tree.aspas, + &out.tree.router_keys, + time::OffsetDateTime::now_utc(), + ) + .map_err(|e| e.to_string())?; + write_ccr_file(path, &ccr).map_err(|e| e.to_string())?; + eprintln!("wrote CCR: {}", path.display()); + } + let report = build_report(&policy, validation_time, out); if let Some(p) = args.report_json_path.as_deref() { @@ -971,6 +995,25 @@ mod tests { assert!(err.contains("invalid --max-depth"), "{err}"); } + #[test] + fn parse_accepts_ccr_out_path() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + "--rsync-local-dir".to_string(), + "repo".to_string(), + "--ccr-out".to_string(), + "out/example.ccr".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert_eq!(args.ccr_out_path.as_deref(), Some(std::path::Path::new("out/example.ccr"))); + } + #[test] fn parse_rejects_invalid_validation_time() { let argv = vec![ @@ -1370,6 +1413,7 @@ mod tests { customer_as_id: 64496, provider_as_ids: vec![64497, 64498], }], + router_keys: Vec::new(), }; let mut pp1 = crate::audit::PublicationPointAudit::default(); diff --git a/src/data_model/mod.rs b/src/data_model/mod.rs index d22198f..e7ffdc6 100644 --- a/src/data_model/mod.rs +++ b/src/data_model/mod.rs @@ -8,3 +8,5 @@ pub mod roa; pub mod signed_object; pub mod ta; pub mod tal; + +pub mod router_cert; diff --git a/src/data_model/oid.rs b/src/data_model/oid.rs index d6b251c..5e351ce 100644 --- a/src/data_model/oid.rs +++ b/src/data_model/oid.rs @@ -68,3 +68,10 @@ pub const OID_AUTONOMOUS_SYS_IDS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1 // RPKI CP (RFC 6484 / RFC 6487) pub const OID_CP_IPADDR_ASNUMBER: &str = "1.3.6.1.5.5.7.14.2"; pub const OID_CP_IPADDR_ASNUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.14.2); + +pub const OID_CT_RPKI_CCR: &str = "1.2.840.113549.1.9.16.1.54"; +pub const OID_CT_RPKI_CCR_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.54); + +pub const OID_KP_BGPSEC_ROUTER: &str = "1.3.6.1.5.5.7.3.30"; +pub const OID_EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1"; +pub const OID_SECP256R1: &str = "1.2.840.10045.3.1.7"; diff --git a/src/data_model/router_cert.rs b/src/data_model/router_cert.rs new file mode 100644 index 0000000..b213c0c --- /dev/null +++ b/src/data_model/router_cert.rs @@ -0,0 +1,327 @@ +use crate::data_model::oid::{ + OID_EC_PUBLIC_KEY, OID_EXTENDED_KEY_USAGE_RAW, OID_KP_BGPSEC_ROUTER, OID_SECP256R1, +}; +use crate::data_model::rc::{ + AsIdOrRange, AsIdentifierChoice, ResourceCertKind, ResourceCertificate, + ResourceCertificateParseError, ResourceCertificateParsed, + ResourceCertificateProfileError, +}; +use crate::validation::cert_path::{ + CertPathError, validate_ee_cert_path_with_predecoded_ee, +}; +use x509_parser::extensions::ParsedExtension; +use x509_parser::prelude::{FromDer, X509Certificate}; +use x509_parser::public_key::PublicKey; +use x509_parser::x509::SubjectPublicKeyInfo; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BgpsecRouterCertificateParsed { + pub rc_parsed: ResourceCertificateParsed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BgpsecRouterCertificate { + pub raw_der: Vec, + pub resource_cert: ResourceCertificate, + pub subject_key_identifier: Vec, + pub spki_der: Vec, + pub asns: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum BgpsecRouterCertificateParseError { + #[error("resource certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8209 §3.1)")] + ResourceCertificate(#[from] ResourceCertificateParseError), + + #[error("X.509 parse error: {0} (RFC 5280 §4.1; RFC 8209 §3.1)" )] + X509(String), + + #[error("trailing bytes after router certificate DER: {0} bytes (DER; RFC 5280 §4.1)")] + TrailingBytes(usize), + + #[error("router SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7; RFC 8208 §3.1)")] + SpkiParse(String), + + #[error("trailing bytes after router SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 8208 §3.1)")] + SpkiTrailingBytes(usize), +} + +#[derive(Debug, thiserror::Error)] +pub enum BgpsecRouterCertificateProfileError { + #[error("resource certificate profile error: {0} (RFC 6487 §4; RFC 8209 §3.1)")] + ResourceCertificate(#[from] ResourceCertificateProfileError), + + #[error("BGPsec router certificate must be an EE certificate (RFC 8209 §3.1)")] + NotEe, + + #[error("BGPsec router certificate must contain SubjectKeyIdentifier (RFC 6487 §4.8.2; RFC 8209 §3.3)")] + MissingSki, + + #[error("BGPsec router certificate must include ExtendedKeyUsage (RFC 8209 §3.1.3.2; RFC 8209 §3.3)")] + MissingExtendedKeyUsage, + + #[error("BGPsec router certificate ExtendedKeyUsage must be non-critical (RFC 6487 §4.8.4; RFC 8209 §3.1.3.2)")] + ExtendedKeyUsageCriticality, + + #[error("BGPsec router certificate ExtendedKeyUsage must contain id-kp-bgpsec-router ({OID_KP_BGPSEC_ROUTER}) (RFC 8209 §3.1.3.2; RFC 8209 §3.3)")] + MissingBgpsecRouterEku, + + #[error("BGPsec router certificate MUST NOT include Subject Information Access (RFC 8209 §3.1.3.3; RFC 8209 §3.3)")] + SubjectInfoAccessPresent, + + #[error("BGPsec router certificate MUST NOT include IP resources extension (RFC 8209 §3.1.3.4; RFC 8209 §3.3)")] + IpResourcesPresent, + + #[error("BGPsec router certificate MUST include AS resources extension (RFC 8209 §3.1.3.5; RFC 8209 §3.3)")] + AsResourcesMissing, + + #[error("BGPsec router certificate AS resources MUST include one or more ASNs (RFC 8209 §3.1.3.5)")] + AsResourcesAsnumMissing, + + #[error("BGPsec router certificate AS resources MUST NOT use inherit (RFC 8209 §3.1.3.5)")] + AsResourcesInherit, + + #[error("BGPsec router certificate AS resources MUST contain explicit ASNs, not ranges (RFC 8209 §3.1.3.5)")] + AsResourcesRangeNotAllowed, + + #[error("BGPsec router certificate subjectPublicKeyInfo.algorithm must be id-ecPublicKey ({OID_EC_PUBLIC_KEY}) (RFC 8208 §3.1)")] + SpkiAlgorithmNotEcPublicKey, + + #[error("BGPsec router certificate subjectPublicKeyInfo.parameters must be secp256r1 ({OID_SECP256R1}) (RFC 8208 §3.1)")] + SpkiWrongCurve, + + #[error("BGPsec router certificate subjectPublicKeyInfo.parameters missing or invalid (RFC 8208 §3.1)")] + SpkiParametersMissingOrInvalid, + + #[error("BGPsec router certificate subjectPublicKey MUST be uncompressed P-256 ECPoint (RFC 8208 §3.1)")] + SpkiEcPointNotUncompressedP256, +} + +#[derive(Debug, thiserror::Error)] +pub enum BgpsecRouterCertificateDecodeError { + #[error("{0}")] + Parse(#[from] BgpsecRouterCertificateParseError), + + #[error("{0}")] + Validate(#[from] BgpsecRouterCertificateProfileError), +} + +#[derive(Debug, thiserror::Error)] +pub enum BgpsecRouterCertificatePathError { + #[error("{0}")] + Decode(#[from] BgpsecRouterCertificateDecodeError), + + #[error("{0}")] + CertPath(#[from] CertPathError), +} + +impl BgpsecRouterCertificate { + pub fn parse_der(der: &[u8]) -> Result { + let (rem, cert) = X509Certificate::from_der(der) + .map_err(|e| BgpsecRouterCertificateParseError::X509(e.to_string()))?; + if !rem.is_empty() { + return Err(BgpsecRouterCertificateParseError::TrailingBytes(rem.len())); + } + let (spki_rem, _spki) = SubjectPublicKeyInfo::from_der(cert.tbs_certificate.subject_pki.raw) + .map_err(|e| BgpsecRouterCertificateParseError::SpkiParse(e.to_string()))?; + if !spki_rem.is_empty() { + return Err(BgpsecRouterCertificateParseError::SpkiTrailingBytes(spki_rem.len())); + } + let rc_parsed = ResourceCertificate::parse_der(der)?; + Ok(BgpsecRouterCertificateParsed { rc_parsed }) + } + + pub fn validate_profile(&self) -> Result<(), BgpsecRouterCertificateProfileError> { + Ok(()) + } + + pub fn decode_der(der: &[u8]) -> Result { + Ok(Self::parse_der(der)?.validate_profile()?) + } + + pub fn from_der(der: &[u8]) -> Result { + Self::decode_der(der) + } + + pub fn validate_path_with_prevalidated_issuer( + der: &[u8], + issuer_ca: &ResourceCertificate, + issuer_spki: &SubjectPublicKeyInfo<'_>, + issuer_crl: &crate::data_model::crl::RpkixCrl, + issuer_crl_revoked_serials: &std::collections::HashSet>, + issuer_ca_rsync_uri: Option<&str>, + issuer_crl_rsync_uri: Option<&str>, + validation_time: time::OffsetDateTime, + ) -> Result { + let cert = Self::decode_der(der)?; + validate_ee_cert_path_with_predecoded_ee( + &cert.resource_cert, + der, + issuer_ca, + issuer_spki, + issuer_crl, + issuer_crl_revoked_serials, + issuer_ca_rsync_uri, + issuer_crl_rsync_uri, + validation_time, + )?; + Ok(cert) + } +} + +impl BgpsecRouterCertificateParsed { + pub fn validate_profile(self) -> Result { + let rc = self.rc_parsed.validate_profile()?; + if rc.kind != ResourceCertKind::Ee { + return Err(BgpsecRouterCertificateProfileError::NotEe); + } + let ski = rc + .tbs + .extensions + .subject_key_identifier + .clone() + .ok_or(BgpsecRouterCertificateProfileError::MissingSki)?; + + if rc.tbs.extensions.subject_info_access.is_some() { + return Err(BgpsecRouterCertificateProfileError::SubjectInfoAccessPresent); + } + if rc.tbs.extensions.ip_resources.is_some() { + return Err(BgpsecRouterCertificateProfileError::IpResourcesPresent); + } + let as_resources = rc + .tbs + .extensions + .as_resources + .as_ref() + .ok_or(BgpsecRouterCertificateProfileError::AsResourcesMissing)?; + let asns = extract_router_asns(as_resources)?; + + let (rem, cert) = X509Certificate::from_der(&rc.raw_der) + .map_err(|e| BgpsecRouterCertificateProfileError::ResourceCertificate( + ResourceCertificateProfileError::InvalidCertificatePolicy(e.to_string()) + ))?; + if !rem.is_empty() { + return Err(BgpsecRouterCertificateProfileError::ResourceCertificate( + ResourceCertificateProfileError::InvalidCertificatePolicy( + format!("trailing bytes after router certificate DER: {}", rem.len()), + ), + )); + } + validate_router_eku(&cert)?; + validate_router_spki(&rc.tbs.subject_public_key_info)?; + + Ok(BgpsecRouterCertificate { + raw_der: rc.raw_der.clone(), + resource_cert: rc.clone(), + subject_key_identifier: ski, + spki_der: rc.tbs.subject_public_key_info.clone(), + asns, + }) + } +} + +fn extract_router_asns( + as_resources: &crate::data_model::rc::AsResourceSet, +) -> Result, BgpsecRouterCertificateProfileError> { + let asnum = as_resources + .asnum + .as_ref() + .ok_or(BgpsecRouterCertificateProfileError::AsResourcesAsnumMissing)?; + if matches!(asnum, AsIdentifierChoice::Inherit) + || matches!(as_resources.rdi.as_ref(), Some(AsIdentifierChoice::Inherit)) + { + return Err(BgpsecRouterCertificateProfileError::AsResourcesInherit); + } + let AsIdentifierChoice::AsIdsOrRanges(items) = asnum else { + return Err(BgpsecRouterCertificateProfileError::AsResourcesInherit); + }; + if items.is_empty() { + return Err(BgpsecRouterCertificateProfileError::AsResourcesAsnumMissing); + } + let mut asns = Vec::with_capacity(items.len()); + for item in items { + match item { + AsIdOrRange::Id(v) => asns.push(*v), + AsIdOrRange::Range { .. } => { + return Err(BgpsecRouterCertificateProfileError::AsResourcesRangeNotAllowed) + } + } + } + asns.sort_unstable(); + asns.dedup(); + Ok(asns) +} + +fn validate_router_eku(cert: &X509Certificate<'_>) -> Result<(), BgpsecRouterCertificateProfileError> { + let mut matches = cert + .tbs_certificate + .extensions() + .iter() + .filter(|ext| ext.oid.as_bytes() == OID_EXTENDED_KEY_USAGE_RAW); + let Some(ext) = matches.next() else { + return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage); + }; + if matches.next().is_some() { + return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage); + } + if ext.critical { + return Err(BgpsecRouterCertificateProfileError::ExtendedKeyUsageCriticality); + } + let ParsedExtension::ExtendedKeyUsage(eku) = ext.parsed_extension() else { + return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage); + }; + let found = eku + .other + .iter() + .any(|oid| oid.to_id_string() == OID_KP_BGPSEC_ROUTER); + if !found { + return Err(BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku); + } + Ok(()) +} + +fn validate_router_spki(spki_der: &[u8]) -> Result<(), BgpsecRouterCertificateProfileError> { + let (rem, spki) = SubjectPublicKeyInfo::from_der(spki_der) + .map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)?; + if !rem.is_empty() { + return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid); + } + if spki.algorithm.algorithm.to_id_string() != OID_EC_PUBLIC_KEY { + return Err(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey); + } + let Some(params) = spki.algorithm.parameters.as_ref() else { + return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid); + }; + if params.header.tag().0 != 0x06 { + return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid); + } + let mut der = Vec::with_capacity(params.data.len() + 2); + der.push(0x06); + if params.data.len() >= 0x80 { + return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid); + } + der.push(params.data.len() as u8); + der.extend_from_slice(params.data); + let (prem, oid) = der_parser::der::parse_der_oid(&der) + .map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)?; + if !prem.is_empty() { + return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid); + } + let curve = oid + .as_oid_val() + .map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)? + .to_string(); + if curve != OID_SECP256R1 { + return Err(BgpsecRouterCertificateProfileError::SpkiWrongCurve); + } + let parsed = spki + .parsed() + .map_err(|_| BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256)?; + let PublicKey::EC(ec) = parsed else { + return Err(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey); + }; + if ec.data().len() != 65 || ec.data().first() != Some(&0x04) { + return Err(BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 916d511..c27246d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod ccr; pub mod data_model; #[cfg(feature = "full")] diff --git a/src/storage.rs b/src/storage.rs index 091394e..678305f 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -40,6 +40,7 @@ const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:"; const VCIR_KEY_PREFIX: &str = "vcir:"; const AUDIT_ROA_RULE_KEY_PREFIX: &str = "audit:roa_rule:"; const AUDIT_ASPA_RULE_KEY_PREFIX: &str = "audit:aspa_rule:"; +const AUDIT_ROUTER_KEY_RULE_KEY_PREFIX: &str = "audit:router_key_rule:"; const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:"; const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:"; const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:"; @@ -313,6 +314,7 @@ impl VcirChildEntry { pub enum VcirOutputType { Vrp, Aspa, + RouterKey, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -420,6 +422,7 @@ impl VcirRelatedArtifact { pub struct VcirSummary { pub local_vrp_count: u32, pub local_aspa_count: u32, + pub local_router_key_count: u32, pub child_count: u32, pub accepted_object_count: u32, pub rejected_object_count: u32, @@ -529,6 +532,7 @@ impl ValidatedCaInstanceResult { let mut output_ids = HashSet::with_capacity(self.local_outputs.len()); let mut vrp_count = 0u32; let mut aspa_count = 0u32; + let mut router_key_count = 0u32; for output in &self.local_outputs { output.validate_internal()?; if !output_ids.insert(output.output_id.as_str()) { @@ -540,6 +544,7 @@ impl ValidatedCaInstanceResult { match output.output_type { VcirOutputType::Vrp => vrp_count += 1, VcirOutputType::Aspa => aspa_count += 1, + VcirOutputType::RouterKey => router_key_count += 1, } } if self.summary.local_vrp_count != vrp_count { @@ -560,6 +565,15 @@ impl ValidatedCaInstanceResult { ), }); } + if self.summary.local_router_key_count != router_key_count { + return Err(StorageError::InvalidData { + entity: "vcir.summary", + detail: format!( + "local_router_key_count={} does not match local_outputs count {}", + self.summary.local_router_key_count, router_key_count + ), + }); + } if self.summary.child_count != self.child_entries.len() as u32 { return Err(StorageError::InvalidData { entity: "vcir.summary", @@ -584,6 +598,7 @@ impl ValidatedCaInstanceResult { pub enum AuditRuleKind { Roa, Aspa, + RouterKey, } impl AuditRuleKind { @@ -591,6 +606,7 @@ impl AuditRuleKind { match self { Self::Roa => AUDIT_ROA_RULE_KEY_PREFIX, Self::Aspa => AUDIT_ASPA_RULE_KEY_PREFIX, + Self::RouterKey => AUDIT_ROUTER_KEY_RULE_KEY_PREFIX, } } } @@ -1246,6 +1262,19 @@ impl RocksStore { Ok(Some(vcir)) } + pub fn list_vcirs(&self) -> StorageResult> { + let cf = self.cf(CF_VCIR)?; + let mode = IteratorMode::Start; + let mut out = Vec::new(); + for res in self.db.iterator_cf(cf, mode) { + let (_key, bytes) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?; + let vcir = decode_cbor::(&bytes, "vcir")?; + vcir.validate_internal()?; + out.push(vcir); + } + Ok(out) + } + pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> { let cf = self.cf(CF_VCIR)?; let key = vcir_key(manifest_rsync_uri); @@ -1476,6 +1505,7 @@ fn audit_rule_kind_for_output_type(output_type: VcirOutputType) -> Option Some(AuditRuleKind::Roa), VcirOutputType::Aspa => Some(AuditRuleKind::Aspa), + VcirOutputType::RouterKey => Some(AuditRuleKind::RouterKey), } } @@ -1749,6 +1779,7 @@ mod tests { summary: VcirSummary { local_vrp_count: 1, local_aspa_count: 1, + local_router_key_count: 0, child_count: 1, accepted_object_count: 4, rejected_object_count: 0, @@ -1768,19 +1799,23 @@ mod tests { rule_hash: sha256_hex(match kind { AuditRuleKind::Roa => b"roa-index-rule", AuditRuleKind::Aspa => b"aspa-index-rule", + AuditRuleKind::RouterKey => b"router-key-index-rule", }), manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), source_object_uri: match kind { AuditRuleKind::Roa => "rsync://example.test/repo/object.roa".to_string(), AuditRuleKind::Aspa => "rsync://example.test/repo/object.asa".to_string(), + AuditRuleKind::RouterKey => "rsync://example.test/repo/router.cer".to_string(), }, source_object_hash: sha256_hex(match kind { AuditRuleKind::Roa => b"roa-object", AuditRuleKind::Aspa => b"aspa-object", + AuditRuleKind::RouterKey => b"router-key-object", }), output_id: match kind { AuditRuleKind::Roa => "vrp-1".to_string(), AuditRuleKind::Aspa => "aspa-1".to_string(), + AuditRuleKind::RouterKey => "router-key-1".to_string(), }, item_effective_until: pack_time(12), } @@ -2061,18 +2096,37 @@ mod tests { } #[test] - fn audit_rule_index_roundtrip_for_roa_and_aspa() { + fn list_vcirs_returns_all_entries() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + + let vcir1 = sample_vcir("rsync://example.test/repo/a.mft"); + let vcir2 = sample_vcir("rsync://example.test/repo/b.mft"); + store.put_vcir(&vcir1).expect("put vcir1"); + store.put_vcir(&vcir2).expect("put vcir2"); + + let mut got = store.list_vcirs().expect("list vcirs"); + got.sort_by(|a, b| a.manifest_rsync_uri.cmp(&b.manifest_rsync_uri)); + assert_eq!(got, vec![vcir1, vcir2]); + } + + #[test] + fn audit_rule_index_roundtrip_for_roa_aspa_and_router_key() { let td = tempfile::tempdir().expect("tempdir"); let store = RocksStore::open(td.path()).expect("open rocksdb"); let roa = sample_audit_rule_entry(AuditRuleKind::Roa); let aspa = sample_audit_rule_entry(AuditRuleKind::Aspa); + let router_key = sample_audit_rule_entry(AuditRuleKind::RouterKey); store .put_audit_rule_index_entry(&roa) .expect("put roa audit rule entry"); store .put_audit_rule_index_entry(&aspa) .expect("put aspa audit rule entry"); + store + .put_audit_rule_index_entry(&router_key) + .expect("put router key audit rule entry"); let got_roa = store .get_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) @@ -2082,8 +2136,13 @@ mod tests { .get_audit_rule_index_entry(AuditRuleKind::Aspa, &aspa.rule_hash) .expect("get aspa audit rule entry") .expect("aspa entry exists"); + let got_router_key = store + .get_audit_rule_index_entry(AuditRuleKind::RouterKey, &router_key.rule_hash) + .expect("get router key audit rule entry") + .expect("router key entry exists"); assert_eq!(got_roa, roa); assert_eq!(got_aspa, aspa); + assert_eq!(got_router_key, router_key); store .delete_audit_rule_index_entry(AuditRuleKind::Roa, &roa.rule_hash) @@ -2123,6 +2182,7 @@ mod tests { }]; previous.summary.local_vrp_count = 1; previous.summary.local_aspa_count = 0; + previous.summary.local_router_key_count = 0; store .replace_vcir_and_audit_rule_indexes(None, &previous) .expect("store previous vcir"); diff --git a/src/validation/manifest.rs b/src/validation/manifest.rs index 1c4085b..d7ccd42 100644 --- a/src/validation/manifest.rs +++ b/src/validation/manifest.rs @@ -891,6 +891,7 @@ mod tests { summary: VcirSummary { local_vrp_count: 0, local_aspa_count: 0, + local_router_key_count: 0, child_count: 0, accepted_object_count: 2, rejected_object_count: 0, diff --git a/src/validation/objects.rs b/src/validation/objects.rs index 68a94da..43ec0da 100644 --- a/src/validation/objects.rs +++ b/src/validation/objects.rs @@ -65,10 +65,22 @@ pub struct AspaAttestation { pub provider_as_ids: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouterKeyPayload { + pub as_id: u32, + pub ski: Vec, + pub spki_der: Vec, + pub source_object_uri: String, + pub source_object_hash: String, + pub source_ee_cert_hash: String, + pub item_effective_until: PackTime, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ObjectsOutput { pub vrps: Vec, pub aspas: Vec, + pub router_keys: Vec, pub local_outputs_cache: Vec, pub warnings: Vec, pub stats: ObjectsStats, @@ -151,6 +163,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -175,6 +188,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -193,6 +207,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -252,6 +267,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -356,6 +372,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -456,6 +473,7 @@ pub fn process_publication_point_for_issuer( return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, @@ -470,6 +488,7 @@ pub fn process_publication_point_for_issuer( ObjectsOutput { vrps, aspas, + router_keys: Vec::new(), local_outputs_cache, warnings, stats, diff --git a/src/validation/tree.rs b/src/validation/tree.rs index adae3f5..a292874 100644 --- a/src/validation/tree.rs +++ b/src/validation/tree.rs @@ -3,7 +3,7 @@ use crate::audit::PublicationPointAudit; use crate::data_model::rc::{AsResourceSet, IpResourceSet}; use crate::report::Warning; use crate::validation::manifest::PublicationPointSource; -use crate::validation::objects::{AspaAttestation, ObjectsOutput, Vrp}; +use crate::validation::objects::{AspaAttestation, ObjectsOutput, RouterKeyPayload, Vrp}; use crate::validation::publication_point::PublicationPointSnapshot; #[derive(Clone, Debug, PartialEq, Eq)] @@ -81,6 +81,7 @@ pub struct TreeRunOutput { pub warnings: Vec, pub vrps: Vec, pub aspas: Vec, + pub router_keys: Vec, } #[derive(Debug, thiserror::Error)] @@ -140,6 +141,7 @@ pub fn run_tree_serial_audit( let mut warnings: Vec = Vec::new(); let mut vrps: Vec = Vec::new(); let mut aspas: Vec = Vec::new(); + let mut router_keys: Vec = Vec::new(); let mut publication_points: Vec = Vec::new(); while let Some(node) = queue.pop_front() { @@ -177,6 +179,7 @@ pub fn run_tree_serial_audit( warnings.extend(res.objects.warnings.clone()); vrps.extend(res.objects.vrps.clone()); aspas.extend(res.objects.aspas.clone()); + router_keys.extend(res.objects.router_keys.clone()); let mut audit = res.audit.clone(); audit.node_id = Some(node.id); @@ -213,6 +216,7 @@ pub fn run_tree_serial_audit( warnings, vrps, aspas, + router_keys, }, publication_points, }) diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index 65ef955..4707da6 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -9,6 +9,10 @@ use crate::data_model::crl::RpkixCrl; use crate::data_model::manifest::ManifestObject; use crate::data_model::rc::ResourceCertificate; use crate::data_model::roa::{RoaAfi, RoaObject}; +use crate::data_model::router_cert::{ + BgpsecRouterCertificate, BgpsecRouterCertificateDecodeError, + BgpsecRouterCertificatePathError, BgpsecRouterCertificateProfileError, +}; use crate::fetch::rsync::RsyncFetcher; use crate::policy::Policy; use crate::replay::archive::ReplayArchiveIndex; @@ -33,7 +37,7 @@ use crate::validation::manifest::{ ManifestFreshError, PublicationPointData, PublicationPointSource, process_manifest_publication_point_fresh_after_repo_sync, }; -use crate::validation::objects::{AspaAttestation, Vrp, process_publication_point_for_issuer}; +use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp, process_publication_point_for_issuer}; use crate::validation::publication_point::PublicationPointSnapshot; use crate::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, @@ -42,6 +46,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use serde::Deserialize; +use base64::Engine as _; use serde_json::json; use x509_parser::prelude::FromDer; use x509_parser::x509::SubjectPublicKeyInfo; @@ -266,7 +271,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { match fresh_publication_point { Ok(fresh_point) => { - let objects = { + let mut objects = { let _objects_total = self .timing .as_ref() @@ -295,18 +300,23 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { self.timing.as_ref(), ) }; - let (discovered_children, child_audits) = match out { - Ok(out) => (out.children, out.audits), + let (discovered_children, child_audits, discovered_router_keys) = match out { + Ok(out) => (out.children, out.audits, out.router_keys), Err(e) => { warnings.push( Warning::new(format!("child CA discovery failed: {e}")) .with_rfc_refs(&[RfcRef("RFC 6487 §7.2")]) .with_context(&ca.manifest_rsync_uri), ); - (Vec::new(), Vec::new()) + (Vec::new(), Vec::new(), Vec::new()) } }; + objects.router_keys.extend(discovered_router_keys); + objects + .local_outputs_cache + .extend(build_router_key_local_outputs(ca, &objects.router_keys)); + let pack = fresh_point.to_publication_point_snapshot(); persist_vcir_for_fresh_result( @@ -384,6 +394,7 @@ fn normalize_rsync_base_uri(s: &str) -> String { struct ChildDiscoveryOutput { children: Vec, audits: Vec, + router_keys: Vec, } #[derive(Clone, Debug)] @@ -421,6 +432,13 @@ struct VcirAspaPayload { provider_as_ids: Vec, } +#[derive(Debug, Deserialize)] +struct VcirRouterKeyPayload { + as_id: u32, + ski_hex: String, + spki_der_base64: String, +} + fn discover_children_from_fresh_snapshot_with_audit( issuer: &CaInstanceHandle, publication_point: &P, @@ -484,6 +502,7 @@ fn discover_children_from_fresh_snapshot_with_audit( let mut out: Vec = Vec::new(); let mut audits: Vec = Vec::new(); + let mut router_keys: Vec = Vec::new(); let issuer_resources_index = IssuerEffectiveResourcesIndex::from_effective_resources( issuer.effective_ip_resources.as_ref(), issuer.effective_as_resources.as_ref(), @@ -494,12 +513,16 @@ fn discover_children_from_fresh_snapshot_with_audit( let mut ca_skipped_not_ca: u64 = 0; let mut ca_ok: u64 = 0; let mut ca_error: u64 = 0; + let mut router_ok: u64 = 0; + let mut router_error: u64 = 0; + let mut router_skipped_non_router: u64 = 0; let mut crl_select_error: u64 = 0; let mut uri_discovery_error: u64 = 0; let mut select_crl_nanos: u64 = 0; let mut child_decode_nanos: u64 = 0; let mut validate_sub_ca_nanos: u64 = 0; + let mut validate_router_nanos: u64 = 0; let mut uri_discovery_nanos: u64 = 0; let mut enqueue_nanos: u64 = 0; @@ -639,14 +662,100 @@ fn discover_children_from_fresh_snapshot_with_audit( ) { Ok(v) => v, Err(CaPathError::ChildNotCa) => { - ca_skipped_not_ca = ca_skipped_not_ca.saturating_add(1); - audits.push(ObjectAuditEntry { - rsync_uri: f.rsync_uri.clone(), - sha256_hex: sha256_hex_from_32(&f.sha256), - kind: AuditObjectKind::Certificate, - result: AuditObjectResult::Skipped, - detail: Some("skipped: not a CA resource certificate".to_string()), - }); + let tr = std::time::Instant::now(); + let router_result = match ensure_issuer_crl_verified( + issuer_crl_uri.as_str(), + &mut crl_cache, + issuer_ca_der, + ) { + Ok(verified_crl) => { + BgpsecRouterCertificate::validate_path_with_prevalidated_issuer( + child_der, + issuer_ca_ref, + issuer_spki_ref, + &verified_crl.crl, + &verified_crl.revoked_serials, + issuer.ca_certificate_rsync_uri.as_deref(), + Some(issuer_crl_uri.as_str()), + validation_time, + ) + } + Err(err) => { + validate_router_nanos = validate_router_nanos.saturating_add( + tr.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64, + ); + router_error = router_error.saturating_add(1); + audits.push(ObjectAuditEntry { + rsync_uri: f.rsync_uri.clone(), + sha256_hex: sha256_hex_from_32(&f.sha256), + kind: AuditObjectKind::RouterCertificate, + result: AuditObjectResult::Error, + detail: Some(format!( + "router certificate issuer CRL validation failed: {err}" + )), + }); + continue; + } + }; + validate_router_nanos = validate_router_nanos + .saturating_add(tr.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64); + + match router_result { + Ok(router) => { + router_ok = router_ok.saturating_add(1); + let source_object_hash = sha256_hex_from_32(&f.sha256); + let item_effective_until = PackTime::from_utc_offset_datetime( + router.resource_cert.tbs.validity_not_after, + ); + for as_id in &router.asns { + router_keys.push(RouterKeyPayload { + as_id: *as_id, + ski: router.subject_key_identifier.clone(), + spki_der: router.spki_der.clone(), + source_object_uri: f.rsync_uri.clone(), + source_object_hash: source_object_hash.clone(), + source_ee_cert_hash: source_object_hash.clone(), + item_effective_until: item_effective_until.clone(), + }); + } + audits.push(ObjectAuditEntry { + rsync_uri: f.rsync_uri.clone(), + sha256_hex: sha256_hex_from_32(&f.sha256), + kind: AuditObjectKind::RouterCertificate, + result: AuditObjectResult::Ok, + detail: Some( + "validated BGPsec router certificate (RFC 8209); no child CA instance enqueued" + .to_string(), + ), + }); + } + Err(err) if is_non_router_certificate(&err) => { + ca_skipped_not_ca = ca_skipped_not_ca.saturating_add(1); + router_skipped_non_router = router_skipped_non_router.saturating_add(1); + audits.push(ObjectAuditEntry { + rsync_uri: f.rsync_uri.clone(), + sha256_hex: sha256_hex_from_32(&f.sha256), + kind: AuditObjectKind::Certificate, + result: AuditObjectResult::Skipped, + detail: Some( + "skipped: not a CA resource certificate or BGPsec router certificate" + .to_string(), + ), + }); + } + Err(err) => { + router_error = router_error.saturating_add(1); + audits.push(ObjectAuditEntry { + rsync_uri: f.rsync_uri.clone(), + sha256_hex: sha256_hex_from_32(&f.sha256), + kind: AuditObjectKind::RouterCertificate, + result: AuditObjectResult::Error, + detail: Some(format!( + "router certificate validation failed: {err}" + )), + }); + } + } continue; } Err(e) => { @@ -734,6 +843,9 @@ fn discover_children_from_fresh_snapshot_with_audit( t.record_count("child_ca_ok", ca_ok); t.record_count("child_ca_error", ca_error); t.record_count("child_ca_skipped_not_ca", ca_skipped_not_ca); + t.record_count("child_router_ok", router_ok); + t.record_count("child_router_error", router_error); + t.record_count("child_router_skipped_non_router", router_skipped_non_router); t.record_count("child_crl_select_error", crl_select_error); t.record_count("child_uri_discovery_error", uri_discovery_error); @@ -759,6 +871,7 @@ fn discover_children_from_fresh_snapshot_with_audit( t.record_phase_nanos("child_select_issuer_crl_total", select_crl_nanos); t.record_phase_nanos("child_decode_certificate_total", child_decode_nanos); t.record_phase_nanos("child_validate_subordinate_total", validate_sub_ca_nanos); + t.record_phase_nanos("child_validate_router_certificate_total", validate_router_nanos); t.record_phase_nanos("child_ca_instance_uri_discovery_total", uri_discovery_nanos); t.record_phase_nanos("child_enqueue_total", enqueue_nanos); } @@ -766,9 +879,21 @@ fn discover_children_from_fresh_snapshot_with_audit( Ok(ChildDiscoveryOutput { children: out, audits, + router_keys, }) } +fn is_non_router_certificate(err: &BgpsecRouterCertificatePathError) -> bool { + matches!( + err, + BgpsecRouterCertificatePathError::Decode(BgpsecRouterCertificateDecodeError::Validate( + BgpsecRouterCertificateProfileError::NotEe + | BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage + | BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku + )) + ) +} + fn select_issuer_crl_uri_for_child<'a>( child: &'a crate::data_model::rc::ResourceCertificate, crl_cache: &std::collections::HashMap, @@ -1181,6 +1306,7 @@ fn empty_objects_output() -> crate::validation::objects::ObjectsOutput { crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: crate::validation::objects::ObjectsStats::default(), @@ -1367,6 +1493,14 @@ fn reconstruct_snapshot_from_vcir( }) } +fn audit_kind_for_vcir_output_type(output_type: VcirOutputType) -> AuditObjectKind { + match output_type { + VcirOutputType::Vrp => AuditObjectKind::Roa, + VcirOutputType::Aspa => AuditObjectKind::Aspa, + VcirOutputType::RouterKey => AuditObjectKind::RouterCertificate, + } +} + fn build_objects_output_from_vcir( vcir: &ValidatedCaInstanceResult, validation_time: time::OffsetDateTime, @@ -1411,11 +1545,7 @@ fn build_objects_output_from_vcir( ObjectAuditEntry { rsync_uri: local.source_object_uri.clone(), sha256_hex: local.source_object_hash.clone(), - kind: if local.output_type == VcirOutputType::Vrp { - AuditObjectKind::Roa - } else { - AuditObjectKind::Aspa - }, + kind: audit_kind_for_vcir_output_type(local.output_type), result: AuditObjectResult::Error, detail: Some( "cached local output has invalid item_effective_until".to_string(), @@ -1431,11 +1561,7 @@ fn build_objects_output_from_vcir( .or_insert_with(|| ObjectAuditEntry { rsync_uri: local.source_object_uri.clone(), sha256_hex: local.source_object_hash.clone(), - kind: if local.output_type == VcirOutputType::Vrp { - AuditObjectKind::Roa - } else { - AuditObjectKind::Aspa - }, + kind: audit_kind_for_vcir_output_type(local.output_type), result: AuditObjectResult::Skipped, detail: Some("skipped: cached local output expired".to_string()), }); @@ -1507,6 +1633,37 @@ fn build_objects_output_from_vcir( ); } }, + VcirOutputType::RouterKey => match parse_vcir_router_key_output(local) { + Ok(router_key) => { + output.router_keys.push(router_key); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::RouterCertificate, + result: AuditObjectResult::Ok, + detail: Some("cached Router Key local output restored".to_string()), + }, + ); + } + Err(e) => { + warnings.push( + Warning::new(format!("cached Router Key local output parse failed: {e}")) + .with_context(&local.source_object_uri), + ); + audit_by_uri.insert( + local.source_object_uri.clone(), + ObjectAuditEntry { + rsync_uri: local.source_object_uri.clone(), + sha256_hex: local.source_object_hash.clone(), + kind: AuditObjectKind::RouterCertificate, + result: AuditObjectResult::Error, + detail: Some(format!("cached Router Key local output parse failed: {e}")), + }, + ); + } + }, } } @@ -1539,6 +1696,24 @@ fn parse_vcir_aspa_output(local: &VcirLocalOutput) -> Result Result { + let payload: VcirRouterKeyPayload = serde_json::from_str(&local.payload_json) + .map_err(|e| format!("invalid Router Key payload JSON: {e}"))?; + let ski = hex::decode(&payload.ski_hex) + .map_err(|e| format!("invalid Router Key SKI hex: {e}"))?; + let spki_der = base64::engine::general_purpose::STANDARD.decode(&payload.spki_der_base64) + .map_err(|e| format!("invalid Router Key SPKI base64: {e}"))?; + Ok(RouterKeyPayload { + as_id: payload.as_id, + ski, + spki_der, + source_object_uri: local.source_object_uri.clone(), + source_object_hash: local.source_object_hash.clone(), + source_ee_cert_hash: local.source_ee_cert_hash.clone(), + item_effective_until: local.item_effective_until.clone(), + }) +} + fn parse_vcir_prefix(prefix: &str) -> Result { let (addr, len) = prefix .split_once('/') @@ -1775,6 +1950,10 @@ fn build_vcir_from_fresh_result( .iter() .filter(|output| output.output_type == VcirOutputType::Aspa) .count() as u32, + local_router_key_count: local_outputs + .iter() + .filter(|output| output.output_type == VcirOutputType::RouterKey) + .count() as u32, child_count: discovered_children.len() as u32, accepted_object_count, rejected_object_count, @@ -1937,6 +2116,47 @@ fn build_vcir_local_outputs( Ok(out) } +fn build_router_key_local_outputs( + ca: &CaInstanceHandle, + router_keys: &[RouterKeyPayload], +) -> Vec { + router_keys + .iter() + .map(|router_key| { + let ski_hex = hex::encode(&router_key.ski); + let spki_der_base64 = base64::engine::general_purpose::STANDARD.encode(&router_key.spki_der); + let rule_hash = sha256_hex( + format!( + "router-key-rule:{}:{}:{}:{}", + router_key.source_object_hash, router_key.as_id, ski_hex, spki_der_base64 + ) + .as_bytes(), + ); + VcirLocalOutput { + output_id: rule_hash.clone(), + output_type: VcirOutputType::RouterKey, + item_effective_until: router_key.item_effective_until.clone(), + source_object_uri: router_key.source_object_uri.clone(), + source_object_type: "router_key".to_string(), + source_object_hash: router_key.source_object_hash.clone(), + source_ee_cert_hash: router_key.source_ee_cert_hash.clone(), + payload_json: json!({ + "as_id": router_key.as_id, + "ski_hex": ski_hex, + "spki_der_base64": spki_der_base64, + }) + .to_string(), + rule_hash, + validation_path_hint: vec![ + ca.manifest_rsync_uri.clone(), + router_key.source_object_uri.clone(), + router_key.source_object_hash.clone(), + ], + } + }) + .collect() +} + fn build_vcir_child_entries( discovered_children: &[DiscoveredChildCaInstance], validation_time: time::OffsetDateTime, @@ -2494,6 +2714,191 @@ authorityKeyIdentifier = keyid:always } } + + + struct GeneratedRouter { + issuer_ca_der: Vec, + router_der: Vec, + issuer_crl_der: Vec, + } + + fn generate_router_cert_with_variant(key_spec: &str, include_eku: bool) -> GeneratedRouter { + assert!(openssl_available(), "openssl is required for this test"); + + let td = tempfile::tempdir().expect("tempdir"); + let dir = td.path(); + + std::fs::create_dir_all(dir.join("newcerts")).expect("newcerts"); + std::fs::write(dir.join("index.txt"), b"").expect("index"); + std::fs::write(dir.join("serial"), b"1000\n").expect("serial"); + std::fs::write(dir.join("crlnumber"), b"1000\n").expect("crlnumber"); + + let eku_line = if include_eku { + "extendedKeyUsage = 1.3.6.1.5.5.7.3.30" + } else { + "" + }; + let cnf = format!( + r#" +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = {dir} +database = $dir/index.txt +new_certs_dir = $dir/newcerts +certificate = $dir/issuer.pem +private_key = $dir/issuer.key +serial = $dir/serial +crlnumber = $dir/crlnumber +default_md = sha256 +default_days = 365 +default_crl_days = 1 +policy = policy_any +x509_extensions = v3_issuer_ca +crl_extensions = crl_ext +unique_subject = no +copy_extensions = none + +[ policy_any ] +commonName = supplied + +[ req ] +prompt = no +distinguished_name = dn + +[ dn ] +CN = Test Issuer CA + +[ v3_issuer_ca ] +basicConstraints = critical,CA:true +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +certificatePolicies = critical, 1.3.6.1.5.5.7.14.2 +subjectInfoAccess = caRepository;URI:rsync://example.test/repo/issuer/, rpkiManifest;URI:rsync://example.test/repo/issuer/issuer.mft, rpkiNotify;URI:https://example.test/notification.xml +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8 +sbgp-autonomousSysNum = critical, AS:64496-64511 + +[ v3_router ] +keyUsage = critical, digitalSignature +{eku_line} +authorityKeyIdentifier = keyid:always +crlDistributionPoints = URI:rsync://example.test/repo/issuer/issuer.crl +authorityInfoAccess = caIssuers;URI:rsync://example.test/repo/issuer/issuer.cer +certificatePolicies = critical, 1.3.6.1.5.5.7.14.2 +sbgp-autonomousSysNum = critical, AS:64496 + +[ crl_ext ] +authorityKeyIdentifier = keyid:always +"#, + dir = dir.display(), + eku_line = eku_line, + ); + std::fs::write(dir.join("openssl.cnf"), cnf.as_bytes()).expect("write cnf"); + + run(Command::new("openssl") + .arg("genrsa") + .arg("-out") + .arg(dir.join("issuer.key")) + .arg("2048")); + run(Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-x509") + .arg("-sha256") + .arg("-days") + .arg("365") + .arg("-key") + .arg(dir.join("issuer.key")) + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-extensions") + .arg("v3_issuer_ca") + .arg("-out") + .arg(dir.join("issuer.pem"))); + + match key_spec { + "ec-p256" => run(Command::new("openssl") + .arg("ecparam") + .arg("-name") + .arg("prime256v1") + .arg("-genkey") + .arg("-noout") + .arg("-out") + .arg(dir.join("router.key"))), + "ec-p384" => run(Command::new("openssl") + .arg("ecparam") + .arg("-name") + .arg("secp384r1") + .arg("-genkey") + .arg("-noout") + .arg("-out") + .arg(dir.join("router.key"))), + other => panic!("unsupported key_spec {other}"), + } + + run(Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-key") + .arg(dir.join("router.key")) + .arg("-subj") + .arg("/CN=ROUTER-0000FC10/serialNumber=01020304") + .arg("-out") + .arg(dir.join("router.csr"))); + + run(Command::new("openssl") + .arg("ca") + .arg("-batch") + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-in") + .arg(dir.join("router.csr")) + .arg("-extensions") + .arg("v3_router") + .arg("-out") + .arg(dir.join("router.pem"))); + + run(Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("issuer.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("issuer.cer"))); + run(Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("router.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("router.cer"))); + + run(Command::new("openssl") + .arg("ca") + .arg("-gencrl") + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-out") + .arg(dir.join("issuer.crl.pem"))); + run(Command::new("openssl") + .arg("crl") + .arg("-in") + .arg(dir.join("issuer.crl.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("issuer.crl"))); + + GeneratedRouter { + issuer_ca_der: std::fs::read(dir.join("issuer.cer")).expect("read issuer der"), + router_der: std::fs::read(dir.join("router.cer")).expect("read router der"), + issuer_crl_der: std::fs::read(dir.join("issuer.crl")).expect("read crl der"), + } + } fn dummy_pack_with_files(files: Vec) -> PublicationPointSnapshot { let now = time::OffsetDateTime::now_utc(); PublicationPointSnapshot { @@ -2577,10 +2982,12 @@ authorityKeyIdentifier = keyid:always let child_manifest_uri = "rsync://example.test/repo/child/child.mft".to_string(); let roa_uri = "rsync://example.test/repo/issuer/a.roa".to_string(); let aspa_uri = "rsync://example.test/repo/issuer/a.asa".to_string(); + let router_uri = "rsync://example.test/repo/issuer/router.cer".to_string(); let manifest_hash = sha256_hex(b"manifest-bytes"); let current_crl_hash = sha256_hex(b"current-crl-bytes"); let roa_hash = sha256_hex(b"roa-bytes"); let aspa_hash = sha256_hex(b"aspa-bytes"); + let router_hash = sha256_hex(b"router-bytes"); let ee_hash = sha256_hex(b"ee-cert-bytes"); let gate_until = PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)); ValidatedCaInstanceResult { @@ -2641,6 +3048,22 @@ authorityKeyIdentifier = keyid:always rule_hash: sha256_hex(b"aspa-rule"), validation_path_hint: vec![manifest_uri.clone(), aspa_uri.clone()], }, + VcirLocalOutput { + output_id: sha256_hex(b"router-key-out"), + output_type: VcirOutputType::RouterKey, + item_effective_until: PackTime::from_utc_offset_datetime(now + time::Duration::minutes(30)), + source_object_uri: router_uri.clone(), + source_object_type: "router_key".to_string(), + source_object_hash: router_hash.clone(), + source_ee_cert_hash: router_hash.clone(), + payload_json: serde_json::json!({ + "as_id": 64496, + "ski_hex": "11".repeat(20), + "spki_der_base64": base64::engine::general_purpose::STANDARD.encode([0x30u8, 0x00]), + }).to_string(), + rule_hash: sha256_hex(b"router-key-rule"), + validation_path_hint: vec![manifest_uri.clone(), router_uri.clone()], + }, ], related_artifacts: vec![ VcirRelatedArtifact { @@ -2687,6 +3110,7 @@ authorityKeyIdentifier = keyid:always summary: VcirSummary { local_vrp_count: 1, local_aspa_count: 1, + local_router_key_count: 1, child_count: 1, accepted_object_count: 4, rejected_object_count: 0, @@ -2765,6 +3189,7 @@ authorityKeyIdentifier = keyid:always &crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: cached.clone(), warnings: Vec::new(), stats: crate::validation::objects::ObjectsStats::default(), @@ -2831,6 +3256,39 @@ authorityKeyIdentifier = keyid:always } } + #[test] + fn build_router_key_local_outputs_encodes_router_key_payloads() { + let ca = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: Vec::new(), + ca_certificate_rsync_uri: None, + effective_ip_resources: None, + effective_as_resources: None, + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + let outputs = build_router_key_local_outputs( + &ca, + &[RouterKeyPayload { + as_id: 64496, + ski: vec![0x11; 20], + spki_der: vec![0x30, 0x00], + source_object_uri: "rsync://example.test/repo/issuer/router.cer".to_string(), + source_object_hash: "11".repeat(32), + source_ee_cert_hash: "11".repeat(32), + item_effective_until: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string() }, + }], + ); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].output_type, VcirOutputType::RouterKey); + assert_eq!(outputs[0].source_object_type, "router_key"); + assert!(outputs[0].payload_json.contains("spki_der_base64")); + } + #[test] fn build_vcir_local_outputs_falls_back_to_decoding_accepted_objects_when_cache_is_empty() { let (pack, issuer_ca_der, validation_time) = cernet_publication_point_snapshot_for_vcir_tests(); @@ -2996,6 +3454,7 @@ authorityKeyIdentifier = keyid:always let objects = crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: crate::validation::objects::ObjectsStats::default(), @@ -3566,6 +4025,7 @@ authorityKeyIdentifier = keyid:always let objects = crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: crate::validation::objects::ObjectsStats::default(), @@ -3627,6 +4087,7 @@ authorityKeyIdentifier = keyid:always let objects = crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: crate::validation::objects::ObjectsStats::default(), @@ -3670,6 +4131,145 @@ authorityKeyIdentifier = keyid:always let _ = now; } + + #[test] + fn discover_children_with_router_certificate_records_ok_audit_and_no_child() { + let g = generate_router_cert_with_variant("ec-p256", true); + let pack = dummy_pack_with_files(vec![ + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/issuer.crl", + g.issuer_crl_der.clone(), + ), + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/router.cer", + g.router_der.clone(), + ), + ]); + + let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let issuer = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: g.issuer_ca_der.clone(), + ca_certificate_rsync_uri: Some("rsync://example.test/repo/issuer/issuer.cer".to_string()), + effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), + effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(), + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let out = discover_children_from_fresh_snapshot_with_audit( + &issuer, + &pack, + time::OffsetDateTime::now_utc(), + None, + ) + .expect("discover router cert"); + assert!(out.children.is_empty()); + assert_eq!(out.audits.len(), 1); + assert!(matches!(out.audits[0].result, AuditObjectResult::Ok)); + assert!(out.audits[0] + .detail + .as_deref() + .unwrap_or("") + .contains("validated BGPsec router certificate")); + } + + #[test] + fn discover_children_with_non_router_ee_certificate_records_skipped_audit() { + let g = generate_router_cert_with_variant("ec-p256", false); + let pack = dummy_pack_with_files(vec![ + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/issuer.crl", + g.issuer_crl_der.clone(), + ), + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/router-no-eku.cer", + g.router_der.clone(), + ), + ]); + + let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let issuer = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: g.issuer_ca_der.clone(), + ca_certificate_rsync_uri: Some("rsync://example.test/repo/issuer/issuer.cer".to_string()), + effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), + effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(), + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let out = discover_children_from_fresh_snapshot_with_audit( + &issuer, + &pack, + time::OffsetDateTime::now_utc(), + None, + ) + .expect("discover non-router cert"); + assert!(out.children.is_empty()); + assert_eq!(out.audits.len(), 1); + assert!(matches!(out.audits[0].result, AuditObjectResult::Skipped)); + assert!(out.audits[0] + .detail + .as_deref() + .unwrap_or("") + .contains("not a CA resource certificate or BGPsec router certificate")); + } + + #[test] + fn discover_children_with_invalid_router_certificate_records_error_audit() { + let g = generate_router_cert_with_variant("ec-p384", true); + let pack = dummy_pack_with_files(vec![ + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/issuer.crl", + g.issuer_crl_der.clone(), + ), + PackFile::from_bytes_compute_sha256( + "rsync://example.test/repo/issuer/router-invalid.cer", + g.router_der.clone(), + ), + ]); + + let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let issuer = CaInstanceHandle { + depth: 0, + tal_id: "test-tal".to_string(), + parent_manifest_rsync_uri: None, + ca_certificate_der: g.issuer_ca_der.clone(), + ca_certificate_rsync_uri: Some("rsync://example.test/repo/issuer/issuer.cer".to_string()), + effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(), + effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(), + rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(), + manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(), + publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), + rrdp_notification_uri: None, + }; + + let out = discover_children_from_fresh_snapshot_with_audit( + &issuer, + &pack, + time::OffsetDateTime::now_utc(), + None, + ) + .expect("discover invalid router cert"); + assert!(out.children.is_empty()); + assert_eq!(out.audits.len(), 1); + assert!(matches!(out.audits[0].result, AuditObjectResult::Error)); + assert!(out.audits[0] + .detail + .as_deref() + .unwrap_or("") + .contains("router certificate validation failed")); + } + #[test] fn discover_children_with_audit_records_decode_error_for_corrupt_cer() { let g = generate_chain_and_crl(); @@ -3889,6 +4489,7 @@ authorityKeyIdentifier = keyid:always ); assert_eq!(projection.objects.vrps.len(), 1); assert_eq!(projection.objects.aspas.len(), 1); + assert_eq!(projection.objects.router_keys.len(), 1); assert_eq!(projection.discovered_children.len(), 1); assert_eq!( projection.discovered_children[0].handle.manifest_rsync_uri, @@ -4233,6 +4834,7 @@ authorityKeyIdentifier = keyid:always let objects = crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: vec![Warning::new("objects warning")], stats: crate::validation::objects::ObjectsStats::default(), @@ -4318,6 +4920,7 @@ authorityKeyIdentifier = keyid:always &crate::validation::objects::ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: vec![Warning::new("object warning")], stats: crate::validation::objects::ObjectsStats::default(), diff --git a/tests/test_ccr_m1.rs b/tests/test_ccr_m1.rs new file mode 100644 index 0000000..bde7518 --- /dev/null +++ b/tests/test_ccr_m1.rs @@ -0,0 +1,337 @@ +use rpki::ccr::{ + AspaPayloadSet, AspaPayloadState, CcrContentInfo, CcrDigestAlgorithm, ManifestInstance, + ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, + RpkiCanonicalCacheRepresentation, TrustAnchorState, compute_state_hash, + decode_content_info, encode::{ + encode_manifest_state_payload_der, encode_router_key_state_payload_der, + encode_trust_anchor_state_payload_der, + }, + encode_content_info, verify_state_hash, +}; +use rpki::data_model::common::BigUnsigned; +use rpki::data_model::oid::{OID_CT_RPKI_CCR, OID_CT_RPKI_CCR_RAW}; + +fn sample_time() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + "2026-03-24T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("valid rfc3339") +} + +#[test] +fn minimal_trust_anchor_ccr_roundtrips() { + let skis = vec![vec![0x11; 20], vec![0x22; 20]]; + let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload"); + let state = TrustAnchorState { + skis, + hash: compute_state_hash(&skis_der), + }; + let ccr = RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: None, + vrps: None, + vaps: None, + tas: Some(state), + rks: None, + }; + let content_info = CcrContentInfo::new(ccr.clone()); + let der = encode_content_info(&content_info).expect("encode ccr"); + let decoded = decode_content_info(&der).expect("decode ccr"); + assert_eq!(decoded, content_info); + assert_eq!(decoded.content_type_oid, OID_CT_RPKI_CCR); +} + +#[test] +fn decode_rejects_wrong_content_type_oid() { + let skis = vec![vec![0x11; 20]]; + let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload"); + let content_info = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: None, + vrps: None, + vaps: None, + tas: Some(TrustAnchorState { + skis, + hash: compute_state_hash(&skis_der), + }), + rks: None, + }); + let mut der = encode_content_info(&content_info).expect("encode ccr"); + let needle = OID_CT_RPKI_CCR_RAW; + let pos = der + .windows(needle.len()) + .position(|w| w == needle) + .expect("oid present"); + der[pos + needle.len() - 1] ^= 0x01; + let err = decode_content_info(&der).expect_err("wrong content type must fail"); + assert!(err.to_string().contains("unexpected contentType OID"), "{err}"); +} + +#[test] +fn ccr_requires_at_least_one_state_aspect() { + let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: None, + vrps: None, + vaps: None, + tas: None, + rks: None, + }); + let err = encode_content_info(&ccr).expect_err("empty state aspects must fail"); + assert!(err.to_string().contains("at least one of mfts/vrps/vaps/tas/rks")); +} + +#[test] +fn state_hash_helpers_accept_matching_and_reject_tampered_payload() { + let skis = vec![vec![0x11; 20]]; + let payload_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload"); + let hash = compute_state_hash(&payload_der); + assert!(verify_state_hash(&hash, &payload_der)); + let mut tampered = payload_der.clone(); + *tampered.last_mut().expect("non-empty der") ^= 0x01; + assert!(!verify_state_hash(&hash, &tampered)); +} + +#[test] +fn manifest_and_router_key_skeletons_encode_payloads_and_validate_sorting() { + let manifest_instances = vec![ManifestInstance { + hash: vec![0x33; 32], + size: 2048, + aki: vec![0x44; 20], + manifest_number: BigUnsigned { bytes_be: vec![0x01] }, + this_update: sample_time(), + locations: vec![vec![0x30, 0x00]], + subordinates: vec![vec![0x55; 20]], + }]; + let mis_der = encode_manifest_state_payload_der(&manifest_instances).expect("encode manifest state payload"); + let manifest_state = ManifestState { + mis: manifest_instances, + most_recent_update: sample_time(), + hash: compute_state_hash(&mis_der), + }; + manifest_state.validate().expect("manifest state validate"); + + let rksets = vec![RouterKeySet { + as_id: 64496, + router_keys: vec![RouterKey { + ski: vec![0x66; 20], + spki_der: vec![0x30, 0x00], + }], + }]; + let rk_der = encode_router_key_state_payload_der(&rksets).expect("encode router key payload"); + let rks = RouterKeyState { + rksets, + hash: compute_state_hash(&rk_der), + }; + rks.validate().expect("router key state validate"); +} + +fn sample_manifest_state() -> ManifestState { + 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], vec![0x40; 20]], + }]; + let mis_der = encode_manifest_state_payload_der(&mis).expect("encode mis"); + ManifestState { + mis, + most_recent_update: sample_time(), + hash: compute_state_hash(&mis_der), + } +} + +fn sample_roa_state() -> RoaPayloadState { + let rps = vec![RoaPayloadSet { + as_id: 64496, + ip_addr_blocks: vec![vec![0x30, 0x00]], + }]; + let der = rpki::ccr::encode::encode_roa_payload_state_payload_der(&rps).expect("encode rps"); + RoaPayloadState { + rps, + hash: compute_state_hash(&der), + } +} + +fn sample_aspa_state() -> AspaPayloadState { + let aps = vec![AspaPayloadSet { + customer_as_id: 64496, + providers: vec![64497, 64498], + }]; + let der = rpki::ccr::encode::encode_aspa_payload_state_payload_der(&aps).expect("encode aps"); + AspaPayloadState { + aps, + hash: compute_state_hash(&der), + } +} + +fn sample_ta_state() -> TrustAnchorState { + let skis = vec![vec![0x11; 20], vec![0x22; 20]]; + let der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis"); + TrustAnchorState { + skis, + hash: compute_state_hash(&der), + } +} + +fn sample_router_key_state() -> RouterKeyState { + let rksets = vec![ + RouterKeySet { + as_id: 64496, + router_keys: vec![RouterKey { + ski: vec![0x41; 20], + spki_der: vec![0x30, 0x00], + }], + }, + RouterKeySet { + as_id: 64497, + router_keys: vec![RouterKey { + ski: vec![0x42; 20], + spki_der: vec![0x30, 0x00], + }], + }, + ]; + let der = encode_router_key_state_payload_der(&rksets).expect("encode rksets"); + RouterKeyState { + rksets, + hash: compute_state_hash(&der), + } +} + +#[test] +fn full_ccr_roundtrips_all_supported_state_aspects() { + let ccr = RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: Some(sample_manifest_state()), + vrps: Some(sample_roa_state()), + vaps: Some(sample_aspa_state()), + tas: Some(sample_ta_state()), + rks: Some(sample_router_key_state()), + }; + let encoded = encode_content_info(&CcrContentInfo::new(ccr.clone())).expect("encode full ccr"); + let decoded = decode_content_info(&encoded).expect("decode full ccr"); + assert_eq!(decoded.content, ccr); +} + +#[test] +fn decode_rejects_wrong_digest_algorithm_oid() { + let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: None, + vrps: None, + vaps: None, + tas: Some(sample_ta_state()), + rks: None, + }); + let mut der = encode_content_info(&ccr).expect("encode ccr"); + let oid = rpki::data_model::oid::OID_SHA256_RAW; + let pos = der.windows(oid.len()).position(|w| w == oid).expect("sha256 oid present"); + der[pos + oid.len() - 1] ^= 0x01; + let err = decode_content_info(&der).expect_err("decode must reject wrong digest oid"); + assert!(err.to_string().contains("unexpected digest algorithm OID"), "{err}"); +} + +#[test] +fn decode_rejects_bad_generalized_time() { + let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: None, + vrps: None, + vaps: None, + tas: Some(sample_ta_state()), + rks: None, + }); + let mut der = encode_content_info(&ccr).expect("encode ccr"); + let pos = der.windows(15).position(|w| w == b"20260324000000Z").expect("time present"); + der[pos + 14] = b'X'; + let err = decode_content_info(&der).expect_err("bad time must fail"); + assert!(err.to_string().contains("GeneralizedTime"), "{err}"); +} + +#[test] +fn manifest_state_validate_rejects_unsorted_subordinates() { + let mut state = sample_manifest_state(); + state.mis[0].subordinates = vec![vec![0x40; 20], vec![0x30; 20]]; + let err = state.validate().expect_err("unsorted subordinates must fail"); + assert!(err.to_string().contains("subordinates"), "{err}"); +} + +#[test] +fn roa_payload_state_validate_rejects_duplicate_asn_sets() { + let state = RoaPayloadState { + rps: vec![ + RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x00]] }, + RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x00]] }, + ], + hash: vec![0u8; 32], + }; + let err = state.validate().expect_err("duplicate roa sets must fail"); + assert!(err.to_string().contains("ROAPayloadState.rps"), "{err}"); +} + +#[test] +fn aspa_payload_state_validate_rejects_unsorted_providers() { + let state = AspaPayloadState { + aps: vec![AspaPayloadSet { customer_as_id: 64496, providers: vec![64498, 64497] }], + hash: vec![0u8; 32], + }; + let err = state.validate().expect_err("unsorted providers must fail"); + assert!(err.to_string().contains("providers"), "{err}"); +} + +#[test] +fn trust_anchor_state_validate_rejects_invalid_ski_length() { + let state = TrustAnchorState { + skis: vec![vec![0x11; 19]], + hash: vec![0u8; 32], + }; + let err = state.validate().expect_err("bad ski len must fail"); + assert!(err.to_string().contains("TrustAnchorState.skis"), "{err}"); +} + +#[test] +fn router_key_state_validate_rejects_unsorted_router_keys() { + let state = RouterKeyState { + rksets: vec![RouterKeySet { + as_id: 64496, + router_keys: vec![ + RouterKey { ski: vec![0x42; 20], spki_der: vec![0x30, 0x00] }, + RouterKey { ski: vec![0x41; 20], spki_der: vec![0x30, 0x00] }, + ], + }], + hash: vec![0u8; 32], + }; + let err = state.validate().expect_err("unsorted router keys must fail"); + assert!(err.to_string().contains("router_keys"), "{err}"); +} + +#[test] +fn manifest_instance_validate_rejects_bad_location_tag() { + let instance = 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![0x04, 0x00]], + subordinates: vec![], + }; + let err = instance.validate().expect_err("bad AccessDescription tag must fail"); + assert!(err.to_string().contains("unexpected tag"), "{err}"); +} diff --git a/tests/test_ccr_m7.rs b/tests/test_ccr_m7.rs new file mode 100644 index 0000000..7fb5f79 --- /dev/null +++ b/tests/test_ccr_m7.rs @@ -0,0 +1,202 @@ +use rpki::ccr::{ + CcrContentInfo, CcrDigestAlgorithm, ManifestInstance, ManifestState, RoaPayloadSet, + RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState, + compute_state_hash, decode_content_info, dump_content_info_json_value, + encode::{ + encode_aspa_payload_state_payload_der, encode_content_info, + encode_manifest_state_payload_der, encode_roa_payload_state_payload_der, + encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der, + }, + verify::{verify_against_report_json_path, verify_against_vcir_store, verify_content_info_bytes}, +}; +use rpki::data_model::common::BigUnsigned; +use rpki::storage::{ + PackTime, RocksStore, ValidatedCaInstanceResult, ValidatedManifestMeta, + VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, + VcirChildEntry, VcirInstanceGate, VcirRelatedArtifact, VcirSummary, +}; + +fn sample_time() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + "2026-03-24T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("valid rfc3339") +} + +fn sample_manifest_state() -> ManifestState { + 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 der = encode_manifest_state_payload_der(&mis).expect("encode mis"); + ManifestState { + mis, + most_recent_update: sample_time(), + hash: compute_state_hash(&der), + } +} + +fn sample_roa_state() -> RoaPayloadState { + let rps = vec![RoaPayloadSet { + as_id: 64496, + ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]], + }]; + let der = encode_roa_payload_state_payload_der(&rps).expect("encode rps"); + RoaPayloadState { rps, hash: compute_state_hash(&der) } +} + +fn sample_aspa_state() -> rpki::ccr::AspaPayloadState { + let aps = vec![rpki::ccr::AspaPayloadSet { customer_as_id: 64496, providers: vec![64497] }]; + let der = encode_aspa_payload_state_payload_der(&aps).expect("encode aps"); + rpki::ccr::AspaPayloadState { aps, hash: compute_state_hash(&der) } +} + +fn sample_ta_state() -> TrustAnchorState { + let skis = vec![vec![0x11; 20]]; + let der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis"); + TrustAnchorState { skis, hash: compute_state_hash(&der) } +} + +fn sample_rks() -> RouterKeyState { + let rksets = vec![RouterKeySet { as_id: 64496, router_keys: vec![RouterKey { ski: vec![0x22; 20], spki_der: vec![0x30, 0x00] }] }]; + let der = encode_router_key_state_payload_der(&rksets).expect("encode rk"); + RouterKeyState { rksets, hash: compute_state_hash(&der) } +} + +fn sample_ccr() -> Vec { + let ccr = rpki::ccr::RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: sample_time(), + mfts: Some(sample_manifest_state()), + vrps: Some(sample_roa_state()), + vaps: Some(sample_aspa_state()), + tas: Some(sample_ta_state()), + rks: Some(sample_rks()), + }; + encode_content_info(&CcrContentInfo::new(ccr)).expect("encode ccr") +} + +#[test] +fn verify_content_info_bytes_succeeds_for_valid_ccr() { + let der = sample_ccr(); + let summary = verify_content_info_bytes(&der).expect("verify ccr"); + assert!(summary.state_hashes_ok); + assert_eq!(summary.manifest_instances, 1); + assert_eq!(summary.roa_payload_sets, 1); + assert_eq!(summary.aspa_payload_sets, 1); + assert_eq!(summary.trust_anchor_ski_count, 1); + assert_eq!(summary.router_key_sets, 1); + assert_eq!(summary.router_key_count, 1); +} + +#[test] +fn verify_content_info_bytes_rejects_tampered_manifest_hash() { + let mut content_info = decode_content_info(&sample_ccr()).expect("decode ccr"); + content_info.content.mfts.as_mut().unwrap().hash[0] ^= 0x01; + let der = encode_content_info(&content_info).expect("encode tampered ccr"); + let err = verify_content_info_bytes(&der).expect_err("tampered hash must fail"); + assert!(err.to_string().contains("ManifestState hash mismatch"), "{err}"); +} + +#[test] +fn dump_content_info_json_value_contains_expected_counts() { + let json = dump_content_info_json_value(&sample_ccr()).expect("dump ccr"); + assert_eq!(json["version"], 0); + assert_eq!(json["state_aspects"]["mfts"]["manifest_instances"], 1); + assert_eq!(json["state_aspects"]["vrps"]["payload_sets"], 1); + assert_eq!(json["state_aspects"]["vaps"]["payload_sets"], 1); + assert_eq!(json["state_aspects"]["tas"]["ski_count"], 1); + assert_eq!(json["state_aspects"]["rks"]["router_key_sets"], 1); +} + +#[test] +fn verify_against_report_json_path_rejects_mismatching_report() { + 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()).expect("write report"); + + let mut ci = decode_content_info(&sample_ccr()).expect("decode ccr"); + ci.content.vrps = Some(RoaPayloadState { + rps: vec![RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]] }], + hash: compute_state_hash(&encode_roa_payload_state_payload_der(&[RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]] }]).unwrap()), + }); + verify_against_report_json_path(&ci, &report_path).expect_err("report mismatch expected"); +} + +#[test] +fn verify_against_vcir_store_matches_manifest_hashes() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + let manifest_hash = hex::encode(vec![0x10; 32]); + 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()), + }, + 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: manifest_hash.clone(), + object_type: Some("mft".to_string()), + validation_status: VcirArtifactValidationStatus::Accepted, + }], + 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).expect("put vcir"); + let ci = decode_content_info(&sample_ccr()).expect("decode ccr"); + verify_against_vcir_store(&ci, &store).expect("vcir cross-check"); +} diff --git a/tests/test_ccr_tools_m7.rs b/tests/test_ccr_tools_m7.rs new file mode 100644 index 0000000..597492d --- /dev/null +++ b/tests/test_ccr_tools_m7.rs @@ -0,0 +1,114 @@ + +use rpki::ccr::{ + CcrContentInfo, CcrDigestAlgorithm, TrustAnchorState, compute_state_hash, + encode::{encode_content_info, encode_trust_anchor_state_payload_der}, +}; +use std::process::Command; + +fn sample_ccr_file() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let skis = vec![vec![0x11; 20]]; + let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis"); + let ccr = CcrContentInfo::new(rpki::ccr::RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: time::OffsetDateTime::parse( + "2026-03-24T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("time"), + mfts: None, + vrps: None, + vaps: None, + tas: Some(TrustAnchorState { skis, hash: compute_state_hash(&skis_der) }), + rks: None, + }); + let path = dir.path().join("sample.ccr"); + std::fs::write(&path, encode_content_info(&ccr).expect("encode ccr")).expect("write ccr"); + (dir, path) +} + +#[test] +fn ccr_dump_binary_prints_json_summary() { + let (_dir, ccr_path) = sample_ccr_file(); + let bin = env!("CARGO_BIN_EXE_ccr_dump"); + let out = Command::new(bin) + .args(["--ccr", ccr_path.to_string_lossy().as_ref()]) + .output() + .expect("run ccr_dump"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let json: serde_json::Value = serde_json::from_slice(&out.stdout).expect("parse json"); + assert_eq!(json["version"], 0); + assert_eq!(json["state_aspects"]["tas"]["ski_count"], 1); +} + +#[test] +fn ccr_verify_binary_prints_summary() { + let (_dir, ccr_path) = sample_ccr_file(); + let bin = env!("CARGO_BIN_EXE_ccr_verify"); + let out = Command::new(bin) + .args(["--ccr", ccr_path.to_string_lossy().as_ref()]) + .output() + .expect("run ccr_verify"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let json: serde_json::Value = serde_json::from_slice(&out.stdout).expect("parse json"); + assert_eq!(json["version"], 0); + assert_eq!(json["trust_anchor_ski_count"], 1); + assert_eq!(json["state_hashes_ok"], true); +} + + +#[test] +fn ccr_to_routinator_csv_binary_writes_vrp_csv() { + use rpki::ccr::{ + CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation, + build_roa_payload_state, encode::encode_content_info, + }; + use rpki::validation::objects::Vrp; + use rpki::data_model::roa::{IpPrefix, RoaAfi}; + let dir = tempfile::tempdir().expect("tempdir"); + let ccr_path = dir.path().join("vrp.ccr"); + let csv_path = dir.path().join("out.csv"); + let vrps = vec![Vrp { + asn: 64496, + prefix: IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 24, + addr: [203, 0, 113, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + max_length: 24, + }]; + let roa_state = build_roa_payload_state(&vrps).expect("build roa state"); + let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: time::OffsetDateTime::parse( + "2026-03-25T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("time"), + mfts: None, + vrps: Some(roa_state), + vaps: None, + tas: None, + rks: None, + }); + std::fs::write(&ccr_path, encode_content_info(&ccr).expect("encode ccr")).expect("write ccr"); + + let bin = env!("CARGO_BIN_EXE_ccr_to_routinator_csv"); + let out = Command::new(bin) + .args([ + "--ccr", + ccr_path.to_string_lossy().as_ref(), + "--out", + csv_path.to_string_lossy().as_ref(), + "--trust-anchor", + "apnic", + ]) + .output() + .expect("run ccr_to_routinator_csv"); + assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr)); + let csv = std::fs::read_to_string(csv_path).expect("read csv"); + assert!(csv.contains("ASN,IP Prefix,Max Length,Trust Anchor")); + assert!(csv.contains("AS64496,203.0.113.0/24,24,apnic")); +} diff --git a/tests/test_cli_run_offline_m18.rs b/tests/test_cli_run_offline_m18.rs index 922ff17..938d47d 100644 --- a/tests/test_cli_run_offline_m18.rs +++ b/tests/test_cli_run_offline_m18.rs @@ -1,9 +1,10 @@ #[test] -fn cli_run_offline_mode_executes_and_writes_json() { +fn cli_run_offline_mode_executes_and_writes_json_and_ccr() { let db_dir = tempfile::tempdir().expect("db tempdir"); let repo_dir = tempfile::tempdir().expect("repo tempdir"); let out_dir = tempfile::tempdir().expect("out tempdir"); let report_path = out_dir.path().join("report.json"); + let ccr_path = out_dir.path().join("result.ccr"); let policy_path = out_dir.path().join("policy.toml"); std::fs::write(&policy_path, "sync_preference = \"rsync_only\"\n").expect("write policy"); @@ -31,6 +32,8 @@ fn cli_run_offline_mode_executes_and_writes_json() { "1".to_string(), "--report-json".to_string(), report_path.to_string_lossy().to_string(), + "--ccr-out".to_string(), + ccr_path.to_string_lossy().to_string(), ]; rpki::cli::run(&argv).expect("cli run"); @@ -39,3 +42,41 @@ fn cli_run_offline_mode_executes_and_writes_json() { let v: serde_json::Value = serde_json::from_slice(&bytes).expect("parse report json"); assert_eq!(v["format_version"], 2); } + + +#[test] +fn cli_run_offline_mode_writes_decodable_ccr() { + let db_dir = tempfile::tempdir().expect("db tempdir"); + let repo_dir = tempfile::tempdir().expect("repo tempdir"); + let out_dir = tempfile::tempdir().expect("out tempdir"); + let ccr_path = out_dir.path().join("result.ccr"); + + let tal_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tal/apnic-rfc7730-https.tal"); + let ta_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer"); + + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + db_dir.path().to_string_lossy().to_string(), + "--tal-path".to_string(), + tal_path.to_string_lossy().to_string(), + "--ta-path".to_string(), + ta_path.to_string_lossy().to_string(), + "--rsync-local-dir".to_string(), + repo_dir.path().to_string_lossy().to_string(), + "--max-depth".to_string(), + "0".to_string(), + "--max-instances".to_string(), + "1".to_string(), + "--ccr-out".to_string(), + ccr_path.to_string_lossy().to_string(), + ]; + + rpki::cli::run(&argv).expect("cli run"); + + let bytes = std::fs::read(&ccr_path).expect("read ccr"); + let ccr = rpki::ccr::decode_content_info(&bytes).expect("decode ccr"); + assert!(ccr.content.tas.is_some()); +} diff --git a/tests/test_manifest_processor_m4.rs b/tests/test_manifest_processor_m4.rs index f07fde9..6f75ebb 100644 --- a/tests/test_manifest_processor_m4.rs +++ b/tests/test_manifest_processor_m4.rs @@ -98,6 +98,7 @@ fn store_validated_manifest_baseline( summary: VcirSummary { local_vrp_count: 0, local_aspa_count: 0, + local_router_key_count: 0, child_count: 0, accepted_object_count: 1, rejected_object_count: 0, diff --git a/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs b/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs index 8ec8649..2244c56 100644 --- a/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs +++ b/tests/test_manifest_processor_repo_sync_and_cached_snapshot_cov.rs @@ -101,6 +101,7 @@ fn store_validated_manifest_baseline( summary: VcirSummary { local_vrp_count: 0, local_aspa_count: 0, + local_router_key_count: 0, child_count: 0, accepted_object_count: 1, rejected_object_count: 0, diff --git a/tests/test_router_cert_m4.rs b/tests/test_router_cert_m4.rs new file mode 100644 index 0000000..c952ec6 --- /dev/null +++ b/tests/test_router_cert_m4.rs @@ -0,0 +1,366 @@ +use std::process::Command; + +use rpki::data_model::rc::{ResourceCertificate, ResourceCertKind}; +use rpki::data_model::router_cert::{ + BgpsecRouterCertificate, BgpsecRouterCertificateDecodeError, + BgpsecRouterCertificatePathError, BgpsecRouterCertificateProfileError, +}; + +fn openssl_available() -> bool { + Command::new("openssl") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run(cmd: &mut Command) { + let out = cmd.output().expect("run command"); + if !out.status.success() { + panic!( + "command failed: {:?}\nstdout={}\nstderr={}", + cmd, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } +} + +struct Generated { + issuer_ca_der: Vec, + router_der: Vec, + issuer_crl_der: Vec, + wrong_issuer_der: Vec, +} + +fn generate_router_cert_with_variant( + key_spec: &str, + include_eku: bool, + extra_ext: &str, +) -> Generated { + assert!(openssl_available(), "openssl is required for this test"); + let td = tempfile::tempdir().expect("tempdir"); + let dir = td.path(); + + std::fs::create_dir_all(dir.join("newcerts")).expect("newcerts"); + std::fs::write(dir.join("index.txt"), b"").expect("index"); + std::fs::write(dir.join("serial"), b"1000\n").expect("serial"); + std::fs::write(dir.join("crlnumber"), b"1000\n").expect("crlnumber"); + + let cnf = format!( + r#" +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = {dir} +database = $dir/index.txt +new_certs_dir = $dir/newcerts +certificate = $dir/issuer.pem +private_key = $dir/issuer.key +serial = $dir/serial +crlnumber = $dir/crlnumber +default_md = sha256 +default_days = 365 +default_crl_days = 1 +policy = policy_any +x509_extensions = v3_issuer_ca +crl_extensions = crl_ext +unique_subject = no +copy_extensions = none + +[ policy_any ] +commonName = supplied + +[ req ] +prompt = no +distinguished_name = dn + +[ dn ] +CN = Test Issuer CA + +[ v3_issuer_ca ] +basicConstraints = critical,CA:true +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +certificatePolicies = critical, 1.3.6.1.5.5.7.14.2 +subjectInfoAccess = caRepository;URI:rsync://example.test/repo/issuer/, rpkiManifest;URI:rsync://example.test/repo/issuer/issuer.mft, rpkiNotify;URI:https://example.test/notification.xml +sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8 +sbgp-autonomousSysNum = critical, AS:64496-64511 + +[ v3_router ] +keyUsage = critical, digitalSignature +{eku_line} +authorityKeyIdentifier = keyid:always +crlDistributionPoints = URI:rsync://example.test/repo/issuer/issuer.crl +authorityInfoAccess = caIssuers;URI:rsync://example.test/repo/issuer/issuer.cer +certificatePolicies = critical, 1.3.6.1.5.5.7.14.2 +sbgp-autonomousSysNum = critical, AS:64496 +{eku_line} +{extra_ext} + +[ crl_ext ] +authorityKeyIdentifier = keyid:always +"#, + dir = dir.display(), + eku_line = if include_eku { "extendedKeyUsage = 1.3.6.1.5.5.7.3.30" } else { "" }, + extra_ext = extra_ext + ); + std::fs::write(dir.join("openssl.cnf"), cnf.as_bytes()).expect("write cnf"); + + run(Command::new("openssl") + .arg("genrsa") + .arg("-out") + .arg(dir.join("issuer.key")) + .arg("2048")); + run(Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-x509") + .arg("-sha256") + .arg("-days") + .arg("365") + .arg("-key") + .arg(dir.join("issuer.key")) + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-extensions") + .arg("v3_issuer_ca") + .arg("-out") + .arg(dir.join("issuer.pem"))); + + match key_spec { + "ec-p256" => { + run(Command::new("openssl") + .arg("ecparam") + .arg("-name") + .arg("prime256v1") + .arg("-genkey") + .arg("-noout") + .arg("-out") + .arg(dir.join("router.key"))); + } + "ec-p384" => { + run(Command::new("openssl") + .arg("ecparam") + .arg("-name") + .arg("secp384r1") + .arg("-genkey") + .arg("-noout") + .arg("-out") + .arg(dir.join("router.key"))); + } + "rsa" => { + run(Command::new("openssl") + .arg("genrsa") + .arg("-out") + .arg(dir.join("router.key")) + .arg("2048")); + } + other => panic!("unsupported key_spec {other}"), + } + + run(Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-key") + .arg(dir.join("router.key")) + .arg("-subj") + .arg("/CN=ROUTER-0000FC10/serialNumber=01020304") + .arg("-out") + .arg(dir.join("router.csr"))); + + run(Command::new("openssl") + .arg("ca") + .arg("-batch") + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-in") + .arg(dir.join("router.csr")) + .arg("-extensions") + .arg("v3_router") + .arg("-out") + .arg(dir.join("router.pem"))); + + run(Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("issuer.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("issuer.cer"))); + run(Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("router.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("router.cer"))); + + run(Command::new("openssl") + .arg("ca") + .arg("-gencrl") + .arg("-config") + .arg(dir.join("openssl.cnf")) + .arg("-out") + .arg(dir.join("issuer.crl.pem"))); + run(Command::new("openssl") + .arg("crl") + .arg("-in") + .arg(dir.join("issuer.crl.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("issuer.crl"))); + + // Wrong issuer for path failure. + run(Command::new("openssl") + .arg("genrsa") + .arg("-out") + .arg(dir.join("other.key")) + .arg("2048")); + run(Command::new("openssl") + .arg("req") + .arg("-new") + .arg("-x509") + .arg("-sha256") + .arg("-days") + .arg("365") + .arg("-key") + .arg(dir.join("other.key")) + .arg("-subj") + .arg("/CN=Other Issuer") + .arg("-out") + .arg(dir.join("other.pem"))); + run(Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(dir.join("other.pem")) + .arg("-outform") + .arg("DER") + .arg("-out") + .arg(dir.join("other.cer"))); + + Generated { + issuer_ca_der: std::fs::read(dir.join("issuer.cer")).expect("read issuer der"), + router_der: std::fs::read(dir.join("router.cer")).expect("read router der"), + issuer_crl_der: std::fs::read(dir.join("issuer.crl")).expect("read crl der"), + wrong_issuer_der: std::fs::read(dir.join("other.cer")).expect("read other issuer der"), + } +} + +#[test] +fn decode_bgpsec_router_certificate_fixture_smoke() { + let g = generate_router_cert_with_variant("ec-p256", true, ""); + let cert = BgpsecRouterCertificate::decode_der(&g.router_der).expect("decode router cert"); + assert_eq!(cert.resource_cert.kind, ResourceCertKind::Ee); + assert_eq!(cert.asns, vec![64496]); + assert_eq!(cert.subject_key_identifier.len(), 20); + assert_eq!(cert.spki_der[0], 0x30); +} + +#[test] +fn router_certificate_profile_rejects_missing_eku() { + let g = generate_router_cert_with_variant("ec-p256", false, ""); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku | BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage)), "{err}"); +} + +#[test] +fn router_certificate_profile_rejects_sia_and_ip_resources_and_ranges() { + let g = generate_router_cert_with_variant( + "ec-p256", + true, + "subjectInfoAccess = caRepository;URI:rsync://example.test/repo/router/\n", + ); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SubjectInfoAccessPresent)), "{err}"); + + let g = generate_router_cert_with_variant( + "ec-p256", + true, + "sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8\n", + ); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::IpResourcesPresent)), "{err}"); + + let g = generate_router_cert_with_variant( + "ec-p256", + true, + "sbgp-autonomousSysNum = critical, AS:64496-64500\n", + ); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::AsResourcesRangeNotAllowed)), "{err}"); +} + +#[test] +fn router_certificate_profile_rejects_wrong_spki_algorithm_or_curve() { + let g = generate_router_cert_with_variant("rsa", true, ""); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey)), "{err}"); + + let g = generate_router_cert_with_variant("ec-p384", true, ""); + let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SpkiWrongCurve | BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256)), "{err}"); +} + +#[test] +fn router_certificate_path_validation_accepts_valid_and_rejects_wrong_issuer() { + use rpki::data_model::common::BigUnsigned; + use rpki::data_model::crl::RpkixCrl; + use std::collections::HashSet; + use x509_parser::prelude::FromDer; + use x509_parser::x509::SubjectPublicKeyInfo; + + let g = generate_router_cert_with_variant("ec-p256", true, ""); + let issuer = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer"); + let wrong_issuer = ResourceCertificate::decode_der(&g.wrong_issuer_der).expect("decode wrong issuer"); + let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl"); + let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info).expect("issuer spki"); + assert!(rem.is_empty()); + let (rem, wrong_spki) = SubjectPublicKeyInfo::from_der(&wrong_issuer.tbs.subject_public_key_info).expect("wrong issuer spki"); + assert!(rem.is_empty()); + let now = time::OffsetDateTime::now_utc(); + + let cert = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer( + &g.router_der, + &issuer, + &issuer_spki, + &issuer_crl, + &HashSet::new(), + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + now, + ).expect("router path valid"); + assert_eq!(cert.asns, vec![64496]); + + let err = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer( + &g.router_der, + &wrong_issuer, + &wrong_spki, + &issuer_crl, + &HashSet::new(), + None, + None, + now, + ).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificatePathError::CertPath(_)), "{err}"); + + let rc = ResourceCertificate::decode_der(&g.router_der).expect("decode router rc"); + let mut revoked = HashSet::new(); + revoked.insert(BigUnsigned::from_biguint(&rc.tbs.serial_number).bytes_be); + let err = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer( + &g.router_der, + &issuer, + &issuer_spki, + &issuer_crl, + &revoked, + Some("rsync://example.test/repo/issuer/issuer.cer"), + Some("rsync://example.test/repo/issuer/issuer.crl"), + now, + ).unwrap_err(); + assert!(matches!(err, BgpsecRouterCertificatePathError::CertPath(_)), "{err}"); +} diff --git a/tests/test_tree_failure_handling.rs b/tests/test_tree_failure_handling.rs index 483895a..8cba3ed 100644 --- a/tests/test_tree_failure_handling.rs +++ b/tests/test_tree_failure_handling.rs @@ -112,6 +112,7 @@ fn tree_continues_when_a_publication_point_fails() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -137,6 +138,7 @@ fn tree_continues_when_a_publication_point_fails() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), diff --git a/tests/test_tree_traversal_m14.rs b/tests/test_tree_traversal_m14.rs index c2329a9..65e4afb 100644 --- a/tests/test_tree_traversal_m14.rs +++ b/tests/test_tree_traversal_m14.rs @@ -4,7 +4,7 @@ use rpki::audit::{DiscoveredFrom, PublicationPointAudit}; use rpki::report::Warning; use rpki::storage::{PackFile, PackTime}; use rpki::validation::manifest::PublicationPointSource; -use rpki::validation::objects::{ObjectsOutput, ObjectsStats}; +use rpki::validation::objects::{ObjectsOutput, ObjectsStats, RouterKeyPayload}; use rpki::validation::publication_point::PublicationPointSnapshot; use rpki::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, @@ -122,6 +122,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -143,6 +144,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -164,6 +166,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -185,6 +188,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -243,6 +247,7 @@ fn tree_respects_max_depth_and_max_instances() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -264,6 +269,7 @@ fn tree_respects_max_depth_and_max_instances() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -314,6 +320,7 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -335,6 +342,7 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -366,6 +374,57 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { assert_eq!(df.parent_manifest_rsync_uri, root_manifest); } +#[test] +fn tree_aggregates_router_keys_from_publication_point_results() { + let root_manifest = "rsync://example.test/repo/root.mft"; + + let runner = MockRunner::default().with( + root_manifest, + PublicationPointRunResult { + source: PublicationPointSource::Fresh, + snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")), + warnings: Vec::new(), + objects: ObjectsOutput { + vrps: Vec::new(), + aspas: Vec::new(), + router_keys: vec![ + RouterKeyPayload { + as_id: 64496, + ski: vec![0x11; 20], + spki_der: vec![0x30, 0x00], + source_object_uri: "rsync://example.test/repo/router1.cer".to_string(), + source_object_hash: "11".repeat(32), + source_ee_cert_hash: "11".repeat(32), + item_effective_until: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string() }, + }, + RouterKeyPayload { + as_id: 64497, + ski: vec![0x22; 20], + spki_der: vec![0x30, 0x01], + source_object_uri: "rsync://example.test/repo/router2.cer".to_string(), + source_object_hash: "22".repeat(32), + source_ee_cert_hash: "22".repeat(32), + item_effective_until: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string() }, + }, + ], + local_outputs_cache: Vec::new(), + warnings: Vec::new(), + stats: ObjectsStats::default(), + audit: Vec::new(), + }, + audit: PublicationPointAudit::default(), + discovered_children: Vec::new(), + }, + ); + + let out = run_tree_serial(ca_handle(root_manifest), &runner, &TreeRunConfig::default()) + .expect("run tree"); + + assert_eq!(out.router_keys.len(), 2); + assert_eq!(out.router_keys[0].as_id, 64496); + assert_eq!(out.router_keys[1].as_id, 64497); +} + #[test] fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_queued() { let root_manifest = "rsync://example.test/repo/root.mft"; @@ -388,6 +447,7 @@ fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_que objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), @@ -409,6 +469,7 @@ fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_que objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), + router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(),