From daf0764cd02e5f06a8cf5b4ed2f46c92c3f3ec46 Mon Sep 17 00:00:00 2001 From: yuyr Date: Mon, 20 Jul 2026 16:24:47 +0800 Subject: [PATCH] =?UTF-8?q?20260720=20=E8=A7=84=E8=8C=83=E5=8C=96=20CCR=20?= =?UTF-8?q?Manifest=20location=20=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ccr/check_rpki_client_ccr.sh | 107 +++++ src/ccr/accumulator.rs | 136 +++---- src/ccr/build.rs | 121 +++--- src/ccr/decode.rs | 68 ++++ src/ccr/export.rs | 54 ++- src/ccr/manifest_location.rs | 424 ++++++++++++++++++++ src/ccr/mod.rs | 2 + src/cli/tests.rs | 16 +- src/validation/tree_runner.rs | 13 +- src/validation/tree_runner/tests.rs | 29 +- src/validation/tree_runner/vcir_der.rs | 78 ---- tests/test_ccr_manifest_location_interop.rs | 139 +++++++ 12 files changed, 940 insertions(+), 247 deletions(-) create mode 100755 scripts/ccr/check_rpki_client_ccr.sh create mode 100644 src/ccr/manifest_location.rs delete mode 100644 src/validation/tree_runner/vcir_der.rs create mode 100644 tests/test_ccr_manifest_location_interop.rs diff --git a/scripts/ccr/check_rpki_client_ccr.sh b/scripts/ccr/check_rpki_client_ccr.sh new file mode 100755 index 0000000..9c60db0 --- /dev/null +++ b/scripts/ccr/check_rpki_client_ccr.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + check_rpki_client_ccr.sh \ + --rpki-client \ + --ccr-root \ + --out \ + [--limit ] + +Runs the supplied rpki-client importer against CCR files. A non-zero result +means that rpki-client reported a CCR-file parsing error or could not start. +Per-file command output is retained next to the JSONL result file. +EOF +} + +RPKI_CLIENT="" +CCR_ROOT="" +OUT="" +LIMIT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --rpki-client) RPKI_CLIENT=${2:?missing value for --rpki-client}; shift 2 ;; + --ccr-root) CCR_ROOT=${2:?missing value for --ccr-root}; shift 2 ;; + --out) OUT=${2:?missing value for --out}; shift 2 ;; + --limit) LIMIT=${2:?missing value for --limit}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$RPKI_CLIENT" && -n "$CCR_ROOT" && -n "$OUT" ]] || { + echo "error: --rpki-client, --ccr-root, and --out are required" >&2 + usage >&2 + exit 2 +} +[[ -x "$RPKI_CLIENT" ]] || { echo "error: rpki-client is not executable: $RPKI_CLIENT" >&2; exit 2; } +[[ "$LIMIT" =~ ^[0-9]+$ ]] || { echo "error: --limit must be a non-negative integer" >&2; exit 2; } + +mkdir -p "$(dirname "$OUT")" +LOG_DIR="${OUT%.jsonl}.logs" +mkdir -p "$LOG_DIR" +: > "$OUT" + +declare -a CCR_FILES=() +if [[ -f "$CCR_ROOT" ]]; then + CCR_FILES=("$(readlink -f "$CCR_ROOT")") +elif [[ -d "$CCR_ROOT" ]]; then + while IFS= read -r -d '' file; do + CCR_FILES+=("$file") + done < <(find "$CCR_ROOT" -type f -name '*.ccr' -print0 | sort -z) +else + echo "error: CCR root does not exist: $CCR_ROOT" >&2 + exit 2 +fi + +if (( LIMIT > 0 && ${#CCR_FILES[@]} > LIMIT )); then + CCR_FILES=("${CCR_FILES[@]:0:LIMIT}") +fi +if (( ${#CCR_FILES[@]} == 0 )); then + echo "error: no .ccr files found under: $CCR_ROOT" >&2 + exit 2 +fi + +passed=0 +failed=0 +for ccr in "${CCR_FILES[@]}"; do + digest=$(sha256sum "$ccr" | awk '{print $1}') + log="$LOG_DIR/${digest}.log" + set +e + "$RPKI_CLIENT" -f "$ccr" >"$log" 2>&1 + exit_code=$? + set -e + + classification=pass + if (( exit_code != 0 )); then + classification=tool_error + elif grep -Fq "rpki-client: ${ccr}:" "$log"; then + classification=ccr_parse_error + fi + if [[ "$classification" == pass ]]; then + ((passed += 1)) + else + ((failed += 1)) + fi + + python3 - "$OUT" "$ccr" "$digest" "$exit_code" "$classification" "$log" <<'PY' +import json +import sys + +out, ccr, sha256, exit_code, classification, log = sys.argv[1:] +with open(out, "a", encoding="utf-8") as stream: + stream.write(json.dumps({ + "ccr": ccr, + "sha256": sha256, + "rpkiClientExitCode": int(exit_code), + "classification": classification, + "log": log, + }, ensure_ascii=False, sort_keys=True) + "\n") +PY +done + +echo "checked=${#CCR_FILES[@]} passed=${passed} failed=${failed} jsonl=${OUT} logs=${LOG_DIR}" +(( failed == 0 )) diff --git a/src/ccr/accumulator.rs b/src/ccr/accumulator.rs index d118a78..3f12be1 100644 --- a/src/ccr/accumulator.rs +++ b/src/ccr/accumulator.rs @@ -6,6 +6,7 @@ use crate::ccr::build::{ }; use crate::ccr::encode::encode_manifest_state_payload_der; use crate::ccr::hash::compute_state_hash; +use crate::ccr::manifest_location::select_manifest_signed_object_location_from_der; use crate::ccr::model::{ CcrDigestAlgorithm, ManifestInstance, ManifestState, RpkiCanonicalCacheRepresentation, }; @@ -54,7 +55,10 @@ impl CcrManifestContribution { aki: projection.manifest_ee_aki.clone(), manifest_number_be: projection.manifest_number_be.clone(), this_update, - locations_der: projection.manifest_sia_locations_der.clone(), + locations_der: vec![select_manifest_signed_object_location_from_der( + &projection.manifest_rsync_uri, + &projection.manifest_sia_locations_der, + )?], subordinate_skis: projection.subordinate_skis.clone(), }) } @@ -246,7 +250,8 @@ mod tests { use crate::ccr::export::build_ccr_from_run; use crate::ccr::verify::verify_content_info; use crate::data_model::manifest::ManifestObject; - use crate::data_model::rc::SubjectInfoAccess; + use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT}; + use crate::data_model::rc::{AccessDescription, SubjectInfoAccess}; use crate::data_model::roa::{IpPrefix, RoaAfi}; use crate::data_model::ta::TrustAnchor; use crate::data_model::tal::Tal; @@ -283,16 +288,35 @@ mod tests { ) .expect("read manifest"); let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest"); + let manifest_uri = match manifest.signed_object.signed_data.certificates[0] + .resource_cert + .tbs + .extensions + .subject_info_access + .as_ref() + .expect("manifest sia") + { + SubjectInfoAccess::Ee(ee_sia) => ee_sia + .access_descriptions + .iter() + .find(|ad| { + ad.access_method_oid == OID_AD_SIGNED_OBJECT + && ad.access_location.starts_with("rsync://") + }) + .expect("manifest rsync signedObject") + .access_location + .clone(), + SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), + }; let manifest_hash = hex::encode(sha2::Sha256::digest(&manifest_der)); let mut raw = RawByHashEntry::from_bytes(manifest_hash.clone(), manifest_der.clone()); - raw.origin_uris - .push("rsync://example.test/repo/current.mft".to_string()); + raw.origin_uris.push(manifest_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"); let projection = VcirCcrManifestProjection { - manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + manifest_rsync_uri: manifest_uri.clone(), manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(), manifest_size: manifest_der.len() as u64, manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] @@ -312,23 +336,20 @@ mod tests { .as_ref() .expect("manifest sia") { - SubjectInfoAccess::Ee(ee_sia) => ee_sia - .access_descriptions - .iter() - .map(|ad| { - let oid = encode_oid_for_test(&ad.access_method_oid) - .expect("encode access method oid"); - let uri = encode_tlv_for_test(0x86, ad.access_location.as_bytes().to_vec()); - encode_sequence_for_test(&[oid, uri]) - }) - .collect(), + SubjectInfoAccess::Ee(ee_sia) => vec![ + crate::ccr::manifest_location::select_manifest_signed_object_location( + &manifest_uri, + &ee_sia.access_descriptions, + ) + .expect("select manifest signedObject"), + ], SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), }, subordinate_skis: vec![vec![0x33; 20]], }; let vcir = ValidatedCaInstanceResult { - manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + manifest_rsync_uri: manifest_uri.clone(), parent_manifest_rsync_uri: None, tal_id: "apnic".to_string(), ca_subject_name: "CN=test".to_string(), @@ -337,8 +358,8 @@ mod tests { 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(), + current_manifest_rsync_uri: manifest_uri.clone(), + current_crl_rsync_uri: format!("{manifest_uri}.crl"), validated_manifest_meta: ValidatedManifestMeta { validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), validated_manifest_this_update: PackTime::from_utc_offset_datetime( @@ -381,7 +402,7 @@ mod tests { related_artifacts: vec![VcirRelatedArtifact { artifact_role: VcirArtifactRole::Manifest, artifact_kind: VcirArtifactKind::Mft, - uri: Some("rsync://example.test/repo/current.mft".to_string()), + uri: Some(manifest_uri.clone()), sha256: manifest_hash, object_type: Some("mft".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, @@ -429,55 +450,6 @@ mod tests { (vcir, vrps, aspas, router_keys) } - fn encode_oid_for_test(oid: &str) -> Result, String> { - let arcs = oid - .split('.') - .map(|part| part.parse::().map_err(|_| format!("bad oid: {oid}"))) - .collect::, _>>()?; - if arcs.len() < 2 { - return Err(format!("bad oid: {oid}")); - } - let mut body = Vec::new(); - body.push((arcs[0] * 40 + arcs[1]) as u8); - for arc in &arcs[2..] { - encode_base128_for_test(*arc, &mut body); - } - Ok(encode_tlv_for_test(0x06, body)) - } - - fn encode_base128_for_test(mut value: u64, out: &mut Vec) { - let mut tmp = vec![(value & 0x7F) as u8]; - value >>= 7; - while value > 0 { - tmp.push(((value & 0x7F) as u8) | 0x80); - value >>= 7; - } - tmp.reverse(); - out.extend_from_slice(&tmp); - } - - fn encode_sequence_for_test(elements: &[Vec]) -> Vec { - let total_len: usize = elements.iter().map(Vec::len).sum(); - let mut buf = Vec::with_capacity(total_len); - for element in elements { - buf.extend_from_slice(element); - } - encode_tlv_for_test(0x30, buf) - } - - fn encode_tlv_for_test(tag: u8, value: Vec) -> Vec { - let mut out = Vec::with_capacity(1 + 9 + value.len()); - out.push(tag); - if value.len() < 0x80 { - out.push(value.len() as u8); - } else { - out.push(0x81); - out.push(value.len() as u8); - } - out.extend_from_slice(&value); - out - } - #[test] fn accumulator_finish_matches_builder_on_fresh_vcir_inputs() { let td = tempfile::tempdir().expect("tempdir"); @@ -512,4 +484,32 @@ mod tests { let ci = crate::ccr::model::CcrContentInfo::new(accumulated_ccr); verify_content_info(&ci).expect("verify accumulated ccr"); } + + #[test] + fn accumulator_sanitizes_historical_projection_with_rpki_notify() { + let td = tempfile::tempdir().expect("tempdir"); + let store = RocksStore::open(td.path()).expect("open rocksdb"); + let (mut vcir, _, _, _) = sample_vcir_and_manifest(&store); + let notify_der = + crate::ccr::manifest_location::encode_access_description_der(&AccessDescription { + access_method_oid: OID_AD_RPKI_NOTIFY.to_string(), + access_location: "https://rrdp.example.test/notification.xml".to_string(), + }) + .expect("encode rpkiNotify"); + let expected_location = vcir.ccr_manifest_projection.manifest_sia_locations_der[0].clone(); + vcir.ccr_manifest_projection + .manifest_sia_locations_der + .push(notify_der); + + let mut accumulator = CcrAccumulator::new(Vec::new()); + accumulator + .append_manifest_projection(&vcir.ccr_manifest_projection) + .expect("sanitize historical projection"); + let contribution = accumulator + .manifests_by_hash + .values() + .next() + .expect("manifest contribution"); + assert_eq!(contribution.locations_der, vec![expected_location]); + } } diff --git a/src/ccr/build.rs b/src/ccr/build.rs index 6acdf2c..96282c2 100644 --- a/src/ccr/build.rs +++ b/src/ccr/build.rs @@ -9,12 +9,13 @@ use crate::ccr::encode::{ encode_trust_anchor_state_payload_der, }; use crate::ccr::hash::compute_state_hash; +use crate::ccr::manifest_location::select_manifest_signed_object_location; 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::rc::SubjectInfoAccess; use crate::data_model::roa::RoaAfi; use crate::data_model::router_cert::BgpsecRouterCertificate; use crate::data_model::ta::TrustAnchor; @@ -68,12 +69,17 @@ pub enum CcrBuildError { #[error("manifest EE certificate SIA is not the EE form: {0}")] ManifestEeSiaWrongVariant(String), + #[error( + "manifest EE certificate SIA location selection failed for {manifest_rsync_uri}: {detail}" + )] + ManifestSiaLocationSelection { + manifest_rsync_uri: String, + detail: 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), @@ -262,11 +268,16 @@ pub fn build_manifest_state_from_vcirs_with_breakdown( CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone()) })?; let locations = match sia { - SubjectInfoAccess::Ee(ee_sia) => ee_sia - .access_descriptions - .iter() - .map(encode_access_description_der) - .collect::, _>>()?, + SubjectInfoAccess::Ee(ee_sia) => vec![ + select_manifest_signed_object_location( + &vcir.current_manifest_rsync_uri, + &ee_sia.access_descriptions, + ) + .map_err(|detail| CcrBuildError::ManifestSiaLocationSelection { + manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(), + detail, + })?, + ], SubjectInfoAccess::Ca(_) => { return Err(CcrBuildError::ManifestEeSiaWrongVariant( vcir.current_manifest_rsync_uri.clone(), @@ -353,45 +364,6 @@ fn collect_subordinate_ski_bytes( Ok(skis.into_iter().collect()) } -fn encode_access_description_der(ad: &AccessDescription) -> Result, CcrBuildError> { - let oid = encode_oid_from_string(&ad.access_method_oid)?; - let uri = encode_tlv(0x86, ad.access_location.as_bytes().to_vec()); - Ok(encode_sequence(&[oid, uri])) -} - -fn encode_oid_from_string(oid: &str) -> Result, CcrBuildError> { - let arcs = oid - .split('.') - .map(|part| { - part.parse::() - .map_err(|_| CcrBuildError::UnsupportedAccessMethodOid(oid.to_string())) - }) - .collect::, _>>()?; - if arcs.len() < 2 { - return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string())); - } - if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) { - return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string())); - } - let mut body = Vec::new(); - body.push((arcs[0] * 40 + arcs[1]) as u8); - for arc in &arcs[2..] { - encode_base128(*arc, &mut body); - } - Ok(encode_tlv(0x06, body)) -} - -fn encode_base128(mut value: u64, out: &mut Vec) { - let mut tmp = vec![(value & 0x7F) as u8]; - value >>= 7; - while value > 0 { - tmp.push(((value & 0x7F) as u8) | 0x80); - value >>= 7; - } - tmp.reverse(); - out.extend_from_slice(&tmp); -} - pub fn build_router_key_state( router_certs: &[BgpsecRouterCertificate], ) -> Result { @@ -650,17 +622,33 @@ mod tests { } } - fn sample_manifest_vcir( - manifest_uri: &str, - manifest_der: &[u8], - child_ski_hex: &str, - ) -> ValidatedCaInstanceResult { + fn sample_manifest_vcir(manifest_der: &[u8], child_ski_hex: &str) -> ValidatedCaInstanceResult { let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture"); + let manifest_uri = match manifest.signed_object.signed_data.certificates[0] + .resource_cert + .tbs + .extensions + .subject_info_access + .as_ref() + .expect("manifest sia") + { + SubjectInfoAccess::Ee(ee_sia) => ee_sia + .access_descriptions + .iter() + .find(|ad| { + ad.access_method_oid == crate::data_model::oid::OID_AD_SIGNED_OBJECT + && ad.access_location.starts_with("rsync://") + }) + .expect("manifest rsync signedObject") + .access_location + .clone(), + SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), + }; let hash = hex::encode(sha2::Sha256::digest(manifest_der)); let now = manifest.manifest.this_update; let next = manifest.manifest.next_update; let projection = crate::storage::VcirCcrManifestProjection { - manifest_rsync_uri: manifest_uri.to_string(), + manifest_rsync_uri: manifest_uri.clone(), manifest_sha256: sha2::Sha256::digest(manifest_der).to_vec(), manifest_size: manifest_der.len() as u64, manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] @@ -680,18 +668,19 @@ mod tests { .as_ref() .expect("manifest sia") { - SubjectInfoAccess::Ee(ee_sia) => ee_sia - .access_descriptions - .iter() - .map(encode_access_description_der) - .collect::, _>>() - .expect("encode access descriptions"), + SubjectInfoAccess::Ee(ee_sia) => vec![ + select_manifest_signed_object_location( + &manifest_uri, + &ee_sia.access_descriptions, + ) + .expect("select manifest signedObject"), + ], SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), }, subordinate_skis: vec![hex::decode(child_ski_hex).expect("decode child ski")], }; crate::storage::ValidatedCaInstanceResult { - manifest_rsync_uri: manifest_uri.to_string(), + manifest_rsync_uri: manifest_uri.clone(), parent_manifest_rsync_uri: None, tal_id: "test-tal".to_string(), ca_subject_name: "CN=test".to_string(), @@ -700,7 +689,7 @@ mod tests { last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime( now, ), - current_manifest_rsync_uri: manifest_uri.to_string(), + current_manifest_rsync_uri: manifest_uri.clone(), current_crl_rsync_uri: format!("{manifest_uri}.crl"), validated_manifest_meta: crate::storage::ValidatedManifestMeta { validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), @@ -736,7 +725,7 @@ mod tests { related_artifacts: vec![crate::storage::VcirRelatedArtifact { artifact_role: crate::storage::VcirArtifactRole::Manifest, artifact_kind: crate::storage::VcirArtifactKind::Mft, - uri: Some(manifest_uri.to_string()), + uri: Some(manifest_uri.clone()), sha256: hash, object_type: Some("mft".to_string()), validation_status: crate::storage::VcirArtifactValidationStatus::Accepted, @@ -766,10 +755,8 @@ mod tests { "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 vcir_a = sample_manifest_vcir(&manifest_a, &"33".repeat(20)); + let vcir_b = sample_manifest_vcir(&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)] { diff --git a/src/ccr/decode.rs b/src/ccr/decode.rs index 76eae76..e999add 100644 --- a/src/ccr/decode.rs +++ b/src/ccr/decode.rs @@ -471,3 +471,71 @@ fn decode_big_unsigned(bytes: &[u8]) -> Result { }; Ok(BigUnsigned { bytes_be }) } + +#[cfg(all(test, feature = "full"))] +mod tests { + use super::*; + use crate::ccr::encode::{encode_content_info, encode_manifest_state_payload_der}; + use crate::ccr::hash::compute_state_hash; + use crate::ccr::manifest_location::encode_access_description_der; + use crate::ccr::model::{ + CcrContentInfo, CcrDigestAlgorithm, ManifestState, RpkiCanonicalCacheRepresentation, + }; + use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT}; + use crate::data_model::rc::AccessDescription; + + #[test] + fn generic_decoder_preserves_multiple_manifest_locations() { + let signed_object = encode_access_description_der(&AccessDescription { + access_method_oid: OID_AD_SIGNED_OBJECT.to_string(), + access_location: "rsync://example.test/repository/current.mft".to_string(), + }) + .expect("encode signedObject"); + let rpki_notify = encode_access_description_der(&AccessDescription { + access_method_oid: OID_AD_RPKI_NOTIFY.to_string(), + access_location: "https://rrdp.example.test/notification.xml".to_string(), + }) + .expect("encode rpkiNotify"); + let manifest = ManifestInstance { + hash: vec![0x11; 32], + size: 1024, + aki: vec![0x22; 20], + manifest_number: BigUnsigned { bytes_be: vec![1] }, + this_update: time::OffsetDateTime::parse( + "2026-07-20T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("parse time"), + locations: vec![signed_object, rpki_notify], + subordinates: Vec::new(), + }; + let manifest_payload = encode_manifest_state_payload_der(&[manifest.clone()]) + .expect("encode manifest payload"); + let content = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { + version: 0, + hash_alg: CcrDigestAlgorithm::Sha256, + produced_at: time::OffsetDateTime::parse( + "2026-07-20T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("parse time"), + mfts: Some(ManifestState { + mis: vec![manifest], + most_recent_update: time::OffsetDateTime::parse( + "2026-07-20T00:00:00Z", + &time::format_description::well_known::Rfc3339, + ) + .expect("parse time"), + hash: compute_state_hash(&manifest_payload), + }), + vrps: None, + vaps: None, + tas: None, + rks: None, + }); + + let encoded = encode_content_info(&content).expect("encode CCR"); + let decoded = decode_content_info(&encoded).expect("decode CCR"); + assert_eq!(decoded.content.mfts.unwrap().mis[0].locations.len(), 2); + } +} diff --git a/src/ccr/export.rs b/src/ccr/export.rs index 6593cec..44bee7a 100644 --- a/src/ccr/export.rs +++ b/src/ccr/export.rs @@ -124,6 +124,8 @@ mod tests { use super::*; use crate::ccr::decode::decode_content_info; use crate::data_model::manifest::ManifestObject; + use crate::data_model::oid::OID_AD_SIGNED_OBJECT; + use crate::data_model::rc::SubjectInfoAccess; use crate::data_model::roa::{IpPrefix, RoaAfi}; use crate::data_model::ta::TrustAnchor; use crate::data_model::tal::Tal; @@ -149,15 +151,34 @@ mod tests { 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 manifest_uri = match manifest.signed_object.signed_data.certificates[0] + .resource_cert + .tbs + .extensions + .subject_info_access + .as_ref() + .expect("manifest sia") + { + SubjectInfoAccess::Ee(ee_sia) => ee_sia + .access_descriptions + .iter() + .find(|ad| { + ad.access_method_oid == OID_AD_SIGNED_OBJECT + && ad.access_location.starts_with("rsync://") + }) + .expect("manifest rsync signedObject") + .access_location + .clone(), + SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"), + }; 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.origin_uris.push(manifest_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"); let projection = VcirCcrManifestProjection { - manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + manifest_rsync_uri: manifest_uri.clone(), manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(), manifest_size: manifest_der.len() as u64, manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] @@ -169,14 +190,27 @@ mod tests { .expect("manifest aki"), manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(), manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update), - manifest_sia_locations_der: vec![vec![ - 0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, - b'r', b's', b'y', b'n', b'c', - ]], + manifest_sia_locations_der: match manifest.signed_object.signed_data.certificates[0] + .resource_cert + .tbs + .extensions + .subject_info_access + .as_ref() + .expect("manifest sia") + { + SubjectInfoAccess::Ee(ee_sia) => vec![ + crate::ccr::manifest_location::select_manifest_signed_object_location( + &manifest_uri, + &ee_sia.access_descriptions, + ) + .expect("select manifest signedObject"), + ], + SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"), + }, subordinate_skis: vec![vec![0x33; 20]], }; ValidatedCaInstanceResult { - manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + manifest_rsync_uri: manifest_uri.clone(), parent_manifest_rsync_uri: None, tal_id: "apnic".to_string(), ca_subject_name: "CN=test".to_string(), @@ -185,7 +219,7 @@ mod tests { 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_manifest_rsync_uri: manifest_uri.clone(), 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(), @@ -229,7 +263,7 @@ mod tests { related_artifacts: vec![VcirRelatedArtifact { artifact_role: VcirArtifactRole::Manifest, artifact_kind: VcirArtifactKind::Mft, - uri: Some("rsync://example.test/repo/current.mft".to_string()), + uri: Some(manifest_uri), sha256: hash, object_type: Some("mft".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, diff --git a/src/ccr/manifest_location.rs b/src/ccr/manifest_location.rs new file mode 100644 index 0000000..217a0cb --- /dev/null +++ b/src/ccr/manifest_location.rs @@ -0,0 +1,424 @@ +use crate::data_model::common::DerReader; +use crate::data_model::oid::OID_AD_SIGNED_OBJECT; +use crate::data_model::rc::AccessDescription; + +pub(crate) fn select_manifest_signed_object_location( + manifest_rsync_uri: &str, + access_descriptions: &[AccessDescription], +) -> Result, String> { + let matching = access_descriptions + .iter() + .filter(|access_description| { + access_description.access_method_oid == OID_AD_SIGNED_OBJECT + && access_description.access_location == manifest_rsync_uri + }) + .collect::>(); + let access_description = expect_single_matching_location( + manifest_rsync_uri, + matching.len(), + "parsed Manifest EE SIA", + )?; + encode_access_description_der(matching[access_description]) +} + +pub(crate) fn select_manifest_signed_object_location_from_der( + manifest_rsync_uri: &str, + locations_der: &[Vec], +) -> Result, String> { + let matching = locations_der + .iter() + .filter_map( + |location_der| match decode_access_description_der(location_der) { + Ok(access_description) + if access_description.access_method_oid == OID_AD_SIGNED_OBJECT + && access_description.access_location == manifest_rsync_uri => + { + Some(Ok(location_der.clone())) + } + Ok(_) => None, + Err(detail) => Some(Err(detail)), + }, + ) + .collect::, _>>()?; + let index = expect_single_matching_location( + manifest_rsync_uri, + matching.len(), + "persisted VCIR CCR projection", + )?; + Ok(matching[index].clone()) +} + +pub(crate) fn encode_access_description_der( + access_description: &AccessDescription, +) -> Result, String> { + let oid = encode_oid_der(&access_description.access_method_oid)?; + let uri = encode_tlv(0x86, access_description.access_location.as_bytes().to_vec()); + Ok(encode_sequence(&[oid, uri])) +} + +fn expect_single_matching_location( + manifest_rsync_uri: &str, + matching_count: usize, + source: &str, +) -> Result { + if matching_count == 1 { + return Ok(0); + } + Err(format!( + "{source} contains {matching_count} id-ad-signedObject locations matching manifest URI {manifest_rsync_uri}; expected exactly one" + )) +} + +fn decode_access_description_der(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut sequence = top.take_sequence()?; + if !top.is_empty() { + return Err("trailing bytes after AccessDescription".to_string()); + } + let access_method_oid = decode_oid_der(sequence.take_tag(0x06)?)?; + let access_location = std::str::from_utf8(sequence.take_tag(0x86)?) + .map_err(|error| format!("AccessDescription URI is not UTF-8: {error}"))? + .to_string(); + if !sequence.is_empty() { + return Err("trailing fields in AccessDescription".to_string()); + } + Ok(AccessDescription { + access_method_oid, + access_location, + }) +} + +fn decode_oid_der(value: &[u8]) -> Result { + let mut offset = 0usize; + let first = decode_base128(value, &mut offset)?; + let (first_arc, second_arc) = match first { + 0..=39 => (0, first), + 40..=79 => (1, first - 40), + value => (2, value - 80), + }; + let mut arcs = vec![first_arc, second_arc]; + while offset < value.len() { + arcs.push(decode_base128(value, &mut offset)?); + } + Ok(arcs + .into_iter() + .map(|arc| arc.to_string()) + .collect::>() + .join(".")) +} + +fn decode_base128(value: &[u8], offset: &mut usize) -> Result { + let first = *value + .get(*offset) + .ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?; + if first == 0x80 { + return Err("non-minimal OBJECT IDENTIFIER base-128 encoding".to_string()); + } + let mut out = 0u64; + loop { + let byte = *value + .get(*offset) + .ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?; + *offset += 1; + out = out + .checked_shl(7) + .ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())? + .checked_add((byte & 0x7f) as u64) + .ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())?; + if byte & 0x80 == 0 { + return Ok(out); + } + } +} + +fn encode_oid_der(oid: &str) -> Result, String> { + let arcs = oid + .split('.') + .map(|part| { + part.parse::() + .map_err(|_| format!("unsupported accessMethod OID: {oid}")) + }) + .collect::, _>>()?; + if arcs.len() < 2 || arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) { + return Err(format!("unsupported accessMethod OID: {oid}")); + } + let mut body = Vec::new(); + encode_base128( + arcs[0] + .checked_mul(40) + .and_then(|value| value.checked_add(arcs[1])) + .ok_or_else(|| format!("unsupported accessMethod OID: {oid}"))?, + &mut body, + ); + for arc in &arcs[2..] { + encode_base128(*arc, &mut body); + } + Ok(encode_tlv(0x06, body)) +} + +fn encode_base128(mut value: u64, out: &mut Vec) { + let mut encoded = vec![(value & 0x7f) as u8]; + value >>= 7; + while value > 0 { + encoded.push(((value & 0x7f) as u8) | 0x80); + value >>= 7; + } + encoded.reverse(); + out.extend_from_slice(&encoded); +} + +fn encode_sequence(elements: &[Vec]) -> Vec { + let total_len = elements.iter().map(Vec::len).sum(); + let mut value = Vec::with_capacity(total_len); + for element in elements { + value.extend_from_slice(element); + } + encode_tlv(0x30, value) +} + +fn encode_tlv(tag: u8, value: Vec) -> Vec { + let mut out = Vec::with_capacity(1 + 9 + value.len()); + out.push(tag); + encode_length(value.len(), &mut out); + out.extend_from_slice(&value); + out +} + +fn encode_length(len: usize, out: &mut Vec) { + if len < 0x80 { + out.push(len as u8); + return; + } + let mut bytes = Vec::new(); + let mut value = len; + while value > 0 { + bytes.push((value & 0xff) as u8); + value >>= 8; + } + bytes.reverse(); + out.push(0x80 | bytes.len() as u8); + out.extend_from_slice(&bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_model::oid::OID_AD_RPKI_NOTIFY; + + const MANIFEST_URI: &str = "rsync://example.test/repo/manifest.mft"; + + fn access_description(access_method_oid: &str, access_location: &str) -> AccessDescription { + AccessDescription { + access_method_oid: access_method_oid.to_string(), + access_location: access_location.to_string(), + } + } + + #[test] + fn selects_matching_signed_object_and_excludes_rpki_notify() { + let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI); + let selected = select_manifest_signed_object_location( + MANIFEST_URI, + &[ + signed_object.clone(), + access_description( + OID_AD_RPKI_NOTIFY, + "https://rrdp.example.test/notification.xml", + ), + ], + ) + .expect("select signed object"); + + assert_eq!( + selected, + encode_access_description_der(&signed_object).unwrap() + ); + } + + #[test] + fn selects_only_the_signed_object_matching_manifest_uri() { + let expected = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI); + let selected = select_manifest_signed_object_location( + MANIFEST_URI, + &[ + access_description( + OID_AD_SIGNED_OBJECT, + "https://backup.example.test/manifest.mft", + ), + expected.clone(), + ], + ) + .expect("select matching signed object"); + + assert_eq!(selected, encode_access_description_der(&expected).unwrap()); + } + + #[test] + fn rejects_missing_or_duplicate_matching_signed_object() { + let missing = select_manifest_signed_object_location( + MANIFEST_URI, + &[access_description( + OID_AD_RPKI_NOTIFY, + "https://rrdp.example.test/notification.xml", + )], + ) + .expect_err("missing signed object must fail"); + assert!(missing.contains("contains 0"), "{missing}"); + + let duplicate = select_manifest_signed_object_location( + MANIFEST_URI, + &[ + access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI), + access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI), + ], + ) + .expect_err("duplicate signed object must fail"); + assert!(duplicate.contains("contains 2"), "{duplicate}"); + } + + #[test] + fn selects_matching_signed_object_from_historical_projection() { + let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI); + let signed_object_der = encode_access_description_der(&signed_object).unwrap(); + let notify_der = encode_access_description_der(&access_description( + OID_AD_RPKI_NOTIFY, + "https://rrdp.example.test/notification.xml", + )) + .unwrap(); + + let selected = select_manifest_signed_object_location_from_der( + MANIFEST_URI, + &[notify_der, signed_object_der.clone()], + ) + .expect("select historical signed object"); + assert_eq!(selected, signed_object_der); + } + + #[test] + fn rejects_malformed_historical_projection() { + let error = select_manifest_signed_object_location_from_der( + MANIFEST_URI, + &[vec![0x30, 0x01, 0x06]], + ) + .expect_err("malformed historical projection must fail"); + assert!(error.contains("truncated DER"), "{error}"); + } + + #[test] + fn access_description_der_codec_covers_long_and_invalid_forms() { + let long = access_description( + "2.999.200.1", + &format!("rsync://example.test/repo/{}", "x".repeat(160)), + ); + let encoded = encode_access_description_der(&long).expect("encode long access description"); + assert_eq!( + decode_access_description_der(&encoded).expect("decode long access description"), + long + ); + + let mut trailing = encoded.clone(); + trailing.push(0); + assert!( + decode_access_description_der(&trailing) + .expect_err("trailing bytes must fail") + .contains("trailing bytes") + ); + + let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID"); + let uri = encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()); + assert!( + decode_access_description_der(&encode_sequence(&[ + oid.clone(), + uri.clone(), + encode_tlv(0x05, Vec::new()), + ])) + .expect_err("trailing field must fail") + .contains("trailing fields") + ); + assert!( + decode_access_description_der(&encode_sequence(&[oid, encode_tlv(0x86, vec![0xff]),])) + .expect_err("non-UTF8 URI must fail") + .contains("not UTF-8") + ); + assert!( + decode_access_description_der(&encode_sequence(&[ + encode_tlv(0x06, vec![0x80, 0x00]), + uri.clone(), + ])) + .expect_err("non-minimal OID must fail") + .contains("non-minimal") + ); + assert!( + decode_access_description_der(&encode_sequence(&[encode_tlv(0x06, vec![0x81]), uri,])) + .expect_err("truncated OID must fail") + .contains("truncated OBJECT IDENTIFIER") + ); + assert!( + encode_access_description_der(&access_description("3.1", MANIFEST_URI)) + .expect_err("invalid OID must fail") + .contains("unsupported accessMethod OID") + ); + } + + #[test] + fn access_description_der_codec_reports_malformed_selector_inputs() { + let malformed_sequence = decode_access_description_der(&[0x31, 0x00]) + .expect_err("non-sequence AccessDescription must fail"); + assert!(!malformed_sequence.is_empty()); + + let missing_method = decode_access_description_der(&encode_sequence(&[encode_tlv( + 0x86, + MANIFEST_URI.as_bytes().to_vec(), + )])) + .expect_err("missing accessMethod must fail"); + assert!(!missing_method.is_empty()); + + let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID"); + let missing_location = decode_access_description_der(&encode_sequence(&[oid.clone()])) + .expect_err("missing accessLocation must fail"); + assert!(!missing_location.is_empty()); + + let wrong_location_tag = decode_access_description_der(&encode_sequence(&[ + oid.clone(), + encode_tlv(0x04, MANIFEST_URI.as_bytes().to_vec()), + ])) + .expect_err("wrong accessLocation tag must fail"); + assert!(!wrong_location_tag.is_empty()); + + let empty_oid = decode_access_description_der(&encode_sequence(&[ + encode_tlv(0x06, Vec::new()), + encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()), + ])) + .expect_err("empty OID must fail"); + assert!(!empty_oid.is_empty()); + + let first_arc_zero = decode_access_description_der(&encode_sequence(&[ + encode_tlv(0x06, vec![0x01, 0x02]), + encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()), + ])) + .expect("decode first OID arc zero"); + assert_eq!(first_arc_zero.access_method_oid, "0.1.2"); + assert_eq!(first_arc_zero.access_location, MANIFEST_URI); + + let non_numeric = + encode_access_description_der(&access_description("no.such.oid", MANIFEST_URI)) + .expect_err("non-numeric OID must fail"); + assert!(!non_numeric.is_empty()); + + let too_short = encode_access_description_der(&access_description("1", MANIFEST_URI)) + .expect_err("OID without second arc must fail"); + assert!(!too_short.is_empty()); + + let invalid_second_arc = + encode_access_description_der(&access_description("1.40", MANIFEST_URI)) + .expect_err("invalid second OID arc must fail"); + assert!(!invalid_second_arc.is_empty()); + + let overflowing_first_subidentifier = encode_access_description_der(&access_description( + "2.18446744073709551615", + MANIFEST_URI, + )) + .expect_err("overflowing first OID subidentifier must fail"); + assert!(!overflowing_first_subidentifier.is_empty()); + } +} diff --git a/src/ccr/mod.rs b/src/ccr/mod.rs index 9ad537a..54c82ce 100644 --- a/src/ccr/mod.rs +++ b/src/ccr/mod.rs @@ -10,6 +10,8 @@ pub mod encode; #[cfg(feature = "full")] pub mod export; pub mod hash; +#[cfg(feature = "full")] +pub(crate) mod manifest_location; pub mod model; pub mod state_digest; #[cfg(feature = "full")] diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 471d06c..831404a 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -1,4 +1,6 @@ use super::*; +use crate::data_model::oid::OID_AD_SIGNED_OBJECT; +use crate::data_model::rc::AccessDescription; use crate::memory_telemetry::{ MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot, }; @@ -1722,8 +1724,9 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator { ) .expect("discover root"); let mut accumulator = CcrAccumulator::new(vec![discovery.trust_anchor.clone()]); + let manifest_uri = "rsync://example.test/repo/current.mft".to_string(); let projection = crate::storage::VcirCcrManifestProjection { - manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), + manifest_rsync_uri: manifest_uri.clone(), manifest_sha256: vec![0x44; 32], manifest_size: 2048, manifest_ee_aki: vec![0x55; 20], @@ -1731,10 +1734,13 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator { manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime( time::OffsetDateTime::now_utc(), ), - manifest_sia_locations_der: vec![vec![ - 0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, - b'r', b's', b'y', b'n', b'c', - ]], + manifest_sia_locations_der: vec![ + crate::ccr::manifest_location::encode_access_description_der(&AccessDescription { + access_method_oid: OID_AD_SIGNED_OBJECT.to_string(), + access_location: manifest_uri, + }) + .expect("encode signedObject"), + ], subordinate_skis: vec![vec![0x33; 20]], }; accumulator diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index d8821a0..b13ee09 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -1,5 +1,3 @@ -mod vcir_der; - use crate::analysis::timing::TimingHandle; use crate::audit::{ AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit, @@ -69,7 +67,7 @@ use base64::Engine as _; use x509_parser::prelude::FromDer; use x509_parser::x509::SubjectPublicKeyInfo; -use vcir_der::encode_access_description_der_for_vcir_ccr_projection; +use crate::ccr::manifest_location::select_manifest_signed_object_location; const PUBLICATION_POINT_CACHE_CHILD_RESTORE_PARALLEL_MIN_CHILDREN: usize = 256; const PUBLICATION_POINT_CACHE_CHILD_RESTORE_MAX_WORKERS: usize = 16; @@ -5677,11 +5675,10 @@ fn build_vcir_ccr_manifest_projection_from_fresh( .as_ref() .ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())? { - SubjectInfoAccess::Ee(ee_sia) => ee_sia - .access_descriptions - .iter() - .map(encode_access_description_der_for_vcir_ccr_projection) - .collect::, _>>()?, + SubjectInfoAccess::Ee(ee_sia) => vec![select_manifest_signed_object_location( + &ca.manifest_rsync_uri, + &ee_sia.access_descriptions, + )?], SubjectInfoAccess::Ca(_) => { return Err( "manifest EE certificate Subject Information Access has CA variant".to_string(), diff --git a/src/validation/tree_runner/tests.rs b/src/validation/tree_runner/tests.rs index 8efc568..479c5f7 100644 --- a/src/validation/tree_runner/tests.rs +++ b/src/validation/tree_runner/tests.rs @@ -1,5 +1,8 @@ use super::*; -use crate::data_model::rc::{AsIdOrRange, AsIdentifierChoice, AsResourceSet, ResourceCertificate}; +use crate::data_model::oid::OID_AD_SIGNED_OBJECT; +use crate::data_model::rc::{ + AccessDescription, AsIdOrRange, AsIdentifierChoice, AsResourceSet, ResourceCertificate, +}; use crate::data_model::roa::RoaAfi; use crate::fetch::rsync::LocalDirRsyncFetcher; use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher}; @@ -603,10 +606,13 @@ fn sample_vcir_for_projection( manifest_ee_aki: vec![0x11; 20], manifest_number_be: vec![1], manifest_this_update: PackTime::from_utc_offset_datetime(now), - manifest_sia_locations_der: vec![vec![ - 0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, - b'r', b's', b'y', b'n', b'c', - ]], + manifest_sia_locations_der: vec![ + crate::ccr::manifest_location::encode_access_description_der(&AccessDescription { + access_method_oid: OID_AD_SIGNED_OBJECT.to_string(), + access_location: manifest_uri.clone(), + }) + .expect("encode signedObject"), + ], subordinate_skis: vec![vec![0x33; 20]], }; ValidatedCaInstanceResult { @@ -1252,12 +1258,13 @@ fn build_vcir_ccr_manifest_projection_from_fresh_real_snapshot_matches_manifest_ .as_ref() .expect("manifest sia") { - SubjectInfoAccess::Ee(ee_sia) => ee_sia - .access_descriptions - .iter() - .map(encode_access_description_der_for_vcir_ccr_projection) - .collect::, _>>() - .expect("encode locations"), + SubjectInfoAccess::Ee(ee_sia) => vec![ + crate::ccr::manifest_location::select_manifest_signed_object_location( + &pack.manifest_rsync_uri, + &ee_sia.access_descriptions, + ) + .expect("select manifest signedObject"), + ], SubjectInfoAccess::Ca(_) => panic!("manifest ee SIA should not be CA variant"), }; diff --git a/src/validation/tree_runner/vcir_der.rs b/src/validation/tree_runner/vcir_der.rs deleted file mode 100644 index 3db7d69..0000000 --- a/src/validation/tree_runner/vcir_der.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::data_model::rc::AccessDescription; - -pub(super) fn encode_access_description_der_for_vcir_ccr_projection( - access_description: &AccessDescription, -) -> Result, String> { - let oid = encode_oid_der_for_vcir_ccr_projection(&access_description.access_method_oid)?; - let uri = encode_tlv_for_vcir_ccr_projection( - 0x86, - access_description.access_location.as_bytes().to_vec(), - ); - Ok(encode_sequence_for_vcir_ccr_projection(&[oid, uri])) -} - -fn encode_oid_der_for_vcir_ccr_projection(oid: &str) -> Result, String> { - let arcs = oid - .split('.') - .map(|part| { - part.parse::() - .map_err(|_| format!("unsupported accessMethod OID: {oid}")) - }) - .collect::, _>>()?; - if arcs.len() < 2 { - return Err(format!("unsupported accessMethod OID: {oid}")); - } - if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) { - return Err(format!("unsupported accessMethod OID: {oid}")); - } - let mut body = Vec::new(); - body.push((arcs[0] * 40 + arcs[1]) as u8); - for arc in &arcs[2..] { - encode_base128_for_vcir_ccr_projection(*arc, &mut body); - } - Ok(encode_tlv_for_vcir_ccr_projection(0x06, body)) -} - -fn encode_base128_for_vcir_ccr_projection(mut value: u64, out: &mut Vec) { - let mut tmp = vec![(value & 0x7F) as u8]; - value >>= 7; - while value > 0 { - tmp.push(((value & 0x7F) as u8) | 0x80); - value >>= 7; - } - tmp.reverse(); - out.extend_from_slice(&tmp); -} - -fn encode_sequence_for_vcir_ccr_projection(elements: &[Vec]) -> Vec { - let total_len: usize = elements.iter().map(Vec::len).sum(); - let mut buf = Vec::with_capacity(total_len); - for element in elements { - buf.extend_from_slice(element); - } - encode_tlv_for_vcir_ccr_projection(0x30, buf) -} - -fn encode_tlv_for_vcir_ccr_projection(tag: u8, value: Vec) -> Vec { - let mut out = Vec::with_capacity(1 + 9 + value.len()); - out.push(tag); - encode_length_for_vcir_ccr_projection(value.len(), &mut out); - out.extend_from_slice(&value); - out -} - -fn encode_length_for_vcir_ccr_projection(len: usize, out: &mut Vec) { - if len < 0x80 { - out.push(len as u8); - return; - } - let mut bytes = Vec::new(); - let mut value = len; - while value > 0 { - bytes.push((value & 0xFF) as u8); - value >>= 8; - } - bytes.reverse(); - out.push(0x80 | (bytes.len() as u8)); - out.extend_from_slice(&bytes); -} diff --git a/tests/test_ccr_manifest_location_interop.rs b/tests/test_ccr_manifest_location_interop.rs new file mode 100644 index 0000000..d58ccef --- /dev/null +++ b/tests/test_ccr_manifest_location_interop.rs @@ -0,0 +1,139 @@ +use rpki::ccr::{CcrAccumulator, build_roa_payload_state}; +use rpki::data_model::roa::{IpPrefix, RoaAfi}; +use rpki::storage::{PackTime, VcirCcrManifestProjection}; +use rpki::validation::objects::Vrp; + +const MANIFEST_URI: &str = "rsync://example.test/repo/current.mft"; +const OID_AD_SIGNED_OBJECT: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x0b]; +const OID_AD_RPKI_NOTIFY: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x0d]; + +fn der_length(len: usize) -> Vec { + if len < 0x80 { + return vec![len as u8]; + } + let mut bytes = len.to_be_bytes().to_vec(); + let first = bytes + .iter() + .position(|byte| *byte != 0) + .expect("non-zero length"); + bytes.drain(..first); + let mut out = vec![0x80 | bytes.len() as u8]; + out.extend_from_slice(&bytes); + out +} + +fn der_tlv(tag: u8, value: &[u8]) -> Vec { + let mut out = vec![tag]; + out.extend(der_length(value.len())); + out.extend_from_slice(value); + out +} + +fn access_description_der(access_method_oid: &[u8], uri: &str) -> Vec { + let oid = der_tlv(0x06, access_method_oid); + let location = der_tlv(0x86, uri.as_bytes()); + let mut body = oid; + body.extend(location); + der_tlv(0x30, &body) +} + +fn projection(locations: Vec>) -> VcirCcrManifestProjection { + VcirCcrManifestProjection { + manifest_rsync_uri: MANIFEST_URI.to_string(), + manifest_sha256: vec![0x11; 32], + manifest_size: 1000, + manifest_ee_aki: vec![0x22; 20], + manifest_number_be: vec![1], + manifest_this_update: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()), + manifest_sia_locations_der: locations, + subordinate_skis: vec![vec![0x33; 20]], + } +} + +#[test] +fn historical_projection_normalizes_locations_and_accounts_memory() { + let signed_object = access_description_der(OID_AD_SIGNED_OBJECT, MANIFEST_URI); + let notify = access_description_der( + OID_AD_RPKI_NOTIFY, + "https://rrdp.example.test/notification.xml", + ); + let projection = projection(vec![notify, signed_object]); + + let mut accumulator = CcrAccumulator::new(Vec::new()); + accumulator + .append_manifest_projection(&projection) + .expect("append normalized historical projection"); + accumulator + .append_manifest_projection(&projection) + .expect("duplicate matching projection is idempotent"); + + assert_eq!(accumulator.manifest_count(), 1); + let stats = accumulator.memory_stats(); + assert_eq!(stats.manifest_count, 1); + assert_eq!(stats.locations_der_count, 1); + assert_eq!(stats.subordinate_ski_count, 1); + assert!(stats.estimated_heap_bytes > 0); + assert!(stats.btree_entry_shallow_bytes > 0); + + let mut conflicting = projection; + conflicting.subordinate_skis.push(vec![0x44; 20]); + let error = accumulator + .append_manifest_projection(&conflicting) + .expect_err("same hash with different projection must fail"); + assert!(error.contains("duplicate manifest hash"), "{error}"); +} + +#[test] +fn historical_projection_rejects_invalid_location_sets() { + let mut accumulator = CcrAccumulator::new(Vec::new()); + + let empty_error = accumulator + .append_manifest_projection(&projection(Vec::new())) + .expect_err("empty location set must fail"); + assert!(empty_error.contains("contains 0"), "{empty_error}"); + + let signed_object = access_description_der(OID_AD_SIGNED_OBJECT, MANIFEST_URI); + let duplicate_error = accumulator + .append_manifest_projection(&projection(vec![signed_object.clone(), signed_object])) + .expect_err("duplicate matching locations must fail"); + assert!(duplicate_error.contains("contains 2"), "{duplicate_error}"); + + let malformed_error = accumulator + .append_manifest_projection(&projection(vec![vec![0x30, 0x01, 0x06]])) + .expect_err("malformed location DER must fail"); + assert!( + malformed_error.contains("truncated DER"), + "{malformed_error}" + ); +} + +#[test] +fn roa_payload_builder_encodes_large_non_byte_aligned_family() { + let mut vrps = Vec::new(); + for octet in 0..=255 { + vrps.push(Vrp { + asn: 64_496, + prefix: IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 24, + addr: [10, octet, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + max_length: 24, + }); + } + vrps.push(Vrp { + asn: 64_496, + prefix: IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 13, + addr: [10, 0x1f, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + max_length: 24, + }); + + let state = build_roa_payload_state(&vrps).expect("build ROA payload state"); + assert_eq!(state.rps.len(), 1); + assert_eq!(state.rps[0].as_id, 64_496); + assert_eq!(state.rps[0].ip_addr_blocks.len(), 1); + assert!(state.rps[0].ip_addr_blocks[0].len() > 128); +}