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