rpki/src/validation/objects.rs

4302 lines
153 KiB
Rust

use crate::analysis::timing::TimingHandle;
use crate::audit::{AuditObjectKind, AuditObjectResult, ObjectAuditEntry, sha256_hex_from_32};
use crate::data_model::aspa::{AspaDecodeError, AspaObject, AspaValidateError};
use crate::data_model::manifest::ManifestObject;
use crate::data_model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressOrRange, IpPrefix as RcIpPrefix,
ResourceCertificate,
};
use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError};
use crate::data_model::signed_object::SignedObjectVerifyError;
use crate::parallel::config::ParallelPhase2Config;
use crate::parallel::object_worker::{
ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
};
use crate::policy::{Policy, SignedObjectFailurePolicy};
use crate::report::{RfcRef, Warning};
use crate::storage::{
PackFile, PackTime, RoaCacheProjection, ValidatedCaInstanceResult, VcirArtifactKind,
VcirArtifactRole, VcirArtifactValidationStatus, VcirLocalOutput, VcirLocalOutputPayload,
VcirOutputType, VcirSourceObjectType,
};
use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast};
use crate::validation::manifest::PublicationPointData;
use crate::validation::publication_point::PublicationPointSnapshot;
use sha2::Digest;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;
const RFC_NONE: &[RfcRef] = &[];
const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")];
const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] =
&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")];
fn sha256_hex_to_32(hex_value: &str) -> [u8; 32] {
let bytes = hex::decode(hex_value).expect("internal sha256 hex should decode");
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
out
}
fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(bytes))
}
fn decode_resource_certificate_with_policy(
der: &[u8],
policy: &Policy,
) -> Result<ResourceCertificate, crate::data_model::rc::ResourceCertificateDecodeError> {
if policy.strict.name {
ResourceCertificate::decode_der_with_strict_name(der)
} else {
ResourceCertificate::decode_der(der)
}
}
#[derive(Clone, Debug)]
pub(crate) struct VerifiedIssuerCrl {
crl: crate::data_model::crl::RpkixCrl,
revoked_serials: std::collections::HashSet<Vec<u8>>,
}
#[derive(Clone, Debug)]
pub(crate) enum CachedIssuerCrl {
Pending(Vec<u8>),
Ok(Arc<VerifiedIssuerCrl>),
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IssuerResourcesIndex {
ip_v4: Option<Vec<(Vec<u8>, Vec<u8>)>>,
ip_v6: Option<Vec<(Vec<u8>, Vec<u8>)>>,
asnum: Option<Vec<(u32, u32)>>,
rdi: Option<Vec<(u32, u32)>>,
}
fn extra_rfc_refs_for_crl_selection(e: &ObjectValidateError) -> &'static [RfcRef] {
match e {
ObjectValidateError::MissingCrlDpUris => RFC_CRLDP,
ObjectValidateError::CrlNotFound(_) => RFC_CRLDP_AND_LOCKED_PACK,
_ => RFC_NONE,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vrp {
pub asn: u32,
pub prefix: IpPrefix,
pub max_length: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaAttestation {
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouterKeyPayload {
pub as_id: u32,
pub ski: Vec<u8>,
pub spki_der: Vec<u8>,
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<Vrp>,
pub aspas: Vec<AspaAttestation>,
pub router_keys: Vec<RouterKeyPayload>,
pub local_outputs_cache: Vec<VcirLocalOutput>,
pub warnings: Vec<Warning>,
pub stats: ObjectsStats,
pub audit: Vec<ObjectAuditEntry>,
pub roa_cache_stats: RoaValidationCacheStats,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ObjectsStats {
pub roa_total: usize,
pub roa_ok: usize,
pub aspa_total: usize,
pub aspa_ok: usize,
/// Whether this publication point was dropped due to an unrecoverable objects-processing error
/// (e.g., missing issuer CRL in the pack, or `signed_object_failure_policy=drop_publication_point`).
pub publication_point_dropped: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct RoaValidationCacheStats {
pub enabled_publication_points: usize,
pub vcir_hit_publication_points: usize,
pub vcir_miss_publication_points: usize,
pub hit_roas: usize,
pub miss_roas: usize,
pub blocked_roas: usize,
pub fresh_roas: usize,
pub context_gate_nanos: u64,
pub lookup_nanos: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct RoaValidationCacheInput<'a> {
enabled: bool,
view: Option<&'a RoaValidationCacheView>,
}
impl<'a> RoaValidationCacheInput<'a> {
pub fn disabled() -> Self {
Self {
enabled: false,
view: None,
}
}
pub fn enabled(view: Option<&'a RoaValidationCacheView>) -> Self {
Self {
enabled: true,
view,
}
}
}
impl RoaValidationCacheStats {
pub fn add_assign(&mut self, other: &Self) {
self.enabled_publication_points += other.enabled_publication_points;
self.vcir_hit_publication_points += other.vcir_hit_publication_points;
self.vcir_miss_publication_points += other.vcir_miss_publication_points;
self.hit_roas += other.hit_roas;
self.miss_roas += other.miss_roas;
self.blocked_roas += other.blocked_roas;
self.fresh_roas += other.fresh_roas;
self.context_gate_nanos = self
.context_gate_nanos
.saturating_add(other.context_gate_nanos);
self.lookup_nanos = self.lookup_nanos.saturating_add(other.lookup_nanos);
}
fn for_input(input: RoaValidationCacheInput<'_>, roa_total: usize) -> Self {
let mut stats = Self::default();
if !input.enabled {
return stats;
}
stats.enabled_publication_points = 1;
if input.view.is_some() {
stats.vcir_hit_publication_points = 1;
} else {
stats.vcir_miss_publication_points = 1;
stats.miss_roas = roa_total;
stats.fresh_roas = roa_total;
}
stats
}
fn record_to_timing(&self, timing: Option<&TimingHandle>) {
let Some(timing) = timing else {
return;
};
record_non_zero(
timing,
"roa_validation_cache_enabled_publication_points",
self.enabled_publication_points,
);
record_non_zero(
timing,
"roa_validation_cache_vcir_hit_publication_points",
self.vcir_hit_publication_points,
);
record_non_zero(
timing,
"roa_validation_cache_vcir_miss_publication_points",
self.vcir_miss_publication_points,
);
record_non_zero(timing, "roa_validation_cache_hit_roas", self.hit_roas);
record_non_zero(timing, "roa_validation_cache_miss_roas", self.miss_roas);
record_non_zero(
timing,
"roa_validation_cache_blocked_roas",
self.blocked_roas,
);
record_non_zero(timing, "roa_validation_cache_fresh_roas", self.fresh_roas);
record_non_zero(
timing,
"roa_validation_cache_context_gate_nanos",
self.context_gate_nanos as usize,
);
record_non_zero(
timing,
"roa_validation_cache_lookup_nanos",
self.lookup_nanos as usize,
);
timing.record_phase_nanos(
"roa_validation_cache_context_gate_total",
self.context_gate_nanos,
);
timing.record_phase_nanos("roa_validation_cache_lookup_total", self.lookup_nanos);
}
}
fn record_non_zero(timing: &TimingHandle, key: &'static str, value: usize) {
if value > 0 {
timing.record_count(key, value as u64);
}
}
#[derive(Clone, Debug)]
pub struct RoaValidationCacheView {
entries_by_uri: HashMap<String, CachedRoaValidationResult>,
issuer_ca_sha256_hex: Option<String>,
crl_sha256_by_uri: HashMap<String, String>,
blocked: bool,
}
#[derive(Clone, Debug)]
pub struct CachedRoaValidationResult {
source_object_hash: [u8; 32],
outputs: Vec<VcirLocalOutput>,
}
impl RoaValidationCacheView {
pub fn from_projection(
projection: &RoaCacheProjection,
validation_time: time::OffsetDateTime,
) -> Self {
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
HashMap::with_capacity(projection.entries.len());
let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone();
let crl_sha256_by_uri = projection
.crl_sha256_by_uri
.iter()
.map(|crl| (crl.uri.clone(), crl.sha256.clone()))
.collect::<HashMap<_, _>>();
let blocked = projection
.instance_effective_until
.parse()
.map(|effective_until| effective_until <= validation_time)
.unwrap_or(true);
if blocked {
return Self {
entries_by_uri,
issuer_ca_sha256_hex,
crl_sha256_by_uri,
blocked,
};
}
for entry in &projection.entries {
let outputs = entry
.outputs
.iter()
.map(|output| VcirLocalOutput {
output_type: VcirOutputType::Vrp,
item_effective_until: output.item_effective_until.clone(),
source_object_uri: entry.source_object_uri.clone(),
source_object_type: VcirSourceObjectType::Roa,
source_object_hash: entry.source_object_hash,
source_ee_cert_hash: output.source_ee_cert_hash,
payload: output.payload.clone(),
rule_hash: output.rule_hash,
})
.collect::<Vec<_>>();
entries_by_uri.insert(
entry.source_object_uri.clone(),
CachedRoaValidationResult {
source_object_hash: entry.source_object_hash,
outputs,
},
);
}
Self {
entries_by_uri,
issuer_ca_sha256_hex,
crl_sha256_by_uri,
blocked,
}
}
pub fn from_vcir(
vcir: &ValidatedCaInstanceResult,
validation_time: time::OffsetDateTime,
) -> Self {
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> = HashMap::new();
let mut issuer_ca_sha256_hex: Option<String> = None;
let mut crl_sha256_by_uri: HashMap<String, String> = HashMap::new();
let blocked = vcir
.instance_gate
.instance_effective_until
.parse()
.map(|effective_until| effective_until <= validation_time)
.unwrap_or(true);
if blocked {
return Self {
entries_by_uri,
issuer_ca_sha256_hex,
crl_sha256_by_uri,
blocked,
};
}
for artifact in &vcir.related_artifacts {
if artifact.validation_status != VcirArtifactValidationStatus::Accepted {
continue;
}
match (artifact.artifact_role, artifact.artifact_kind) {
(
VcirArtifactRole::IssuerCert | VcirArtifactRole::TrustAnchorCert,
VcirArtifactKind::Cer,
) => {
issuer_ca_sha256_hex = Some(artifact.sha256.clone());
}
(_, VcirArtifactKind::Crl) => {
if let Some(uri) = artifact.uri.as_ref() {
crl_sha256_by_uri.insert(uri.clone(), artifact.sha256.clone());
}
}
_ => {}
}
}
for output in &vcir.local_outputs {
if output.output_type != VcirOutputType::Vrp
|| output.source_object_type != VcirSourceObjectType::Roa
{
continue;
}
entries_by_uri
.entry(output.source_object_uri.clone())
.or_insert_with(|| CachedRoaValidationResult {
source_object_hash: output.source_object_hash,
outputs: Vec::new(),
})
.outputs
.push(output.clone());
}
Self {
entries_by_uri,
issuer_ca_sha256_hex,
crl_sha256_by_uri,
blocked,
}
}
fn matches_current_context(&self, issuer_ca_der: &[u8], locked_files: &[PackFile]) -> bool {
if self.blocked {
return false;
}
let Some(expected_issuer_hash) = self.issuer_ca_sha256_hex.as_ref() else {
return false;
};
if expected_issuer_hash != &sha256_hex(issuer_ca_der) {
return false;
}
let current_crl_hashes = locked_files
.iter()
.filter(|file| file.rsync_uri.ends_with(".crl"))
.map(|file| (file.rsync_uri.clone(), sha256_hex_from_32(&file.sha256)))
.collect::<HashMap<_, _>>();
self.crl_sha256_by_uri == current_crl_hashes
}
fn lookup(
&self,
file: &PackFile,
validation_time: time::OffsetDateTime,
) -> RoaCacheLookupResult {
if self.blocked {
return RoaCacheLookupResult::Blocked;
}
let Some(cached) = self.entries_by_uri.get(file.rsync_uri.as_str()) else {
return RoaCacheLookupResult::Miss;
};
if cached.source_object_hash != file.sha256 {
return RoaCacheLookupResult::Blocked;
}
if cached.outputs.is_empty() {
return RoaCacheLookupResult::Miss;
}
if cached.outputs.iter().any(|output| {
output
.item_effective_until
.parse()
.map_or(true, |t| t <= validation_time)
}) {
return RoaCacheLookupResult::Blocked;
}
let mut vrps = Vec::with_capacity(cached.outputs.len());
for output in &cached.outputs {
let VcirLocalOutputPayload::Vrp {
asn,
afi,
prefix_len,
addr,
max_length,
} = &output.payload
else {
return RoaCacheLookupResult::Blocked;
};
vrps.push(Vrp {
asn: *asn,
prefix: IpPrefix {
afi: *afi,
prefix_len: *prefix_len,
addr: *addr,
},
max_length: *max_length,
});
}
RoaCacheLookupResult::Hit(RoaTaskOk {
vrps,
local_outputs: cached.outputs.clone(),
reused_from_cache: true,
})
}
}
fn active_roa_cache_view<'a>(
roa_cache: RoaValidationCacheInput<'a>,
issuer_ca_der: &[u8],
locked_files: &[PackFile],
stats: &mut RoaValidationCacheStats,
roa_total: usize,
) -> Option<&'a RoaValidationCacheView> {
let view = roa_cache.view?;
let gate_started = Instant::now();
if view.matches_current_context(issuer_ca_der, locked_files) {
stats.context_gate_nanos = stats
.context_gate_nanos
.saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
Some(view)
} else {
stats.context_gate_nanos = stats
.context_gate_nanos
.saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
stats.blocked_roas += roa_total;
stats.fresh_roas += roa_total;
None
}
}
#[derive(Debug)]
enum RoaCacheLookupResult {
Hit(RoaTaskOk),
Miss,
Blocked,
}
#[derive(Clone, Copy)]
pub(crate) struct RoaTask<'a> {
pub(crate) index: usize,
pub(crate) file: &'a PackFile,
}
#[derive(Debug)]
pub(crate) struct RoaTaskOk {
pub(crate) vrps: Vec<Vrp>,
pub(crate) local_outputs: Vec<VcirLocalOutput>,
pub(crate) reused_from_cache: bool,
}
#[derive(Debug)]
pub(crate) struct RoaTaskResult {
pub(crate) publication_point_id: u64,
pub(crate) index: usize,
pub(crate) worker_index: usize,
pub(crate) queue_wait_ms: u64,
pub(crate) worker_ms: u64,
pub(crate) outcome: Result<RoaTaskOk, ObjectValidateError>,
}
/// Process objects from a publication point snapshot using a known issuer CA certificate
/// and its effective resources (resolved via the resource-path, RFC 6487 §7.2).
pub fn process_publication_point_for_issuer<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
) -> ObjectsOutput {
process_publication_point_for_issuer_with_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
true,
)
}
pub fn process_publication_point_for_issuer_with_options<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
process_publication_point_for_issuer_with_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
RoaValidationCacheInput::disabled(),
)
}
pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
let manifest_bytes = publication_point.manifest_bytes();
let locked_files = publication_point.files();
let mut warnings: Vec<Warning> = Vec::new();
let mut stats = ObjectsStats::default();
stats.roa_total = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".roa"))
.count();
stats.aspa_total = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".asa"))
.count();
let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total);
let mut audit: Vec<ObjectAuditEntry> = Vec::new();
// Enforce that `manifest_bytes` is actually a manifest object.
let _manifest = match ManifestObject::decode_der_with_strict_options(
manifest_bytes,
policy.strict.cms_der,
policy.strict.name,
) {
Ok(manifest) => manifest,
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: manifest decode failed: {e}"
))
.with_rfc_refs(&[
RfcRef("RFC 9286 §4"),
RfcRef("RFC 9286 §6.2"),
RfcRef("RFC 9286 §6.6"),
])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: manifest decode failed".to_string()),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: manifest decode failed".to_string()),
});
}
}
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
};
// Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates.
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(v) => v,
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer CA decode failed: {e}"
))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: issuer CA decode failed".to_string()),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: issuer CA decode failed".to_string()),
});
}
}
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
};
// Parse issuer SubjectPublicKeyInfo once and reuse for all EE certificate signature checks.
let issuer_spki = match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) {
Ok((rem, spki)) if rem.is_empty() => spki,
Ok((rem, _)) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: trailing bytes after issuer SPKI DER: {} bytes",
rem.len()
))
.with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
.with_context(manifest_rsync_uri),
);
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer SPKI parse failed: {e}"
))
.with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
.with_context(manifest_rsync_uri),
);
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
};
let mut crl_cache: std::collections::HashMap<String, CachedIssuerCrl> = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".crl"))
.map(|f| {
let bytes = f
.bytes_cloned()
.expect("snapshot CRL bytes must be loadable");
(f.rsync_uri.clone(), CachedIssuerCrl::Pending(bytes))
})
.collect();
let issuer_resources_index =
build_issuer_resources_index(issuer_effective_ip, issuer_effective_as);
// If the snapshot has signed objects but no CRLs at all, we cannot validate any embedded EE
// certificate paths deterministically (EE CRLDP must reference an rsync URI in the snapshot).
if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) {
stats.publication_point_dropped = true;
warnings.push(
Warning::new("dropping publication point: no CRL files in validated publication point")
.with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to missing CRL files in validated publication point"
.to_string(),
),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to missing CRL files in validated publication point"
.to_string(),
),
});
}
}
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
let mut vrps: Vec<Vrp> = Vec::new();
let mut aspas: Vec<AspaAttestation> = Vec::new();
let mut local_outputs_cache: Vec<VcirLocalOutput> = Vec::new();
let active_cache_view = active_roa_cache_view(
roa_cache,
issuer_ca_der,
locked_files,
&mut roa_cache_stats,
stats.roa_total,
);
for (idx, file) in locked_files.iter().enumerate() {
if file.rsync_uri.ends_with(".roa") {
let result = if let Some(cache_view) = active_cache_view {
let lookup_started = Instant::now();
let lookup_result = cache_view.lookup(file, validation_time);
roa_cache_stats.lookup_nanos = roa_cache_stats.lookup_nanos.saturating_add(
lookup_started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
match lookup_result {
RoaCacheLookupResult::Hit(ok) => {
roa_cache_stats.hit_roas += 1;
RoaTaskResult {
publication_point_id: 0,
index: idx,
worker_index: 0,
queue_wait_ms: 0,
worker_ms: 0,
outcome: Ok(ok),
}
}
RoaCacheLookupResult::Miss => {
roa_cache_stats.miss_roas += 1;
roa_cache_stats.fresh_roas += 1;
let task = RoaTask { index: idx, file };
let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
validate_roa_task_serial(
task,
manifest_rsync_uri,
issuer_ca_der,
&issuer_ca,
&issuer_spki,
issuer_ca_rsync_uri,
&mut crl_cache,
&issuer_resources_index,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
policy.strict.cms_der,
policy.strict.name,
)
}
RoaCacheLookupResult::Blocked => {
roa_cache_stats.blocked_roas += 1;
roa_cache_stats.fresh_roas += 1;
let task = RoaTask { index: idx, file };
let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
validate_roa_task_serial(
task,
manifest_rsync_uri,
issuer_ca_der,
&issuer_ca,
&issuer_spki,
issuer_ca_rsync_uri,
&mut crl_cache,
&issuer_resources_index,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
policy.strict.cms_der,
policy.strict.name,
)
}
}
} else {
let task = RoaTask { index: idx, file };
let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
validate_roa_task_serial(
task,
manifest_rsync_uri,
issuer_ca_der,
&issuer_ca,
&issuer_spki,
issuer_ca_rsync_uri,
&mut crl_cache,
&issuer_resources_index,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
policy.strict.cms_der,
policy.strict.name,
)
};
match result.outcome {
Ok(mut ok) => {
stats.roa_ok += 1;
vrps.append(&mut ok.vrps);
if collect_vcir_local_outputs || ok.reused_from_cache {
local_outputs_cache.extend(ok.local_outputs);
}
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Ok,
detail: None,
});
}
Err(e) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri))
.with_rfc_refs(&refs)
.with_context(&file.rsync_uri),
)
}
SignedObjectFailurePolicy::DropPublicationPoint => {
stats.publication_point_dropped = true;
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
for f in locked_files.iter().skip(idx + 1) {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to policy=signed_object_failure_policy=drop_publication_point"
.to_string(),
),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to policy=signed_object_failure_policy=drop_publication_point"
.to_string(),
),
});
}
}
let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!(
"dropping publication point due to invalid ROA: {}: {e}",
file.rsync_uri
))
.with_rfc_refs(&refs)
.with_context(manifest_rsync_uri),
);
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
},
}
} else if file.rsync_uri.ends_with(".asa") {
let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total"));
match process_aspa_with_issuer(
file,
manifest_rsync_uri,
issuer_ca_der,
&issuer_ca,
&issuer_spki,
issuer_ca_rsync_uri,
&mut crl_cache,
&issuer_resources_index,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
policy.strict.cms_der,
policy.strict.name,
) {
Ok((att, local_output)) => {
stats.aspa_ok += 1;
aspas.push(att);
if let Some(local_output) = local_output {
local_outputs_cache.push(local_output);
}
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Ok,
detail: None,
});
}
Err(e) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
let mut refs = vec![RfcRef("RFC 6488 §3")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri))
.with_rfc_refs(&refs)
.with_context(&file.rsync_uri),
)
}
SignedObjectFailurePolicy::DropPublicationPoint => {
stats.publication_point_dropped = true;
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
for f in locked_files.iter().skip(idx + 1) {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to policy=signed_object_failure_policy=drop_publication_point"
.to_string(),
),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to policy=signed_object_failure_policy=drop_publication_point"
.to_string(),
),
});
}
}
let mut refs = vec![RfcRef("RFC 6488 §3")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!(
"dropping publication point due to invalid ASPA: {}: {e}",
file.rsync_uri
))
.with_rfc_refs(&refs)
.with_context(manifest_rsync_uri),
);
return ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
};
}
},
}
}
}
roa_cache_stats.record_to_timing(timing);
ObjectsOutput {
vrps,
aspas,
router_keys: Vec::new(),
local_outputs_cache,
warnings,
stats,
audit,
roa_cache_stats,
}
}
pub fn process_publication_point_for_issuer_parallel_roa<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa_with_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
config,
true,
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_options<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa_with_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
config,
collect_vcir_local_outputs,
RoaValidationCacheInput::disabled(),
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_cache_options<
P: PublicationPointData,
>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
if config.object_workers <= 1
|| policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint
{
return process_publication_point_for_issuer_with_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
roa_cache,
);
}
let pool = match ParallelRoaWorkerPool::new(config) {
Ok(pool) => pool,
Err(_) => {
return process_publication_point_for_issuer_with_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
roa_cache,
);
}
};
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
&pool,
collect_vcir_local_outputs,
roa_cache,
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_pool<P: PublicationPointData>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa_with_pool_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
pool,
true,
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_pool_options<
P: PublicationPointData,
>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
pool,
collect_vcir_local_outputs,
RoaValidationCacheInput::disabled(),
)
}
pub fn process_publication_point_for_issuer_parallel_roa_with_pool_cache_options<
P: PublicationPointData,
>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
if policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint {
return process_publication_point_for_issuer_with_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
);
}
process_publication_point_for_issuer_parallel_roa_inner(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
pool,
collect_vcir_local_outputs,
roa_cache,
)
.unwrap_or_else(|_| {
process_publication_point_for_issuer_with_cache_options(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
roa_cache,
)
})
}
#[derive(Clone)]
pub(crate) struct RoaTaskShared {
locked_files: Arc<[PackFile]>,
manifest_rsync_uri: Arc<str>,
issuer_ca_der: Arc<[u8]>,
issuer_ca: Arc<ResourceCertificate>,
issuer_spki_der: Arc<[u8]>,
issuer_ca_rsync_uri: Option<Arc<str>>,
crl_cache: Arc<Mutex<std::collections::HashMap<String, CachedIssuerCrl>>>,
issuer_resources_index: Arc<IssuerResourcesIndex>,
issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>,
issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>,
}
#[derive(Clone)]
pub(crate) struct OwnedRoaTask {
pub(crate) publication_point_id: u64,
index: usize,
shared: Arc<RoaTaskShared>,
validation_time: time::OffsetDateTime,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
pub(crate) submitted_at: Option<Instant>,
}
#[derive(Clone)]
struct RoaTaskExecutor;
impl ObjectTaskExecutor<OwnedRoaTask, RoaTaskResult> for RoaTaskExecutor {
fn execute(&self, worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
validate_owned_roa_task(worker_index, task)
}
}
pub struct ParallelRoaWorkerPool {
pool: Mutex<ObjectWorkerPool<OwnedRoaTask, RoaTaskResult, RoaTaskExecutor>>,
}
impl ParallelRoaWorkerPool {
pub fn new(config: &ParallelPhase2Config) -> Result<Self, String> {
if config.object_workers <= 1 {
return Err("parallel ROA worker pool requires object_workers > 1".to_string());
}
Ok(Self {
pool: Mutex::new(ObjectWorkerPool::new(
config.object_workers,
config.worker_queue_capacity,
RoaTaskExecutor,
)?),
})
}
pub(crate) fn try_submit_round_robin(
&self,
task: OwnedRoaTask,
) -> Result<usize, ObjectWorkerSubmitError<OwnedRoaTask>> {
self.pool
.lock()
.expect("parallel ROA worker pool lock")
.try_submit_round_robin(task)
}
pub(crate) fn recv_result_timeout(
&self,
timeout: Duration,
) -> Result<Option<RoaTaskResult>, String> {
self.pool
.lock()
.expect("parallel ROA worker pool lock")
.recv_result_timeout(timeout)
}
}
fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
let worker_started = Instant::now();
let queue_wait_ms = task
.submitted_at
.map(|submitted_at| worker_started.saturating_duration_since(submitted_at))
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
let shared = task.shared.as_ref();
let file = task
.shared
.locked_files
.get(task.index)
.expect("ROA task index must reference locked file");
let issuer_spki = match SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref()) {
Ok((rem, spki)) if rem.is_empty() => spki,
Ok((rem, _)) => {
return RoaTaskResult {
publication_point_id: task.publication_point_id,
index: task.index,
worker_index,
queue_wait_ms,
worker_ms: worker_started.elapsed().as_millis() as u64,
outcome: Err(ObjectValidateError::CertPath(
CertPathError::IssuerSpkiTrailingBytes(rem.len()),
)),
};
}
Err(e) => {
return RoaTaskResult {
publication_point_id: task.publication_point_id,
index: task.index,
worker_index,
queue_wait_ms,
worker_ms: worker_started.elapsed().as_millis() as u64,
outcome: Err(ObjectValidateError::CertPath(
CertPathError::IssuerSpkiParse(e.to_string()),
)),
};
}
};
let outcome = process_roa_with_issuer_parallel_cached(
file,
shared.manifest_rsync_uri.as_ref(),
shared.issuer_ca_der.as_ref(),
shared.issuer_ca.as_ref(),
&issuer_spki,
shared.issuer_ca_rsync_uri.as_deref(),
shared.crl_cache.as_ref(),
shared.issuer_resources_index.as_ref(),
shared.issuer_effective_ip.as_deref(),
shared.issuer_effective_as.as_deref(),
task.validation_time,
None,
task.collect_vcir_local_outputs,
task.strict_cms_der,
task.strict_name,
)
.map(|(vrps, local_outputs)| RoaTaskOk {
vrps,
local_outputs,
reused_from_cache: false,
});
RoaTaskResult {
publication_point_id: task.publication_point_id,
index: task.index,
worker_index,
queue_wait_ms,
worker_ms: worker_started.elapsed().as_millis() as u64,
outcome,
}
}
pub(crate) enum ParallelObjectsPrepare {
Complete(ObjectsOutput),
Staged(ParallelObjectsStage),
}
pub(crate) struct ParallelObjectsStage {
pub(crate) publication_point_id: u64,
shared: Arc<RoaTaskShared>,
validation_time: time::OffsetDateTime,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
roa_task_indices: Vec<usize>,
cached_roa_results: Vec<RoaTaskResult>,
roa_cache_stats: RoaValidationCacheStats,
warnings: Vec<Warning>,
stats: ObjectsStats,
audit: Vec<ObjectAuditEntry>,
}
impl ParallelObjectsStage {
#[cfg(test)]
pub(crate) fn build_roa_tasks(&self) -> Vec<OwnedRoaTask> {
let mut tasks = Vec::with_capacity(self.roa_task_count());
self.extend_roa_tasks(|task| tasks.push(task));
tasks
}
pub(crate) fn append_roa_tasks_to(
&self,
pending: &mut std::collections::VecDeque<OwnedRoaTask>,
) {
self.extend_roa_tasks(|task| pending.push_back(task));
}
fn extend_roa_tasks<F>(&self, mut push: F)
where
F: FnMut(OwnedRoaTask),
{
let shared = self.shared.clone();
self.roa_task_indices.iter().for_each(|index| {
push(OwnedRoaTask {
publication_point_id: self.publication_point_id,
index: *index,
shared: shared.clone(),
validation_time: self.validation_time,
collect_vcir_local_outputs: self.collect_vcir_local_outputs,
strict_cms_der: self.strict_cms_der,
strict_name: self.strict_name,
submitted_at: None,
});
});
}
pub(crate) fn roa_task_count(&self) -> usize {
self.roa_task_indices.len()
}
pub(crate) fn aspa_task_count(&self) -> usize {
self.stats.aspa_total
}
pub(crate) fn locked_file_count(&self) -> usize {
self.shared.locked_files.len()
}
}
pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: PublicationPointData>(
publication_point_id: u64,
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ParallelObjectsPrepare {
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
let manifest_bytes = publication_point.manifest_bytes();
let locked_files = publication_point.files();
let mut warnings: Vec<Warning> = Vec::new();
let mut stats = ObjectsStats::default();
stats.roa_total = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".roa"))
.count();
stats.aspa_total = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".asa"))
.count();
let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total);
let mut audit: Vec<ObjectAuditEntry> = Vec::new();
let _manifest = match ManifestObject::decode_der_with_strict_options(
manifest_bytes,
policy.strict.cms_der,
policy.strict.name,
) {
Ok(manifest) => manifest,
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: manifest decode failed: {e}"
))
.with_rfc_refs(&[
RfcRef("RFC 9286 §4"),
RfcRef("RFC 9286 §6.2"),
RfcRef("RFC 9286 §6.6"),
])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: manifest decode failed".to_string()),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: manifest decode failed".to_string()),
});
}
}
return ParallelObjectsPrepare::Complete(ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
});
}
};
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(v) => v,
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer CA decode failed: {e}"
))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: issuer CA decode failed".to_string()),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some("skipped: issuer CA decode failed".to_string()),
});
}
}
return ParallelObjectsPrepare::Complete(ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
});
}
};
match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) {
Ok((rem, _)) if rem.is_empty() => {}
Ok((rem, _)) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: trailing bytes after issuer SPKI DER: {} bytes",
rem.len()
))
.with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
.with_context(manifest_rsync_uri),
);
return ParallelObjectsPrepare::Complete(ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
});
}
Err(e) => {
stats.publication_point_dropped = true;
warnings.push(
Warning::new(format!(
"dropping publication point: issuer SPKI parse failed: {e}"
))
.with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
.with_context(manifest_rsync_uri),
);
return ParallelObjectsPrepare::Complete(ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
});
}
}
let crl_cache: std::collections::HashMap<String, CachedIssuerCrl> = locked_files
.iter()
.filter(|f| f.rsync_uri.ends_with(".crl"))
.map(|f| {
let bytes = f
.bytes_cloned()
.expect("snapshot CRL bytes must be loadable");
(f.rsync_uri.clone(), CachedIssuerCrl::Pending(bytes))
})
.collect();
if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) {
stats.publication_point_dropped = true;
warnings.push(
Warning::new("dropping publication point: no CRL files in validated publication point")
.with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")])
.with_context(manifest_rsync_uri),
);
for f in locked_files {
if f.rsync_uri.ends_with(".roa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to missing CRL files in validated publication point"
.to_string(),
),
});
} else if f.rsync_uri.ends_with(".asa") {
audit.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Skipped,
detail: Some(
"skipped due to missing CRL files in validated publication point"
.to_string(),
),
});
}
}
return ParallelObjectsPrepare::Complete(ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings,
stats,
audit,
roa_cache_stats,
});
}
let active_cache_view = active_roa_cache_view(
roa_cache,
issuer_ca_der,
locked_files,
&mut roa_cache_stats,
stats.roa_total,
);
let mut roa_task_indices = Vec::new();
let mut cached_roa_results = Vec::new();
for (index, file) in locked_files.iter().enumerate() {
if !file.rsync_uri.ends_with(".roa") {
continue;
}
if let Some(cache_view) = active_cache_view {
let lookup_started = Instant::now();
let lookup_result = cache_view.lookup(file, validation_time);
roa_cache_stats.lookup_nanos = roa_cache_stats.lookup_nanos.saturating_add(
lookup_started
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64,
);
match lookup_result {
RoaCacheLookupResult::Hit(ok) => {
roa_cache_stats.hit_roas += 1;
cached_roa_results.push(RoaTaskResult {
publication_point_id,
index,
worker_index: usize::MAX,
queue_wait_ms: 0,
worker_ms: 0,
outcome: Ok(ok),
});
}
RoaCacheLookupResult::Miss => {
roa_cache_stats.miss_roas += 1;
roa_cache_stats.fresh_roas += 1;
roa_task_indices.push(index);
}
RoaCacheLookupResult::Blocked => {
roa_cache_stats.blocked_roas += 1;
roa_cache_stats.fresh_roas += 1;
roa_task_indices.push(index);
}
}
} else {
roa_task_indices.push(index);
}
}
ParallelObjectsPrepare::Staged(ParallelObjectsStage {
publication_point_id,
shared: Arc::new(RoaTaskShared {
locked_files: Arc::<[PackFile]>::from(locked_files.to_vec()),
manifest_rsync_uri: Arc::<str>::from(manifest_rsync_uri),
issuer_ca_der: Arc::<[u8]>::from(issuer_ca_der.to_vec()),
issuer_spki_der: Arc::<[u8]>::from(issuer_ca.tbs.subject_public_key_info.clone()),
issuer_ca: Arc::new(issuer_ca),
issuer_ca_rsync_uri: issuer_ca_rsync_uri.map(Arc::<str>::from),
crl_cache: Arc::new(Mutex::new(crl_cache)),
issuer_resources_index: Arc::new(build_issuer_resources_index(
issuer_effective_ip,
issuer_effective_as,
)),
issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
}),
validation_time,
collect_vcir_local_outputs,
strict_cms_der: policy.strict.cms_der,
strict_name: policy.strict.name,
roa_task_indices,
cached_roa_results,
roa_cache_stats,
warnings,
stats,
audit,
})
}
pub(crate) fn reduce_parallel_roa_stage(
stage: ParallelObjectsStage,
mut roa_results: Vec<RoaTaskResult>,
timing: Option<&TimingHandle>,
) -> Result<ObjectsOutput, String> {
roa_results.extend(stage.cached_roa_results);
roa_results.sort_by_key(|result| result.index);
let mut roa_results = roa_results.into_iter().peekable();
let shared = stage.shared.clone();
let mut aspa_crl_cache = shared
.crl_cache
.lock()
.expect("parallel ROA CRL cache lock")
.clone();
let issuer_spki = SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref())
.map_err(|e| e.to_string())?
.1;
let collect_vcir_local_outputs = stage.collect_vcir_local_outputs;
let validation_time = stage.validation_time;
let strict_cms_der = stage.strict_cms_der;
let strict_name = stage.strict_name;
let roa_cache_stats = stage.roa_cache_stats;
let mut stats = stage.stats;
let mut warnings = stage.warnings;
let mut audit = stage.audit;
let mut vrps: Vec<Vrp> = Vec::new();
let mut aspas: Vec<AspaAttestation> = Vec::new();
let mut local_outputs_cache: Vec<VcirLocalOutput> = Vec::new();
for (idx, file) in shared.locked_files.iter().enumerate() {
if file.rsync_uri.ends_with(".roa") {
let result = match roa_results.peek() {
Some(result) if result.index == idx => roa_results
.next()
.expect("peeked ROA task result must be present"),
Some(result) => {
return Err(format!(
"unexpected ROA task result index {} while reducing {} at index {}",
result.index, file.rsync_uri, idx
));
}
None => {
return Err(format!("missing ROA task result for {}", file.rsync_uri));
}
};
match result.outcome {
Ok(mut ok) => {
stats.roa_ok += 1;
vrps.append(&mut ok.vrps);
if collect_vcir_local_outputs || ok.reused_from_cache {
local_outputs_cache.extend(ok.local_outputs);
}
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Ok,
detail: None,
});
}
Err(e) => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri))
.with_rfc_refs(&refs)
.with_context(&file.rsync_uri),
)
}
}
} else if file.rsync_uri.ends_with(".asa") {
let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total"));
match process_aspa_with_issuer(
file,
shared.manifest_rsync_uri.as_ref(),
shared.issuer_ca_der.as_ref(),
shared.issuer_ca.as_ref(),
&issuer_spki,
shared.issuer_ca_rsync_uri.as_deref(),
&mut aspa_crl_cache,
shared.issuer_resources_index.as_ref(),
shared.issuer_effective_ip.as_deref(),
shared.issuer_effective_as.as_deref(),
validation_time,
timing,
collect_vcir_local_outputs,
strict_cms_der,
strict_name,
) {
Ok((att, local_output)) => {
stats.aspa_ok += 1;
aspas.push(att);
if let Some(local_output) = local_output {
local_outputs_cache.push(local_output);
}
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Ok,
detail: None,
});
}
Err(e) => {
audit.push(ObjectAuditEntry {
rsync_uri: file.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&file.sha256),
kind: AuditObjectKind::Aspa,
result: AuditObjectResult::Error,
detail: Some(e.to_string()),
});
let mut refs = vec![RfcRef("RFC 6488 §3")];
refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
warnings.push(
Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri))
.with_rfc_refs(&refs)
.with_context(&file.rsync_uri),
)
}
}
}
}
if let Some(result) = roa_results.next() {
return Err(format!(
"unexpected trailing ROA task result at index {}",
result.index
));
}
roa_cache_stats.record_to_timing(timing);
Ok(ObjectsOutput {
vrps,
aspas,
router_keys: Vec::new(),
local_outputs_cache,
warnings,
stats,
audit,
roa_cache_stats,
})
}
fn process_publication_point_for_issuer_parallel_roa_inner<P: PublicationPointData>(
publication_point: &P,
_policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
pool: &ParallelRoaWorkerPool,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> Result<ObjectsOutput, String> {
let stage = match prepare_publication_point_for_parallel_roa_with_cache(
0,
publication_point,
_policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
collect_vcir_local_outputs,
roa_cache,
) {
ParallelObjectsPrepare::Complete(out) => return Ok(out),
ParallelObjectsPrepare::Staged(stage) => stage,
};
let roa_task_count = stage.roa_task_count();
let mut pending = std::collections::VecDeque::with_capacity(roa_task_count);
stage.append_roa_tasks_to(&mut pending);
let mut worker_pool = pool
.pool
.lock()
.map_err(|_| "parallel ROA worker pool lock poisoned".to_string())?;
while let Some(task) = pending.pop_front() {
match worker_pool.try_submit_round_robin(task) {
Ok(_) => {}
Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => {
pending.push_front(task);
std::thread::yield_now();
}
Err(ObjectWorkerSubmitError::Disconnected { .. }) => {
return Err("parallel ROA worker queue disconnected".to_string());
}
}
}
let mut roa_results = Vec::with_capacity(roa_task_count);
while roa_results.len() < roa_task_count {
let Some(result) = worker_pool.recv_result_timeout(Duration::from_secs(30))? else {
return Err("parallel ROA worker timed out".to_string());
};
roa_results.push(result);
}
drop(worker_pool);
reduce_parallel_roa_stage(stage, roa_results, timing)
}
/// Compatibility wrapper that processes a publication point snapshot.
pub fn process_publication_point_snapshot_for_issuer(
pack: &PublicationPointSnapshot,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
) -> ObjectsOutput {
process_publication_point_for_issuer(
pack,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
)
}
pub fn process_publication_point_snapshot_for_issuer_parallel_roa(
pack: &PublicationPointSnapshot,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
config: &ParallelPhase2Config,
) -> ObjectsOutput {
process_publication_point_for_issuer_parallel_roa(
pack,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
config,
)
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ObjectValidateError {
#[error("object bytes load failed: {0}")]
BytesLoad(String),
#[error("ROA decode failed: {0}")]
RoaDecode(#[from] RoaDecodeError),
#[error("ROA embedded EE resource validation failed: {0}")]
RoaEeResources(#[from] RoaValidateError),
#[error("ASPA decode failed: {0}")]
AspaDecode(#[from] AspaDecodeError),
#[error("ASPA embedded EE resource validation failed: {0}")]
AspaEeResources(#[from] AspaValidateError),
#[error("CMS signature verification failed: {0}")]
Signature(#[from] SignedObjectVerifyError),
#[error("EE certificate path validation failed: {0}")]
CertPath(#[from] CertPathError),
#[error(
"certificate CRLDistributionPoints URIs missing (cannot select issuer CRL) (RFC 6487 §4.8.6)"
)]
MissingCrlDpUris,
#[error(
"no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)"
)]
MissingCrlInPack,
#[error(
"CRL referenced by CRLDistributionPoints not found in publication point snapshot: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)"
)]
CrlNotFound(String),
#[error(
"issuer effective IP resources missing (cannot validate EE IP resources subset) (RFC 6487 §7.2; RFC 3779 §2.3)"
)]
MissingIssuerEffectiveIp,
#[error(
"issuer effective AS resources missing (cannot validate EE AS resources subset) (RFC 6487 §7.2; RFC 3779 §3.3)"
)]
MissingIssuerEffectiveAs,
#[error(
"EE certificate resources are not a subset of issuer effective resources (RFC 6487 §7.2; RFC 3779)"
)]
EeResourcesNotSubset,
}
pub(crate) fn validate_roa_task_serial(
task: RoaTask<'_>,
manifest_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
) -> RoaTaskResult {
let outcome = process_roa_with_issuer(
task.file,
manifest_rsync_uri,
issuer_ca_der,
issuer_ca,
issuer_spki,
issuer_ca_rsync_uri,
crl_cache,
issuer_resources_index,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
strict_cms_der,
strict_name,
)
.map(|(vrps, local_outputs)| RoaTaskOk {
vrps,
local_outputs,
reused_from_cache: false,
});
RoaTaskResult {
publication_point_id: 0,
index: task.index,
worker_index: 0,
queue_wait_ms: 0,
worker_ms: 0,
outcome,
}
}
fn process_roa_with_issuer(
file: &PackFile,
_manifest_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>), ObjectValidateError> {
let _decode = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
let roa = RoaObject::decode_der_with_strict_options(
file.bytes().map_err(ObjectValidateError::BytesLoad)?,
strict_cms_der,
strict_name,
)?;
drop(_decode);
let _ee_profile = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_embedded_ee_total"));
roa.validate_embedded_ee_cert()?;
drop(_ee_profile);
let _verify = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_verify_signature_total"));
roa.signed_object.verify()?;
drop(_verify);
let ee = &roa.signed_object.signed_data.certificates[0];
let ee_crldp_uris = ee
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref();
let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?;
let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?;
let _cert_path = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total"));
validate_signed_object_ee_cert_path_fast(
ee,
issuer_ca,
issuer_spki,
&verified_crl.crl,
&verified_crl.revoked_serials,
issuer_ca_rsync_uri,
Some(issuer_crl_rsync_uri),
validation_time,
)?;
drop(_cert_path);
let _subset = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
validate_ee_resources_subset(
&ee.resource_cert,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
)?;
drop(_subset);
let vrps = roa_to_vrps(&roa);
if !collect_vcir_local_outputs {
return Ok((vrps, Vec::new()));
}
let source_object_hash = sha256_hex_from_32(&file.sha256);
let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
let item_effective_until =
PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
let local_outputs = vrps
.iter()
.map(|vrp| {
let prefix = vrp_prefix_to_string(vrp);
let rule_hash = crate::audit::sha256_hex(
format!(
"roa-rule:{}:{}:{}:{}",
source_object_hash, vrp.asn, prefix, vrp.max_length
)
.as_bytes(),
);
VcirLocalOutput {
output_type: VcirOutputType::Vrp,
item_effective_until: item_effective_until.clone(),
source_object_uri: file.rsync_uri.clone(),
source_object_type: VcirSourceObjectType::Roa,
source_object_hash: file.sha256,
source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
payload: VcirLocalOutputPayload::Vrp {
asn: vrp.asn,
afi: vrp.prefix.afi,
prefix_len: vrp.prefix.prefix_len,
addr: vrp.prefix.addr,
max_length: vrp.max_length,
},
rule_hash: sha256_hex_to_32(&rule_hash),
}
})
.collect();
Ok((vrps, local_outputs))
}
fn process_roa_with_issuer_parallel_cached(
file: &PackFile,
_manifest_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_cache: &Mutex<std::collections::HashMap<String, CachedIssuerCrl>>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>), ObjectValidateError> {
let _decode = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
let roa = RoaObject::decode_der_with_strict_options(
file.bytes().map_err(ObjectValidateError::BytesLoad)?,
strict_cms_der,
strict_name,
)?;
drop(_decode);
let _ee_profile = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_embedded_ee_total"));
roa.validate_embedded_ee_cert()?;
drop(_ee_profile);
let _verify = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_verify_signature_total"));
roa.signed_object.verify()?;
drop(_verify);
let ee = &roa.signed_object.signed_data.certificates[0];
let ee_crldp_uris = ee
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref();
let (issuer_crl_rsync_uri, verified_crl) = {
let mut crl_cache = crl_cache.lock().expect("parallel ROA CRL cache lock");
let issuer_crl_rsync_uri =
choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache)?.to_string();
let verified_crl =
ensure_issuer_crl_verified(&issuer_crl_rsync_uri, &mut crl_cache, issuer_ca_der)?;
(issuer_crl_rsync_uri, verified_crl)
};
let _cert_path = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total"));
validate_signed_object_ee_cert_path_fast(
ee,
issuer_ca,
issuer_spki,
&verified_crl.crl,
&verified_crl.revoked_serials,
issuer_ca_rsync_uri,
Some(issuer_crl_rsync_uri.as_str()),
validation_time,
)?;
drop(_cert_path);
let _subset = timing
.as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
validate_ee_resources_subset(
&ee.resource_cert,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
)?;
drop(_subset);
let vrps = roa_to_vrps(&roa);
if !collect_vcir_local_outputs {
return Ok((vrps, Vec::new()));
}
let source_object_hash = sha256_hex_from_32(&file.sha256);
let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
let item_effective_until =
PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
let local_outputs = vrps
.iter()
.map(|vrp| {
let prefix = vrp_prefix_to_string(vrp);
let rule_hash = crate::audit::sha256_hex(
format!(
"roa-rule:{}:{}:{}:{}",
source_object_hash, vrp.asn, prefix, vrp.max_length
)
.as_bytes(),
);
VcirLocalOutput {
output_type: VcirOutputType::Vrp,
item_effective_until: item_effective_until.clone(),
source_object_uri: file.rsync_uri.clone(),
source_object_type: VcirSourceObjectType::Roa,
source_object_hash: file.sha256,
source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
payload: VcirLocalOutputPayload::Vrp {
asn: vrp.asn,
afi: vrp.prefix.afi,
prefix_len: vrp.prefix.prefix_len,
addr: vrp.prefix.addr,
max_length: vrp.max_length,
},
rule_hash: sha256_hex_to_32(&rule_hash),
}
})
.collect();
Ok((vrps, local_outputs))
}
fn process_aspa_with_issuer(
file: &PackFile,
_manifest_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_ca_rsync_uri: Option<&str>,
crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
issuer_resources_index: &IssuerResourcesIndex,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
strict_cms_der: bool,
strict_name: bool,
) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> {
let _decode = timing
.as_ref()
.map(|t| t.span_phase("objects_aspa_decode_and_validate_total"));
let aspa = AspaObject::decode_der_with_strict_options(
file.bytes().map_err(ObjectValidateError::BytesLoad)?,
strict_cms_der,
strict_name,
)?;
drop(_decode);
let _ee_profile = timing
.as_ref()
.map(|t| t.span_phase("objects_aspa_validate_embedded_ee_total"));
aspa.validate_embedded_ee_cert()?;
drop(_ee_profile);
let _verify = timing
.as_ref()
.map(|t| t.span_phase("objects_aspa_verify_signature_total"));
aspa.signed_object.verify()?;
drop(_verify);
let ee = &aspa.signed_object.signed_data.certificates[0];
let ee_crldp_uris = ee
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref();
let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?;
let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?;
let _cert_path = timing
.as_ref()
.map(|t| t.span_phase("objects_aspa_validate_ee_cert_path_total"));
validate_signed_object_ee_cert_path_fast(
ee,
issuer_ca,
issuer_spki,
&verified_crl.crl,
&verified_crl.revoked_serials,
issuer_ca_rsync_uri,
Some(issuer_crl_rsync_uri),
validation_time,
)?;
drop(_cert_path);
let _subset = timing
.as_ref()
.map(|t| t.span_phase("objects_aspa_validate_ee_resources_subset_total"));
validate_ee_resources_subset(
&ee.resource_cert,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
)?;
drop(_subset);
let attestation = AspaAttestation {
customer_as_id: aspa.aspa.customer_as_id,
provider_as_ids: aspa.aspa.provider_as_ids.clone(),
};
if !collect_vcir_local_outputs {
return Ok((attestation, None));
}
let source_object_hash = sha256_hex_from_32(&file.sha256);
let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
let item_effective_until =
PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
let providers = attestation
.provider_as_ids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let rule_hash = crate::audit::sha256_hex(
format!(
"aspa-rule:{}:{}:{}",
source_object_hash, attestation.customer_as_id, providers
)
.as_bytes(),
);
let local_output = VcirLocalOutput {
output_type: VcirOutputType::Aspa,
item_effective_until,
source_object_uri: file.rsync_uri.clone(),
source_object_type: VcirSourceObjectType::Aspa,
source_object_hash: file.sha256,
source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
payload: VcirLocalOutputPayload::Aspa {
customer_as_id: attestation.customer_as_id,
provider_as_ids: attestation.provider_as_ids.clone(),
},
rule_hash: sha256_hex_to_32(&rule_hash),
};
Ok((attestation, Some(local_output)))
}
fn vrp_prefix_to_string(vrp: &Vrp) -> String {
let prefix = &vrp.prefix;
match prefix.afi {
RoaAfi::Ipv4 => {
let addr = std::net::Ipv4Addr::new(
prefix.addr[0],
prefix.addr[1],
prefix.addr[2],
prefix.addr[3],
);
format!("{addr}/{}", prefix.prefix_len)
}
RoaAfi::Ipv6 => {
let mut octets = [0u8; 16];
octets.copy_from_slice(&prefix.addr[..16]);
let addr = std::net::Ipv6Addr::from(octets);
format!("{addr}/{}", prefix.prefix_len)
}
}
}
fn choose_crl_uri_for_certificate<'a>(
crldp_uris: Option<&'a Vec<String>>,
crl_cache: &std::collections::HashMap<String, CachedIssuerCrl>,
) -> Result<&'a str, ObjectValidateError> {
if crl_cache.is_empty() {
return Err(ObjectValidateError::MissingCrlInPack);
}
let Some(crldp_uris) = crldp_uris else {
return Err(ObjectValidateError::MissingCrlDpUris);
};
for u in crldp_uris {
let s = u.as_str();
if crl_cache.contains_key(s) {
return Ok(s);
}
}
Err(ObjectValidateError::CrlNotFound(
crldp_uris
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(", "),
))
}
fn ensure_issuer_crl_verified<'a>(
crl_rsync_uri: &str,
crl_cache: &'a mut std::collections::HashMap<String, CachedIssuerCrl>,
issuer_ca_der: &[u8],
) -> Result<Arc<VerifiedIssuerCrl>, CertPathError> {
let entry = crl_cache
.get_mut(crl_rsync_uri)
.expect("CRL must exist in cache");
match entry {
CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)),
CachedIssuerCrl::Pending(bytes) => {
let der = std::mem::take(bytes);
let crl = crate::data_model::crl::RpkixCrl::decode_der(&der)
.map_err(CertPathError::CrlDecode)?;
crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)
.map_err(CertPathError::CrlVerify)?;
let mut revoked_serials: std::collections::HashSet<Vec<u8>> =
std::collections::HashSet::with_capacity(crl.revoked_certs.len());
for rc in &crl.revoked_certs {
revoked_serials.insert(rc.serial_number.bytes_be.clone());
}
*entry = CachedIssuerCrl::Ok(Arc::new(VerifiedIssuerCrl {
crl,
revoked_serials,
}));
match entry {
CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)),
_ => unreachable!(),
}
}
}
}
fn validate_ee_resources_subset(
ee: &ResourceCertificate,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<(), ObjectValidateError> {
if let Some(child_ip) = ee.tbs.extensions.ip_resources.as_ref() {
let Some(parent_ip) = issuer_effective_ip else {
return Err(ObjectValidateError::MissingIssuerEffectiveIp);
};
if !ip_resources_is_subset_indexed(child_ip, parent_ip, issuer_resources_index) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
if let Some(child_as) = ee.tbs.extensions.as_resources.as_ref() {
let Some(parent_as) = issuer_effective_as else {
return Err(ObjectValidateError::MissingIssuerEffectiveAs);
};
if !as_resources_is_subset_indexed(child_as, parent_as, issuer_resources_index) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
Ok(())
}
fn as_resources_is_subset(child: &AsResourceSet, parent: &AsResourceSet) -> bool {
as_choice_subset(child.asnum.as_ref(), parent.asnum.as_ref())
&& as_choice_subset(child.rdi.as_ref(), parent.rdi.as_ref())
}
fn as_resources_is_subset_indexed(
child: &AsResourceSet,
parent: &AsResourceSet,
idx: &IssuerResourcesIndex,
) -> bool {
let _ = parent;
as_choice_subset_indexed(child.asnum.as_ref(), idx.asnum.as_deref())
&& as_choice_subset_indexed(child.rdi.as_ref(), idx.rdi.as_deref())
}
fn as_choice_subset_indexed(
child: Option<&AsIdentifierChoice>,
parent_intervals: Option<&[(u32, u32)]>,
) -> bool {
let Some(child) = child else {
return true;
};
let Some(parent_intervals) = parent_intervals else {
return false;
};
if matches!(child, AsIdentifierChoice::Inherit) {
return false;
}
let child_intervals = as_choice_to_merged_intervals(child);
for (cmin, cmax) in &child_intervals {
if !as_interval_is_covered(parent_intervals, *cmin, *cmax) {
return false;
}
}
true
}
fn as_choice_subset(
child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>,
) -> bool {
let Some(child) = child else {
return true;
};
let Some(parent) = parent else {
return false;
};
match (child, parent) {
(AsIdentifierChoice::Inherit, _) => return false,
(_, AsIdentifierChoice::Inherit) => return false,
_ => {}
}
let child_intervals = as_choice_to_merged_intervals(child);
let parent_intervals = as_choice_to_merged_intervals(parent);
for (cmin, cmax) in &child_intervals {
if !as_interval_is_covered(&parent_intervals, *cmin, *cmax) {
return false;
}
}
true
}
fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> {
let mut v = Vec::new();
match choice {
AsIdentifierChoice::Inherit => {}
AsIdentifierChoice::AsIdsOrRanges(items) => {
for item in items {
match item {
crate::data_model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)),
crate::data_model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)),
}
}
}
}
v.sort_by_key(|(a, _)| *a);
merge_as_intervals(&v)
}
fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((*min, *max));
continue;
};
if *min <= last.1.saturating_add(1) {
last.1 = last.1.max(*max);
continue;
}
out.push((*min, *max));
}
out
}
fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool {
for (pmin, pmax) in parent {
if *pmin <= min && max <= *pmax {
return true;
}
if *pmin > min {
break;
}
}
false
}
fn ip_resources_is_subset(
child: &crate::data_model::rc::IpResourceSet,
parent: &crate::data_model::rc::IpResourceSet,
) -> bool {
let parent_by_afi = ip_resources_to_merged_intervals(parent);
let child_by_afi = match ip_resources_to_merged_intervals_strict(child) {
Ok(v) => v,
Err(()) => return false,
};
for (afi, child_intervals) in child_by_afi {
let Some(parent_intervals) = parent_by_afi.get(&afi) else {
return false;
};
for (cmin, cmax) in &child_intervals {
if !interval_is_covered(parent_intervals, cmin, cmax) {
return false;
}
}
}
true
}
fn ip_resources_is_subset_indexed(
child: &crate::data_model::rc::IpResourceSet,
parent: &crate::data_model::rc::IpResourceSet,
idx: &IssuerResourcesIndex,
) -> bool {
let _ = parent;
for fam in &child.families {
let parent_intervals = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => idx.ip_v4.as_deref(),
crate::data_model::rc::Afi::Ipv6 => idx.ip_v6.as_deref(),
};
let Some(parent_intervals) = parent_intervals else {
return false;
};
let items = match &fam.choice {
IpAddressChoice::Inherit => return false,
IpAddressChoice::AddressesOrRanges(items) => items,
};
let mut child_intervals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => child_intervals.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => child_intervals.push((r.min.clone(), r.max.clone())),
}
}
if child_intervals.is_empty() {
continue;
}
child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut child_intervals);
if !intervals_are_covered(parent_intervals, &child_intervals) {
return false;
}
}
true
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum AfiKey {
V4,
V6,
}
fn ip_resources_to_merged_intervals(
set: &crate::data_model::rc::IpResourceSet,
) -> std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> {
let mut m: std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> =
std::collections::HashMap::new();
for fam in &set.families {
let afi = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => AfiKey::V4,
crate::data_model::rc::Afi::Ipv6 => AfiKey::V6,
};
match &fam.choice {
IpAddressChoice::Inherit => {
// Effective resource sets should not contain inherit, but if they do we treat it
// as "unknown" by leaving it empty here (subset checks will fail).
}
IpAddressChoice::AddressesOrRanges(items) => {
let ent = m.entry(afi).or_default();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
}
}
}
}
}
for (_afi, v) in m.iter_mut() {
v.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(v);
}
m
}
fn ip_resources_to_merged_intervals_strict(
set: &crate::data_model::rc::IpResourceSet,
) -> Result<std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>>, ()> {
let mut m: std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> =
std::collections::HashMap::new();
for fam in &set.families {
let afi = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => AfiKey::V4,
crate::data_model::rc::Afi::Ipv6 => AfiKey::V6,
};
match &fam.choice {
IpAddressChoice::Inherit => return Err(()),
IpAddressChoice::AddressesOrRanges(items) => {
let ent = m.entry(afi).or_default();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
}
}
}
}
}
for (_afi, v) in m.iter_mut() {
v.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(v);
}
Ok(m)
}
fn merge_ip_intervals_in_place(v: &mut Vec<(Vec<u8>, Vec<u8>)>) {
if v.is_empty() {
return;
}
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(v.len());
for (min, max) in v.drain(..) {
let Some(last) = out.last_mut() else {
out.push((min, max));
continue;
};
if bytes_leq(&min, &last.1) || bytes_is_next(&min, &last.1) {
if bytes_leq(&last.1, &max) {
last.1 = max;
}
continue;
}
out.push((min, max));
}
*v = out;
}
fn interval_is_covered(parent: &[(Vec<u8>, Vec<u8>)], min: &[u8], max: &[u8]) -> bool {
for (pmin, pmax) in parent {
if bytes_leq(pmin, min) && bytes_leq(max, pmax) {
return true;
}
if pmin.as_slice() > min {
break;
}
}
false
}
fn intervals_are_covered(parent: &[(Vec<u8>, Vec<u8>)], child: &[(Vec<u8>, Vec<u8>)]) -> bool {
let mut i = 0usize;
for (cmin, cmax) in child {
while i < parent.len() && parent[i].1.as_slice() < cmin.as_slice() {
i += 1;
}
if i >= parent.len() {
return false;
}
let (pmin, pmax) = &parent[i];
if !bytes_leq(pmin, cmin) || !bytes_leq(cmax, pmax) {
return false;
}
}
true
}
fn prefix_to_range(prefix: &RcIpPrefix) -> (Vec<u8>, Vec<u8>) {
let mut min = prefix.addr.clone();
let mut max = prefix.addr.clone();
let bitlen = prefix.afi.ub();
let plen = prefix.prefix_len.min(bitlen);
for bit in plen..bitlen {
let byte = (bit / 8) as usize;
let offset = 7 - (bit % 8);
let mask = 1u8 << offset;
min[byte] &= !mask;
max[byte] |= mask;
}
(min, max)
}
fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
a <= b
}
fn increment_bytes(v: &[u8]) -> Vec<u8> {
let mut out = v.to_vec();
for i in (0..out.len()).rev() {
if out[i] != 0xFF {
out[i] += 1;
for j in i + 1..out.len() {
out[j] = 0;
}
return out;
}
}
vec![0u8; out.len()]
}
fn bytes_is_next(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut carry: u16 = 1;
for i in (0..b.len()).rev() {
let sum = (b[i] as u16) + carry;
let expected = (sum & 0xFF) as u8;
carry = sum >> 8;
if a[i] != expected {
return false;
}
}
true
}
fn build_issuer_resources_index(
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
) -> IssuerResourcesIndex {
let mut idx = IssuerResourcesIndex::default();
if let Some(ip) = issuer_effective_ip {
let mut v4: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
let mut v6: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for fam in &ip.families {
let ent = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => &mut v4,
crate::data_model::rc::Afi::Ipv6 => &mut v6,
};
match &fam.choice {
IpAddressChoice::Inherit => {
// Effective resources should not contain inherit; leave empty so subset fails.
}
IpAddressChoice::AddressesOrRanges(items) => {
for item in items {
match item {
IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
}
}
}
}
}
if !v4.is_empty() {
v4.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut v4);
idx.ip_v4 = Some(v4);
}
if !v6.is_empty() {
v6.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut v6);
idx.ip_v6 = Some(v6);
}
}
if let Some(asr) = issuer_effective_as {
if let Some(choice) = asr.asnum.as_ref() {
if !matches!(choice, AsIdentifierChoice::Inherit) {
idx.asnum = Some(as_choice_to_merged_intervals(choice));
}
}
if let Some(choice) = asr.rdi.as_ref() {
if !matches!(choice, AsIdentifierChoice::Inherit) {
idx.rdi = Some(as_choice_to_merged_intervals(choice));
}
}
}
idx
}
fn roa_to_vrps(roa: &RoaObject) -> Vec<Vrp> {
let asn = roa.roa.as_id;
let mut out = Vec::new();
for fam in &roa.roa.ip_addr_blocks {
for entry in &fam.addresses {
let max_length = entry.max_length.unwrap_or(entry.prefix.prefix_len);
out.push(Vrp {
asn,
prefix: entry.prefix.clone(),
max_length,
});
}
}
out
}
#[allow(dead_code)]
fn roa_afi_to_string(afi: RoaAfi) -> &'static str {
match afi {
RoaAfi::Ipv4 => "ipv4",
RoaAfi::Ipv6 => "ipv6",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::timing::{TimingHandle, TimingMeta};
use crate::data_model::rc::{
Afi, AsIdOrRange, AsIdentifierChoice, IpAddressFamily, IpAddressOrRange, IpAddressRange,
IpPrefix, IpResourceSet,
};
use crate::policy::Policy;
use crate::storage::{
PackTime, RoaCacheProjection, ValidatedManifestMeta, VcirAuditSummary,
VcirCcrManifestProjection, VcirInstanceGate, VcirRelatedArtifact, VcirSummary,
};
use crate::validation::publication_point::PublicationPointSnapshot;
use std::collections::HashMap;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
fn fixture_bytes(path: &str) -> Vec<u8> {
std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path))
.unwrap_or_else(|e| panic!("read fixture {path}: {e}"))
}
fn fixed_time(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time")
}
fn sample_roa_cache_vcir(
issuer_der: &[u8],
crl_hash: [u8; 32],
roa_hash: [u8; 32],
item_effective_until: OffsetDateTime,
instance_effective_until: OffsetDateTime,
) -> ValidatedCaInstanceResult {
let manifest_time = PackTime::from_utc_offset_datetime(fixed_time("2026-06-04T00:00:00Z"));
let effective_until = PackTime::from_utc_offset_datetime(instance_effective_until);
ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
parent_manifest_rsync_uri: Some("rsync://example.test/repo/parent.mft".to_string()),
tal_id: "test-tal".to_string(),
ca_subject_name: "CN=example".to_string(),
ca_ski: "001122".to_string(),
issuer_ski: "334455".to_string(),
last_successful_validation_time: manifest_time.clone(),
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: vec![1],
validated_manifest_this_update: manifest_time.clone(),
validated_manifest_next_update: effective_until.clone(),
},
ccr_manifest_projection: VcirCcrManifestProjection {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_sha256: vec![0xaa; 32],
manifest_size: 2048,
manifest_ee_aki: vec![0xbb; 20],
manifest_number_be: vec![1],
manifest_this_update: manifest_time.clone(),
manifest_sia_locations_der: vec![vec![0x30, 0x00]],
subordinate_skis: Vec::new(),
},
instance_gate: VcirInstanceGate {
manifest_next_update: effective_until.clone(),
current_crl_next_update: effective_until.clone(),
self_ca_not_after: effective_until.clone(),
instance_effective_until: effective_until,
},
child_entries: Vec::new(),
local_outputs: vec![VcirLocalOutput {
output_type: VcirOutputType::Vrp,
item_effective_until: PackTime::from_utc_offset_datetime(item_effective_until),
source_object_uri: "rsync://example.test/repo/a.roa".to_string(),
source_object_type: VcirSourceObjectType::Roa,
source_object_hash: roa_hash,
source_ee_cert_hash: [0xcc; 32],
payload: VcirLocalOutputPayload::Vrp {
asn: 64500,
afi: RoaAfi::Ipv4,
prefix_len: 24,
addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
max_length: 24,
},
rule_hash: [0xdd; 32],
}],
related_artifacts: vec![
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::IssuerCert,
artifact_kind: VcirArtifactKind::Cer,
uri: Some("rsync://example.test/repo/ca.cer".to_string()),
sha256: sha256_hex(issuer_der),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::CurrentCrl,
artifact_kind: VcirArtifactKind::Crl,
uri: Some("rsync://example.test/repo/current.crl".to_string()),
sha256: sha256_hex_from_32(&crl_hash),
object_type: Some("crl".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
},
],
summary: VcirSummary {
local_vrp_count: 1,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 0,
accepted_object_count: 2,
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 roa_validation_cache_view_hits_when_context_and_hash_match() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let files = vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
crl_hash,
),
];
assert!(view.matches_current_context(issuer_der, &files));
let hit = view.lookup(&files[0], validation_time);
let RoaCacheLookupResult::Hit(ok) = hit else {
panic!("expected cache hit, got {hit:?}");
};
assert!(ok.reused_from_cache);
assert_eq!(ok.vrps.len(), 1);
assert_eq!(ok.vrps[0].asn, 64500);
assert_eq!(ok.local_outputs.len(), 1);
}
#[test]
fn roa_validation_cache_view_from_projection_matches_vcir_view() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let projection = RoaCacheProjection::from_vcir(&vcir)
.expect("build projection")
.expect("projection exists");
let view = RoaValidationCacheView::from_projection(&projection, validation_time);
let files = vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
crl_hash,
),
];
assert!(view.matches_current_context(issuer_der, &files));
let hit = view.lookup(&files[0], validation_time);
let RoaCacheLookupResult::Hit(ok) = hit else {
panic!("expected projection cache hit, got {hit:?}");
};
assert!(ok.reused_from_cache);
assert_eq!(ok.vrps.len(), 1);
assert_eq!(ok.vrps[0].asn, 64500);
assert_eq!(ok.local_outputs.len(), 1);
assert_eq!(
ok.local_outputs[0].source_object_uri,
"rsync://example.test/repo/a.roa"
);
}
#[test]
fn roa_validation_cache_view_from_projection_blocks_on_gates() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let mut projection = RoaCacheProjection::from_vcir(&vcir)
.expect("build projection")
.expect("projection exists");
let files = vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
crl_hash,
),
];
projection.issuer_ca_sha256_hex = Some("00".repeat(32));
let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
assert!(!issuer_changed.matches_current_context(issuer_der, &files));
let mut projection = RoaCacheProjection::from_vcir(&vcir)
.expect("build projection")
.expect("projection exists");
projection.crl_sha256_by_uri[0].sha256 = "11".repeat(32);
let crl_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
assert!(!crl_changed.matches_current_context(issuer_der, &files));
let mut projection = RoaCacheProjection::from_vcir(&vcir)
.expect("build projection")
.expect("projection exists");
projection.entries[0].source_object_hash = [0xff; 32];
let roa_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
assert!(matches!(
roa_changed.lookup(&files[0], validation_time),
RoaCacheLookupResult::Blocked
));
let expired_vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-04T00:00:00Z"),
);
let expired_projection = RoaCacheProjection::from_vcir(&expired_vcir)
.expect("build projection")
.expect("projection exists");
let expired_view =
RoaValidationCacheView::from_projection(&expired_projection, validation_time);
assert!(!expired_view.matches_current_context(issuer_der, &files));
assert!(matches!(
expired_view.lookup(&files[0], validation_time),
RoaCacheLookupResult::Blocked
));
}
#[test]
fn roa_validation_cache_view_blocks_when_context_changes() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let files = vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
[0x33; 32],
),
];
let mut stats =
RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(Some(&view)), 1);
assert!(!view.matches_current_context(issuer_der, &files));
assert!(
active_roa_cache_view(
RoaValidationCacheInput::enabled(Some(&view)),
issuer_der,
&files,
&mut stats,
1,
)
.is_none()
);
assert_eq!(stats.blocked_roas, 1);
assert_eq!(stats.fresh_roas, 1);
}
#[test]
fn roa_validation_cache_view_blocks_expired_output() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-04T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let file = PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
);
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Blocked
));
}
#[test]
fn roa_validation_cache_stats_records_vcir_miss_to_timing() {
let stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(None), 3);
assert_eq!(stats.enabled_publication_points, 1);
assert_eq!(stats.vcir_miss_publication_points, 1);
assert_eq!(stats.miss_roas, 3);
assert_eq!(stats.fresh_roas, 3);
let timing = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
tal_url: None,
db_path: None,
});
stats.record_to_timing(Some(&timing));
let dir = tempfile::tempdir().expect("timing dir");
let path = dir.path().join("timing.json");
timing.write_json(&path, 10).expect("write timing");
let report: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).expect("read timing"))
.expect("parse timing");
assert_eq!(
report["counts"]["roa_validation_cache_enabled_publication_points"],
1
);
assert_eq!(
report["counts"]["roa_validation_cache_vcir_miss_publication_points"],
1
);
assert_eq!(report["counts"]["roa_validation_cache_miss_roas"], 3);
assert_eq!(report["counts"]["roa_validation_cache_fresh_roas"], 3);
}
#[test]
fn roa_validation_cache_view_blocks_expired_instance_gate() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-04T00:00:00Z"),
);
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let file = PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
);
assert!(!view.matches_current_context(issuer_der, &[]));
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Blocked
));
}
#[test]
fn roa_validation_cache_view_ignores_rejected_artifacts_and_non_roa_outputs() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = [0x22; 32];
let roa_hash = [0x11; 32];
let mut vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
vcir.related_artifacts.insert(
0,
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::IssuerCert,
artifact_kind: VcirArtifactKind::Cer,
uri: Some("rsync://example.test/repo/rejected.cer".to_string()),
sha256: "00".repeat(32),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
},
);
vcir.local_outputs.push(VcirLocalOutput {
output_type: VcirOutputType::Aspa,
item_effective_until: PackTime::from_utc_offset_datetime(fixed_time(
"2026-06-07T00:00:00Z",
)),
source_object_uri: "rsync://example.test/repo/a.asa".to_string(),
source_object_type: VcirSourceObjectType::Aspa,
source_object_hash: [0x44; 32],
source_ee_cert_hash: [0x55; 32],
payload: VcirLocalOutputPayload::Aspa {
customer_as_id: 64500,
provider_as_ids: vec![64501],
},
rule_hash: [0x66; 32],
});
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let files = vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
crl_hash,
),
];
assert!(view.matches_current_context(issuer_der, &files));
assert!(matches!(
view.lookup(&files[0], validation_time),
RoaCacheLookupResult::Hit(_)
));
assert!(matches!(
view.lookup(
&PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.asa",
vec![0x03],
[0x44; 32],
),
validation_time
),
RoaCacheLookupResult::Miss
));
}
#[test]
fn roa_validation_cache_context_rejects_missing_and_changed_issuer() {
let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: None,
crl_sha256_by_uri: HashMap::new(),
blocked: false,
};
assert!(!view.matches_current_context(b"issuer-ca", &[]));
view.issuer_ca_sha256_hex = Some("00".repeat(32));
assert!(!view.matches_current_context(b"issuer-ca", &[]));
view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
assert!(view.matches_current_context(b"issuer-ca", &[]));
}
#[test]
fn roa_validation_cache_lookup_classifies_hash_empty_payload_and_missing_uri() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let roa_hash = [0x11; 32];
let file = PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
roa_hash,
);
let mut output = sample_roa_cache_vcir(
b"issuer-ca",
[0x22; 32],
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
)
.local_outputs
.remove(0);
let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: Some(sha256_hex(b"issuer-ca")),
crl_sha256_by_uri: HashMap::new(),
blocked: false,
};
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Miss
));
view.entries_by_uri.insert(
file.rsync_uri.clone(),
CachedRoaValidationResult {
source_object_hash: [0xff; 32],
outputs: vec![output.clone()],
},
);
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Blocked
));
view.entries_by_uri.insert(
file.rsync_uri.clone(),
CachedRoaValidationResult {
source_object_hash: roa_hash,
outputs: Vec::new(),
},
);
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Miss
));
output.payload = VcirLocalOutputPayload::Aspa {
customer_as_id: 64500,
provider_as_ids: vec![64501],
};
view.entries_by_uri.insert(
file.rsync_uri.clone(),
CachedRoaValidationResult {
source_object_hash: roa_hash,
outputs: vec![output],
},
);
assert!(matches!(
view.lookup(&file, validation_time),
RoaCacheLookupResult::Blocked
));
}
#[test]
fn parallel_roa_cache_blocked_falls_back_to_single_fresh_task() {
let manifest_bytes = fixture_bytes(
"tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft",
);
let issuer_ca_der = fixture_bytes(
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
);
let crl_hash = [0x22; 32];
let actual_roa_hash = [0x11; 32];
let cached_roa_hash = [0x33; 32];
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let publication_point = PublicationPointSnapshot {
format_version: PublicationPointSnapshot::FORMAT_VERSION_V1,
manifest_rsync_uri:
"rsync://rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"
.to_string(),
publication_point_rsync_uri: "rsync://rpki.cernet.net/repo/cernet/0/".to_string(),
manifest_number_be: vec![1],
this_update: PackTime::from_utc_offset_datetime(validation_time),
next_update: PackTime::from_utc_offset_datetime(
validation_time + time::Duration::days(1),
),
verified_at: PackTime::from_utc_offset_datetime(validation_time),
manifest_bytes,
files: vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/current.crl",
vec![0x02],
crl_hash,
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![0x01],
actual_roa_hash,
),
],
};
let vcir = sample_roa_cache_vcir(
&issuer_ca_der,
crl_hash,
cached_roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let view = RoaValidationCacheView::from_vcir(&vcir, validation_time);
let stage = match prepare_publication_point_for_parallel_roa_with_cache(
7,
&publication_point,
&Policy::default(),
&issuer_ca_der,
None,
None,
None,
validation_time,
false,
RoaValidationCacheInput::enabled(Some(&view)),
) {
ParallelObjectsPrepare::Staged(stage) => stage,
ParallelObjectsPrepare::Complete(_) => panic!("expected staged ROA fallback"),
};
assert_eq!(stage.roa_cache_stats.blocked_roas, 1);
assert_eq!(stage.roa_cache_stats.fresh_roas, 1);
assert_eq!(stage.roa_task_indices, vec![1]);
assert_eq!(stage.roa_task_count(), 1);
}
#[test]
fn merge_as_intervals_merges_overlapping_and_adjacent() {
let v = vec![(1, 2), (3, 5), (10, 10), (11, 12)];
let merged = merge_as_intervals(&v);
assert_eq!(merged, vec![(1, 5), (10, 12)]);
}
#[test]
fn as_choice_subset_rejects_inherit() {
let child = Some(&AsIdentifierChoice::Inherit);
let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range { min: 1, max: 10 },
]));
assert!(!as_choice_subset(child, parent));
}
#[test]
fn as_choice_subset_checks_ranges() {
let child = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Id(5),
AsIdOrRange::Range { min: 7, max: 9 },
]));
let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range { min: 1, max: 10 },
]));
assert!(as_choice_subset(child, parent));
}
#[test]
fn ip_resources_is_subset_accepts_prefixes_and_ranges() {
let parent = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![
IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 8,
addr: vec![10, 0, 0, 0],
}),
IpAddressOrRange::Range(IpAddressRange {
min: vec![192, 0, 2, 0],
max: vec![192, 0, 2, 255],
}),
]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![
IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 16,
addr: vec![10, 1, 0, 0],
}),
IpAddressOrRange::Range(IpAddressRange {
min: vec![192, 0, 2, 10],
max: vec![192, 0, 2, 20],
}),
]),
}],
};
assert!(ip_resources_is_subset(&child, &parent));
}
#[test]
fn ip_resources_is_subset_rejects_inherit_in_child() {
let parent = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv6,
prefix_len: 32,
addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
)]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::Inherit,
}],
};
assert!(!ip_resources_is_subset(&child, &parent));
}
#[test]
fn increment_bytes_wraps_all_ff_to_zero() {
assert_eq!(increment_bytes(&[0xFF, 0xFF]), vec![0x00, 0x00]);
}
#[test]
fn merge_ip_intervals_merges_contiguous() {
let mut v = vec![
(vec![0, 0, 0, 0], vec![0, 0, 0, 10]),
(vec![0, 0, 0, 11], vec![0, 0, 0, 20]),
];
merge_ip_intervals_in_place(&mut v);
assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 20])]);
}
#[test]
fn choose_crl_for_certificate_reports_missing_crl_in_snapshot() {
let roa_der =
fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref();
let crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err();
assert!(matches!(err, ObjectValidateError::MissingCrlInPack));
}
#[test]
fn choose_crl_for_certificate_reports_missing_crldp_uris() {
let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
crl_cache.insert(
"rsync://example.test/a.crl".to_string(),
CachedIssuerCrl::Pending(vec![0x01]),
);
let err = choose_crl_uri_for_certificate(None, &crl_cache).unwrap_err();
assert!(matches!(err, ObjectValidateError::MissingCrlDpUris));
}
#[test]
fn choose_crl_for_certificate_prefers_matching_crldp_uri_in_order() {
let roa_der =
fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref()
.expect("fixture ee has crldp");
let matching_uri = ee_crldp_uris[0].as_str().to_string();
let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
crl_cache.insert(
"rsync://example.test/other.crl".to_string(),
CachedIssuerCrl::Pending(vec![0x00]),
);
crl_cache.insert(matching_uri.clone(), CachedIssuerCrl::Pending(vec![0x01]));
let uri = choose_crl_uri_for_certificate(Some(ee_crldp_uris), &crl_cache).unwrap();
assert_eq!(uri, matching_uri);
}
#[test]
fn choose_crl_for_certificate_reports_not_found_when_crldp_does_not_match_snapshot() {
let roa_der =
fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref();
let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
crl_cache.insert(
"rsync://example.test/other.crl".to_string(),
CachedIssuerCrl::Pending(vec![0x01]),
);
let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err();
assert!(matches!(err, ObjectValidateError::CrlNotFound(_)));
}
#[test]
fn validate_ee_resources_subset_reports_missing_issuer_effective_ip() {
let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa_der = std::fs::read(roa_path).expect("read roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;
let idx = IssuerResourcesIndex::default();
let err = validate_ee_resources_subset(ee, None, None, &idx).unwrap_err();
assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveIp));
}
#[test]
fn validate_ee_resources_subset_reports_missing_issuer_effective_as() {
let aspa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa",
);
let aspa_der = std::fs::read(aspa_path).expect("read aspa");
let aspa = AspaObject::decode_der(&aspa_der).expect("decode aspa");
let ee = &aspa.signed_object.signed_data.certificates[0].resource_cert;
let issuer_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv6,
prefix_len: 32,
addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
)]),
}],
};
let idx = build_issuer_resources_index(Some(&issuer_ip), None);
let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err();
assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveAs));
}
#[test]
fn validate_ee_resources_subset_reports_not_subset() {
let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa_der = std::fs::read(roa_path).expect("read roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;
// Unrelated parent resources.
let issuer_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv6,
prefix_len: 32,
addr: vec![0x26, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
)]),
}],
};
let idx = build_issuer_resources_index(Some(&issuer_ip), None);
let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err();
assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset));
}
#[test]
fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() {
assert_eq!(
extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlDpUris),
RFC_CRLDP
);
assert_eq!(
extra_rfc_refs_for_crl_selection(&ObjectValidateError::CrlNotFound(
"rsync://example.test/x.crl".to_string(),
)),
RFC_CRLDP_AND_LOCKED_PACK
);
assert!(
extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlInPack).is_empty()
);
}
#[test]
fn as_subset_helpers_cover_success_and_failure_paths() {
let child = AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Id(5),
AsIdOrRange::Range { min: 7, max: 9 },
]);
let parent =
AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Range { min: 1, max: 10 }]);
let parent_intervals = [(1, 10)];
assert!(as_choice_subset(None, Some(&parent)));
assert!(!as_choice_subset(Some(&child), None));
assert!(!as_choice_subset(
Some(&AsIdentifierChoice::Inherit),
Some(&parent)
));
assert!(!as_choice_subset(
Some(&child),
Some(&AsIdentifierChoice::Inherit)
));
assert!(as_choice_subset(Some(&child), Some(&parent)));
assert!(as_choice_subset_indexed(None, Some(&parent_intervals)));
assert!(!as_choice_subset_indexed(Some(&child), None));
assert!(!as_choice_subset_indexed(
Some(&AsIdentifierChoice::Inherit),
Some(&parent_intervals),
));
assert!(as_choice_subset_indexed(
Some(&child),
Some(&parent_intervals)
));
assert!(!as_choice_subset_indexed(
Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range { min: 11, max: 12 }
])),
Some(&parent_intervals),
));
let child_set = AsResourceSet {
asnum: Some(child.clone()),
rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(42)])),
};
let parent_set = AsResourceSet {
asnum: Some(parent.clone()),
rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range { min: 40, max: 50 },
])),
};
assert!(as_resources_is_subset(&child_set, &parent_set));
assert!(as_resources_is_subset_indexed(
&child_set,
&parent_set,
&IssuerResourcesIndex {
asnum: Some(vec![(1, 10)]),
rdi: Some(vec![(40, 50)]),
..IssuerResourcesIndex::default()
},
));
}
#[test]
fn ip_subset_helpers_cover_strict_and_indexed_paths() {
let parent = IpResourceSet {
families: vec![
IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![
IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 8,
addr: vec![10, 0, 0, 0],
}),
IpAddressOrRange::Range(IpAddressRange {
min: vec![192, 0, 2, 0],
max: vec![192, 0, 2, 255],
}),
]),
},
IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::Inherit,
},
],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![
IpAddressOrRange::Prefix(IpPrefix {
afi: Afi::Ipv4,
prefix_len: 16,
addr: vec![10, 1, 0, 0],
}),
IpAddressOrRange::Range(IpAddressRange {
min: vec![192, 0, 2, 10],
max: vec![192, 0, 2, 20],
}),
]),
}],
};
let strict_bad = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::Inherit,
}],
};
assert!(ip_resources_is_subset(&child, &parent));
assert!(ip_resources_to_merged_intervals(&parent).contains_key(&AfiKey::V4));
assert!(ip_resources_to_merged_intervals_strict(&child).is_ok());
assert!(ip_resources_to_merged_intervals_strict(&strict_bad).is_err());
let idx = build_issuer_resources_index(
Some(&parent),
Some(&AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: Some(AsIdentifierChoice::Inherit),
}),
);
assert!(idx.ip_v4.is_some());
assert!(idx.ip_v6.is_none());
assert!(idx.asnum.is_some());
assert!(idx.rdi.is_none());
assert!(ip_resources_is_subset_indexed(&child, &parent, &idx));
assert!(!ip_resources_is_subset_indexed(&strict_bad, &parent, &idx));
}
#[test]
fn interval_and_byte_helpers_cover_edge_cases() {
let parent = vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 10])];
assert!(interval_is_covered(&parent, &[0, 0, 0, 1], &[0, 0, 0, 2]));
assert!(!interval_is_covered(
&parent,
&[0, 0, 0, 11],
&[0, 0, 0, 12]
));
assert!(intervals_are_covered(
&parent,
&[(vec![0, 0, 0, 1], vec![0, 0, 0, 2])]
));
assert!(!intervals_are_covered(
&parent,
&[(vec![0, 0, 0, 9], vec![0, 0, 0, 11])],
));
let prefix = RcIpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![203, 0, 113, 7],
};
assert_eq!(
prefix_to_range(&prefix),
(vec![203, 0, 113, 0], vec![203, 0, 113, 255])
);
assert_eq!(increment_bytes(&[0, 0, 0, 255]), vec![0, 0, 1, 0]);
assert!(bytes_is_next(&[0, 0, 1, 0], &[0, 0, 0, 255]));
assert!(!bytes_is_next(&[1, 2], &[1]));
}
#[test]
fn merged_interval_helpers_cover_empty_and_break_paths() {
let mut empty: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
merge_ip_intervals_in_place(&mut empty);
assert!(empty.is_empty());
let mut v = vec![
(vec![0, 0, 0, 20], vec![0, 0, 0, 30]),
(vec![0, 0, 0, 0], vec![0, 0, 0, 10]),
(vec![0, 0, 0, 11], vec![0, 0, 0, 19]),
];
v.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut v);
assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 30])]);
assert_eq!(
as_choice_to_merged_intervals(&AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Id(1),
AsIdOrRange::Range { min: 2, max: 3 },
AsIdOrRange::Range { min: 7, max: 9 },
])),
vec![(1, 3), (7, 9)]
);
assert!(as_interval_is_covered(&[(1, 3), (7, 9)], 2, 3));
assert!(!as_interval_is_covered(&[(7, 9)], 2, 3));
}
#[test]
fn roa_output_helpers_cover_vrps_and_afi_strings() {
let roa_der =
fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let vrps = roa_to_vrps(&roa);
assert!(!vrps.is_empty());
assert!(vrps.iter().all(|vrp| vrp.asn == roa.roa.as_id));
assert_eq!(roa_afi_to_string(RoaAfi::Ipv4), "ipv4");
assert_eq!(roa_afi_to_string(RoaAfi::Ipv6), "ipv6");
}
#[test]
fn parallel_stage_roa_tasks_share_stage_owned_payloads() {
let stage = ParallelObjectsStage {
publication_point_id: 7,
shared: Arc::new(RoaTaskShared {
locked_files: Arc::<[PackFile]>::from(vec![
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/a.roa",
vec![1, 2, 3],
[1u8; 32],
),
PackFile::from_bytes_with_sha256(
"rsync://example.test/repo/b.roa",
vec![4, 5, 6],
[2u8; 32],
),
]),
manifest_rsync_uri: Arc::<str>::from("rsync://example.test/repo/manifest.mft"),
issuer_ca_der: Arc::from([0x01u8].as_slice()),
issuer_ca: Arc::new(
ResourceCertificate::decode_der(&fixture_bytes(
"tests/fixtures/ta/apnic-ta.cer",
))
.expect("decode fixture CA certificate"),
),
issuer_spki_der: Arc::from([0x02u8].as_slice()),
issuer_ca_rsync_uri: Some(Arc::<str>::from("rsync://example.test/repo/ca.cer")),
crl_cache: Arc::new(Mutex::new(HashMap::new())),
issuer_resources_index: Arc::new(IssuerResourcesIndex::default()),
issuer_effective_ip: None,
issuer_effective_as: None,
}),
validation_time: OffsetDateTime::now_utc(),
collect_vcir_local_outputs: false,
strict_cms_der: false,
strict_name: false,
roa_task_indices: vec![0, 1],
cached_roa_results: Vec::new(),
roa_cache_stats: RoaValidationCacheStats::default(),
warnings: Vec::new(),
stats: ObjectsStats {
roa_total: 2,
..ObjectsStats::default()
},
audit: Vec::new(),
};
let tasks = stage.build_roa_tasks();
assert_eq!(tasks.len(), 2);
assert!(Arc::ptr_eq(&tasks[0].shared, &tasks[1].shared));
}
#[test]
fn strict_name_manifest_decode_failure_drops_publication_point() {
let publication_point = PublicationPointSnapshot {
format_version: PublicationPointSnapshot::FORMAT_VERSION_V1,
manifest_rsync_uri: "rsync://example.test/repo/manifest.mft".to_string(),
publication_point_rsync_uri: "rsync://example.test/repo/".to_string(),
manifest_number_be: vec![0x01],
this_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
next_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
verified_at: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
manifest_bytes: vec![0x01, 0x02, 0x03],
files: vec![],
};
let policy = Policy::default();
let output = process_publication_point_for_issuer_with_options(
&publication_point,
&policy,
&[],
None,
None,
None,
OffsetDateTime::now_utc(),
None,
false,
);
assert!(output.stats.publication_point_dropped);
assert!(output.vrps.is_empty());
assert!(
output
.warnings
.iter()
.any(|warning| warning.message.contains("manifest decode failed")),
"{:?}",
output.warnings
);
}
}