20261015_4 修复Manifest编号与改名抗重放校验
Some checks failed
ci / rust (push) Failing after 17s
ci / docker (push) Failing after 1m12s
ci / audit (push) Failing after 8s

This commit is contained in:
2026-10-16 11:16:20 +08:00
parent 385af349ae
commit 5f46837b61
34 changed files with 1346 additions and 149 deletions

View File

@@ -11,6 +11,8 @@ pub struct CaInstanceUris {
pub rsync_base_uri: String,
/// rsync URI for the manifest object (`.mft`).
pub manifest_rsync_uri: String,
/// All current id-ad-rpkiManifest locations, including alternative names.
pub manifest_sia_uris: Vec<String>,
/// Publication point rsync URI (RFC 9286 terminology).
pub publication_point_rsync_uri: String,
/// Optional RRDP notification URI (https).
@@ -78,6 +80,7 @@ pub fn ca_instance_uris_from_ca_certificate(
let mut ca_repo: Option<String> = None;
let mut manifest: Option<String> = None;
let mut manifest_sia_uris = Vec::new();
let mut notify: Option<String> = None;
for ad in access_descriptions {
@@ -93,6 +96,7 @@ pub fn ca_instance_uris_from_ca_certificate(
return Err(CaInstanceUrisError::RpkiManifestNotRsync(u.to_string()));
}
manifest.get_or_insert(u.to_string());
manifest_sia_uris.push(u.to_string());
} else if ad.access_method_oid == OID_AD_RPKI_NOTIFY {
let u = ad.access_location.as_str();
if !u.starts_with("https://") {
@@ -126,6 +130,7 @@ pub fn ca_instance_uris_from_ca_certificate(
Ok(CaInstanceUris {
rsync_base_uri: manifest_parent,
manifest_rsync_uri,
manifest_sia_uris,
publication_point_rsync_uri,
rrdp_notification_uri: notify,
})

View File

@@ -368,6 +368,10 @@ pub fn run_root_from_tal_url_once(
let run = run_publication_point_once(
store,
&<sha2::Sha256 as sha2::Digest>::digest(
&discovery.trust_anchor.tal.subject_public_key_info_der,
)
.into(),
policy,
discovery.ca_instance.rrdp_notification_uri.as_deref(),
&discovery.ca_instance.rsync_base_uri,

View File

@@ -4,7 +4,9 @@ use crate::model::manifest::{ManifestDecodeError, ManifestObject, ManifestValida
use crate::model::signed_object::SignedObjectVerifyError;
use crate::output::report::Warning;
use crate::repository::current_repo_index::CurrentRepoIndexHandle;
use crate::repository::storage::{PackFile, PackTime, RocksStore, StorageError};
use crate::repository::storage::{
CaIdentity, CaManifestHistory, PackFile, PackTime, RocksStore, StorageError,
};
use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast};
use crate::validation::policy::Policy;
use crate::validation::publication_point::PublicationPointSnapshot;
@@ -14,6 +16,7 @@ use x509_parser::prelude::FromDer;
include!("manifest/models_and_process.rs");
include!("manifest/helpers.rs");
include!("manifest/history.rs");
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,160 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestHistoryCandidate {
pub identity: CaIdentity,
pub manifest_sia_uris: Vec<String>,
pub history: CaManifestHistory,
}
fn manifest_filename(uri: &str) -> &str {
uri.split(['?', '#'])
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("")
}
fn manifest_filename_changed(old: &CaManifestHistory, current_sia: &[String]) -> bool {
!current_sia
.iter()
.any(|uri| manifest_filename(uri) == old.reference_filename)
}
/// Pure policy shared by staging and the locked commit. No state is reset
/// until the whole publication point has passed validation.
fn checked_manifest_history(
old: Option<&CaManifestHistory>,
candidate: &ManifestHistoryCandidate,
) -> Result<CaManifestHistory, ManifestFreshError> {
let mut next = candidate.history.clone();
if !candidate
.manifest_sia_uris
.iter()
.any(|uri| uri == &next.manifest_rsync_uri)
{
return Err(ManifestFreshError::IssuerCaLoadFailed {
detail: "selected manifest URI absent from verified CA SIA".into(),
});
}
next.validate_internal()?;
next.reference_filename = manifest_filename(&next.manifest_rsync_uri).into();
next.generation = 1;
if let Some(old) = old {
old.validate_internal()?;
next.generation =
old.generation
.checked_add(1)
.ok_or_else(|| StorageError::InvalidData {
entity: "ca_manifest",
detail: "history generation exhausted".into(),
})?;
let renamed = manifest_filename_changed(old, &candidate.manifest_sia_uris);
if !renamed {
next.reference_filename = old.reference_filename.clone();
if next.manifest_sha256 == old.manifest_sha256
&& next.manifest_number_be == old.manifest_number_be
&& next.manifest_this_update == old.manifest_this_update
{
// The same signed file can be revalidated, but never reset a
// reference name or bypass current validity / location checks.
next.validate_internal()?;
return Ok(next);
}
if cmp_minimal_be_unsigned(&next.manifest_number_be, &old.manifest_number_be)
!= Ordering::Greater
{
return Err(ManifestFreshError::ManifestNumberNotIncreasing {
old_hex: hex::encode_upper(&old.manifest_number_be),
new_hex: hex::encode_upper(&next.manifest_number_be),
});
}
}
if next
.manifest_this_update
.parse()
.expect("validated candidate time")
<= old
.manifest_this_update
.parse()
.expect("validated history time")
{
return Err(ManifestFreshError::ThisUpdateNotIncreasing {
old_rfc3339_utc: old.manifest_this_update.rfc3339_utc.clone(),
new_rfc3339_utc: next.manifest_this_update.rfc3339_utc.clone(),
});
}
}
next.validate_internal()?;
Ok(next)
}
pub(crate) fn commit_manifest_history(
store: &RocksStore,
candidate: &ManifestHistoryCandidate,
) -> Result<(CaManifestHistory, Option<Warning>), ManifestFreshError> {
let mut warning = None;
let history = store.update_ca_manifest_history(&candidate.identity, |old| {
let next = checked_manifest_history(old, candidate)?;
warning = old.and_then(|old| {
rename_warning_for_history(
old,
&candidate.identity,
&candidate.manifest_sia_uris,
&candidate.history.manifest_rsync_uri,
)
});
Ok::<_, ManifestFreshError>(next)
})?;
Ok((history, warning))
}
pub(crate) fn manifest_rename_warning(
store: &RocksStore,
identity: &CaIdentity,
current_sia: &[String],
selected_uri: &str,
) -> Result<Option<Warning>, ManifestFreshError> {
let Some(old) = store.get_ca_manifest_history(identity)? else {
return Ok(None);
};
Ok(rename_warning_for_history(
&old,
identity,
current_sia,
selected_uri,
))
}
fn rename_warning_for_history(
old: &CaManifestHistory,
identity: &CaIdentity,
current_sia: &[String],
selected_uri: &str,
) -> Option<Warning> {
if !manifest_filename_changed(old, current_sia) {
return None;
}
Some(Warning::new(format!(
"manifest filename changed: ca={}:{} old={} selected={} current_sia={:?} old_number={} old_this_update={} (RFC 9981 §2–3)",
hex::encode(identity.anchor_namespace), hex::encode(identity.spki_sha256), old.reference_filename,
selected_uri, current_sia, hex::encode_upper(&old.manifest_number_be), old.manifest_this_update.rfc3339_utc
)).with_category(crate::output::report::WarningCategory::SecurityPolicy).with_context(selected_uri))
}
fn validate_manifest_sia_location(
manifest: &ManifestObject,
actual_uri: &str,
) -> Result<(), ManifestFreshError> {
let ee = &manifest.signed_object.signed_data.certificates[0];
if ee
.sia_signed_object_uris
.iter()
.any(|uri| uri == actual_uri)
{
return Ok(());
}
Err(ManifestFreshError::ManifestSiaUriMismatch {
actual_uri: actual_uri.into(),
signed_object_uris: ee.sia_signed_object_uris.clone(),
})
}

View File

@@ -0,0 +1,325 @@
fn history_candidate(name: &str, number: Vec<u8>, seconds: i64) -> ManifestHistoryCandidate {
let now = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(seconds);
let uri = format!("rsync://example.net/repo/{name}");
ManifestHistoryCandidate {
identity: CaIdentity::from_spki([1; 32], b"verified CA SPKI"),
manifest_sia_uris: vec![uri.clone()],
history: CaManifestHistory {
reference_filename: name.into(),
manifest_rsync_uri: uri,
manifest_number_be: number,
manifest_this_update: PackTime::from_utc_offset_datetime(now),
manifest_sha256: sha2::Sha256::digest(seconds.to_be_bytes()).to_vec(),
updated_at_validation_time: PackTime::from_utc_offset_datetime(now),
generation: 1,
},
}
}
#[test]
fn filename_reset_keeps_time_guard_and_latest_history_across_a_b_a() {
let td = tempfile::tempdir().unwrap();
let store = RocksStore::open(td.path()).unwrap();
let first = history_candidate("a.mft", vec![0x7f; 20], 100);
let (h1, _) = commit_manifest_history(&store, &first).unwrap();
let second = history_candidate("b.mft", vec![0], 101);
assert!(
manifest_rename_warning(
&store,
&second.identity,
&second.manifest_sia_uris,
&second.history.manifest_rsync_uri
)
.unwrap()
.is_some()
);
let (h2, _) = commit_manifest_history(&store, &second).unwrap();
assert_eq!(h2.reference_filename, "b.mft");
assert_eq!(h2.generation, h1.generation + 1);
assert!(
manifest_rename_warning(
&store,
&second.identity,
&second.manifest_sia_uris,
&second.history.manifest_rsync_uri
)
.unwrap()
.is_none()
);
assert!(matches!(
commit_manifest_history(&store, &first),
Err(ManifestFreshError::ThisUpdateNotIncreasing { .. })
));
assert_eq!(
store.get_ca_manifest_history(&first.identity).unwrap(),
Some(h2.clone())
);
let third = history_candidate("a.mft", vec![0], 102);
assert_eq!(
commit_manifest_history(&store, &third)
.unwrap()
.0
.reference_filename,
"a.mft"
);
assert_eq!(
checked_manifest_history(Some(&h2), &history_candidate("b.mft", vec![0], 103))
.unwrap_err()
.warning_category(),
crate::output::report::WarningCategory::SecurityPolicy
);
}
#[test]
fn multi_sia_reordering_and_directory_move_do_not_reset_number() {
let first = history_candidate("a.mft", vec![10], 100).history;
let mut reordered = history_candidate("b.mft", vec![1], 101);
reordered
.manifest_sia_uris
.push("rsync://other.example/new/a.mft".into());
assert!(!manifest_filename_changed(
&first,
&reordered.manifest_sia_uris
));
assert!(matches!(
checked_manifest_history(Some(&first), &reordered),
Err(ManifestFreshError::ManifestNumberNotIncreasing { .. })
));
reordered.history.manifest_number_be = vec![11];
assert_eq!(
checked_manifest_history(Some(&first), &reordered)
.unwrap()
.reference_filename,
"a.mft"
);
let mut moved = history_candidate("a.mft", vec![1], 101);
moved.history.manifest_rsync_uri = "rsync://new.example/another/a.mft".into();
moved.manifest_sia_uris = vec![moved.history.manifest_rsync_uri.clone()];
assert!(checked_manifest_history(Some(&first), &moved).is_err());
moved.history.manifest_number_be = vec![11];
moved.history.manifest_this_update = first.manifest_this_update.clone();
assert!(matches!(
checked_manifest_history(Some(&first), &moved),
Err(ManifestFreshError::ThisUpdateNotIncreasing { .. })
));
moved.history.manifest_this_update = PackTime::from_utc_offset_datetime(
time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(101),
);
assert!(checked_manifest_history(Some(&first), &moved).is_ok());
}
#[test]
fn unchanged_bytes_never_bypass_filename_time_or_selected_sia() {
let old = history_candidate("a.mft", vec![10], 100).history;
let same = history_candidate("a.mft", vec![10], 100);
assert!(checked_manifest_history(Some(&old), &same).is_ok());
let mut renamed = same.clone();
renamed.history.manifest_rsync_uri = "rsync://example.net/repo/b.mft".into();
renamed.manifest_sia_uris = vec![renamed.history.manifest_rsync_uri.clone()];
assert!(matches!(
checked_manifest_history(Some(&old), &renamed),
Err(ManifestFreshError::ThisUpdateNotIncreasing { .. })
));
renamed.manifest_sia_uris.clear();
assert!(checked_manifest_history(Some(&old), &renamed).is_err());
let mut equal_changed = same;
equal_changed.history.manifest_sha256[0] ^= 1;
assert!(matches!(
checked_manifest_history(Some(&old), &equal_changed),
Err(ManifestFreshError::ManifestNumberNotIncreasing { .. })
));
}
#[test]
fn ca_history_isolates_keys_and_tal_namespaces_and_survives_restart() {
let td = tempfile::tempdir().unwrap();
let first = history_candidate("a.mft", vec![10], 100);
{
let store = RocksStore::open(td.path()).unwrap();
commit_manifest_history(&store, &first).unwrap();
}
let store = RocksStore::open(td.path()).unwrap();
let same_key = CaIdentity::from_spki([1; 32], b"verified CA SPKI");
assert!(store.get_ca_manifest_history(&same_key).unwrap().is_some());
let mut changed = history_candidate("a.mft", vec![0], 1);
changed.identity = CaIdentity::from_spki([1; 32], b"rolled CA key");
assert!(commit_manifest_history(&store, &changed).is_ok());
changed.identity = CaIdentity::from_spki([2; 32], b"verified CA SPKI");
assert!(commit_manifest_history(&store, &changed).is_ok());
assert_eq!(
store
.get_ca_manifest_history(&same_key)
.unwrap()
.unwrap()
.manifest_number_be,
vec![10]
);
}
#[test]
fn concurrent_candidates_recheck_latest_history_before_commit() {
let td = tempfile::tempdir().unwrap();
let store = std::sync::Arc::new(RocksStore::open(td.path()).unwrap());
let first = history_candidate("a.mft", vec![1], 100);
commit_manifest_history(&store, &first).unwrap();
let next = history_candidate("a.mft", vec![2], 101);
let latest = history_candidate("a.mft", vec![3], 102);
// Both candidates pass against generation 1 before either commits.
let baseline = store
.get_ca_manifest_history(&first.identity)
.unwrap()
.unwrap();
assert!(checked_manifest_history(Some(&baseline), &next).is_ok());
assert!(checked_manifest_history(Some(&baseline), &latest).is_ok());
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
std::thread::scope(|scope| {
let newer = scope.spawn(|| {
commit_manifest_history(&store, &latest).unwrap();
barrier.wait();
});
let older = scope.spawn(|| {
barrier.wait();
assert!(commit_manifest_history(&store, &next).is_err());
});
newer.join().unwrap();
older.join().unwrap();
});
let saved = store
.get_ca_manifest_history(&first.identity)
.unwrap()
.unwrap();
assert_eq!(saved.manifest_number_be, vec![3]);
assert_eq!(saved.generation, 2);
}
#[test]
fn signed_object_sia_requires_exact_location_and_accepts_any_matching_entry() {
let (mut manifest, _, uri, _, _) = load_manifest_fixture();
assert!(validate_manifest_sia_location(&manifest, &uri).is_ok());
let alternate = uri.replace("child.mft", "another.mft");
assert!(matches!(
validate_manifest_sia_location(&manifest, &alternate),
Err(ManifestFreshError::ManifestSiaUriMismatch { .. })
));
manifest.signed_object.signed_data.certificates[0]
.sia_signed_object_uris
.insert(0, alternate);
assert!(validate_manifest_sia_location(&manifest, &uri).is_ok());
assert!(
validate_manifest_sia_location(&manifest, &uri.replace("rsync://", "RSYNC://")).is_err()
);
}
#[test]
fn ca_sia_preserves_all_names_and_identity_ignores_certificate_reissuance() {
use crate::model::rc::{AccessDescription, SubjectInfoAccess};
let mut cert =
crate::model::rc::ResourceCertificate::decode_der(&issuer_ca_fixture_der()).unwrap();
let old_identity = CaIdentity::from_spki([1; 32], &cert.tbs.subject_public_key_info);
let original =
crate::validation::ca_instance::ca_instance_uris_from_ca_certificate(&cert).unwrap();
let SubjectInfoAccess::Ca(sia) = cert.tbs.extensions.subject_info_access.as_mut().unwrap()
else {
panic!("CA SIA")
};
let alternative = "rsync://rpki-local-rsync:873/custom/child/alternative.mft";
sia.access_descriptions.insert(
0,
AccessDescription {
access_method_oid: crate::model::oid::OID_AD_RPKI_MANIFEST.into(),
access_location: alternative.into(),
},
);
let updated =
crate::validation::ca_instance::ca_instance_uris_from_ca_certificate(&cert).unwrap();
assert_eq!(updated.manifest_rsync_uri, alternative);
assert_eq!(
updated.manifest_sia_uris,
vec![alternative.to_string(), original.manifest_rsync_uri]
);
assert_eq!(
old_identity,
CaIdentity::from_spki([1; 32], &cert.tbs.subject_public_key_info)
);
}
#[test]
fn expired_and_early_manifest_revalidation_cannot_bypass_validity() {
let (manifest, bytes, _, _, _) = load_manifest_fixture();
for now in [
manifest.manifest.this_update - time::Duration::seconds(1),
manifest.manifest.next_update + time::Duration::seconds(1),
] {
assert!(matches!(
decode_and_validate_manifest_with_current_time(&bytes, now),
Err(ManifestFreshError::StaleOrEarly { .. })
));
}
}
#[test]
fn maximum_number_cannot_wrap_without_rename_and_generation_cannot_wrap() {
let old = history_candidate("a.mft", vec![0x7f; 20], 100).history;
let same_name = history_candidate("a.mft", vec![0], 101);
assert!(matches!(
checked_manifest_history(Some(&old), &same_name),
Err(ManifestFreshError::ManifestNumberNotIncreasing { .. })
));
let exhausted = CaManifestHistory {
generation: u64::MAX,
..old
};
let renamed = history_candidate("b.mft", vec![0], 101);
assert!(matches!(
checked_manifest_history(Some(&exhausted), &renamed),
Err(ManifestFreshError::Storage(_))
));
let invalid = ManifestHistoryCandidate {
history: CaManifestHistory {
manifest_number_be: vec![0x80; 20],
..renamed.history.clone()
},
..renamed
};
assert!(matches!(
checked_manifest_history(None, &invalid),
Err(ManifestFreshError::Storage(_))
));
}
#[test]
fn interleaved_rename_is_reported_against_the_latest_committed_reference() {
let td = tempfile::tempdir().unwrap();
let store = RocksStore::open(td.path()).unwrap();
let original = history_candidate("a.mft", vec![10], 100);
commit_manifest_history(&store, &original).unwrap();
let candidate = history_candidate("a.mft", vec![11], 103);
assert!(
manifest_rename_warning(
&store,
&candidate.identity,
&candidate.manifest_sia_uris,
&candidate.history.manifest_rsync_uri
)
.unwrap()
.is_none()
);
commit_manifest_history(&store, &history_candidate("b.mft", vec![0], 102)).unwrap();
let (history, warning) = commit_manifest_history(&store, &candidate).unwrap();
assert_eq!(history.reference_filename, "a.mft");
assert!(warning.unwrap().message.contains("old=b.mft"));
}
#[test]
fn filenames_compare_uri_path_segments_without_query_or_fragment() {
assert_eq!(
manifest_filename("rsync://host/r/a.mft?key=1#fragment"),
"a.mft"
);
let old = history_candidate("a.mft", vec![10], 100).history;
assert!(!manifest_filename_changed(
&old,
&["rsync://new-host/new/a.mft?key=2".into()]
));
assert_eq!(manifest_filename("rsync://host/r/a%3Fmft"), "a%3Fmft");
}

View File

@@ -15,6 +15,7 @@ pub struct PublicationPointResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FreshValidatedPublicationPoint {
pub history_candidate: ManifestHistoryCandidate,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub manifest_number_be: Vec<u8>,
@@ -203,6 +204,14 @@ pub enum ManifestFreshError {
#[error("manifest file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")]
HashMismatch { rsync_uri: String },
#[error(
"ManifestSiaUriMismatch: actual={actual_uri} signedObject={signed_object_uris:?} (RFC 9981 §4; RFC 9286 §6.6)"
)]
ManifestSiaUriMismatch {
actual_uri: String,
signed_object_uris: Vec<String>,
},
#[error("issuer CA certificate bytes unavailable: {detail} (RFC 6487 §4; RFC 9286 §6.2)")]
IssuerCaLoadFailed { detail: String },
}
@@ -211,11 +220,13 @@ impl ManifestFreshError {
pub(crate) fn warning_category(&self) -> crate::output::report::WarningCategory {
use crate::output::report::WarningCategory;
match self {
Self::Storage(_) | Self::RepoSyncFailed { .. } => WarningCategory::Unclassified,
Self::RepoSyncFailed { .. } => WarningCategory::Unclassified,
Self::IssuerCaLoadFailed { .. } | Self::EeCertPath(_) | Self::EeResources(_) => {
WarningCategory::Certificate
}
Self::ManifestOutsidePublicationPoint { .. }
Self::Storage(_)
| Self::ManifestSiaUriMismatch { .. }
| Self::ManifestOutsidePublicationPoint { .. }
| Self::ManifestNumberNotIncreasing { .. }
| Self::ThisUpdateNotIncreasing { .. }
| Self::HashMismatch { .. } => WarningCategory::SecurityPolicy,
@@ -235,6 +246,7 @@ pub enum ManifestProcessError {
pub fn process_manifest_publication_point(
store: &RocksStore,
anchor_namespace: &[u8; 32],
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
@@ -244,6 +256,7 @@ pub fn process_manifest_publication_point(
) -> Result<PublicationPointResult, ManifestProcessError> {
process_manifest_publication_point_after_repo_sync(
store,
anchor_namespace,
policy,
manifest_rsync_uri,
publication_point_rsync_uri,
@@ -257,6 +270,7 @@ pub fn process_manifest_publication_point(
pub fn process_manifest_publication_point_fresh_after_repo_sync(
store: &RocksStore,
anchor_namespace: &[u8; 32],
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
@@ -267,6 +281,7 @@ pub fn process_manifest_publication_point_fresh_after_repo_sync(
) -> Result<FreshValidatedPublicationPoint, ManifestFreshError> {
process_manifest_publication_point_fresh_after_repo_sync_with_timing(
store,
anchor_namespace,
manifest_rsync_uri,
publication_point_rsync_uri,
None,
@@ -299,6 +314,7 @@ pub struct FreshPublicationPointTimingBreakdown {
pub fn process_manifest_publication_point_fresh_after_repo_sync_with_timing(
store: &RocksStore,
anchor_namespace: &[u8; 32],
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
current_repo_index: Option<&CurrentRepoIndexHandle>,
@@ -317,6 +333,7 @@ pub fn process_manifest_publication_point_fresh_after_repo_sync_with_timing(
if repo_sync_ok {
try_build_fresh_publication_point_with_timing(
store,
anchor_namespace,
manifest_rsync_uri,
publication_point_rsync_uri,
current_repo_index,
@@ -333,6 +350,7 @@ pub fn process_manifest_publication_point_fresh_after_repo_sync_with_timing(
pub fn process_manifest_publication_point_after_repo_sync(
store: &RocksStore,
anchor_namespace: &[u8; 32],
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
@@ -345,6 +363,7 @@ pub fn process_manifest_publication_point_after_repo_sync(
let fresh = if repo_sync_ok {
try_build_fresh_publication_point(
store,
anchor_namespace,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
@@ -406,6 +425,7 @@ fn decode_and_validate_manifest_with_current_time(
pub(crate) fn try_build_fresh_publication_point(
store: &RocksStore,
anchor_namespace: &[u8; 32],
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
@@ -414,6 +434,7 @@ pub(crate) fn try_build_fresh_publication_point(
) -> Result<FreshValidatedPublicationPoint, ManifestFreshError> {
try_build_fresh_publication_point_with_timing(
store,
anchor_namespace,
manifest_rsync_uri,
publication_point_rsync_uri,
None,
@@ -426,6 +447,7 @@ pub(crate) fn try_build_fresh_publication_point(
pub(crate) fn try_build_fresh_publication_point_with_timing(
store: &RocksStore,
anchor_namespace: &[u8; 32],
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
current_repo_index: Option<&CurrentRepoIndexHandle>,
@@ -505,64 +527,34 @@ pub(crate) fn try_build_fresh_publication_point_with_timing(
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
// RFC 9286 §4.2.1: rollback detection for manifestNumber and thisUpdate.
//
// Important nuance for revalidation across runs:
// - If the manifestNumber is equal to the previously validated manifestNumber *and* the
// manifest bytes are identical, then this is the same manifest being revalidated and MUST
// be accepted (otherwise, RPs would incorrectly treat stable repositories as "failed fetch"
// and continue the current validation run with a clear failure if the
// repository cannot provide a valid manifest.
// - If manifestNumber is equal but the manifest bytes differ, treat this as invalid (a
// repository is not allowed to change the manifest while keeping the manifestNumber).
// - If manifestNumber is lower, treat as rollback and reject.
// - If manifestNumber is higher, require thisUpdate to be more recent than the previously
// validated thisUpdate.
let anti_rollback_started = std::time::Instant::now();
if let Some(old_meta) = store.get_manifest_anti_rollback_meta(manifest_rsync_uri)? {
timing.anti_rollback_meta_hit = true;
if old_meta.manifest_rsync_uri == manifest_rsync_uri {
let new_num = manifest.manifest.manifest_number.bytes_be.as_slice();
let old_num = old_meta.manifest_number_be.as_slice();
match cmp_minimal_be_unsigned(new_num, old_num) {
Ordering::Greater => {
let old_this_update = old_meta
.manifest_this_update
.parse()
.expect("manifest anti-rollback metadata ensures thisUpdate parses");
if this_update <= old_this_update {
use time::format_description::well_known::Rfc3339;
return Err(ManifestFreshError::ThisUpdateNotIncreasing {
old_rfc3339_utc: old_this_update
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format old thisUpdate"),
new_rfc3339_utc: this_update
.format(&Rfc3339)
.expect("format new thisUpdate"),
});
}
}
Ordering::Equal => {
let new_manifest_hash = sha2::Sha256::digest(&manifest_bytes);
if old_meta.manifest_sha256.as_slice() != new_manifest_hash.as_slice() {
return Err(ManifestFreshError::ManifestNumberNotIncreasing {
old_hex: hex::encode_upper(old_num),
new_hex: hex::encode_upper(new_num),
});
}
}
Ordering::Less => {
return Err(ManifestFreshError::ManifestNumberNotIncreasing {
old_hex: hex::encode_upper(old_num),
new_hex: hex::encode_upper(new_num),
});
}
}
validate_manifest_sia_location(&manifest, manifest_rsync_uri)?;
let issuer = crate::model::rc::ResourceCertificate::decode_der(issuer_ca_der).map_err(|e| {
ManifestFreshError::IssuerCaLoadFailed {
detail: e.to_string(),
}
} else {
timing.anti_rollback_meta_miss = true;
}
})?;
let issuer_uris = crate::validation::ca_instance::ca_instance_uris_from_ca_certificate(&issuer)
.map_err(|e| ManifestFreshError::IssuerCaLoadFailed {
detail: e.to_string(),
})?;
let history_candidate = ManifestHistoryCandidate {
identity: CaIdentity::from_spki(*anchor_namespace, &issuer.tbs.subject_public_key_info),
manifest_sia_uris: issuer_uris.manifest_sia_uris,
history: CaManifestHistory {
reference_filename: manifest_filename(manifest_rsync_uri).into(),
manifest_rsync_uri: manifest_rsync_uri.into(),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
manifest_this_update: PackTime::from_utc_offset_datetime(this_update),
manifest_sha256: sha2::Sha256::digest(&manifest_bytes).to_vec(),
updated_at_validation_time: PackTime::from_utc_offset_datetime(now),
generation: 1,
},
};
let anti_rollback_started = std::time::Instant::now();
let old = store.get_ca_manifest_history(&history_candidate.identity)?;
timing.anti_rollback_meta_hit = old.is_some();
timing.anti_rollback_meta_miss = old.is_none();
checked_manifest_history(old.as_ref(), &history_candidate)?;
timing.anti_rollback_guard_ms = anti_rollback_started.elapsed().as_millis() as u64;
let manifest_entries_started = std::time::Instant::now();
@@ -680,6 +672,7 @@ pub(crate) fn try_build_fresh_publication_point_with_timing(
Ok((
FreshValidatedPublicationPoint {
history_candidate,
manifest_rsync_uri: manifest_rsync_uri.to_string(),
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),

View File

@@ -125,6 +125,7 @@ fn try_build_fresh_publication_point_rejects_manifest_outside_publication_point(
let (_, _, manifest_rsync_uri, _, validation_time) = load_manifest_fixture();
let err = try_build_fresh_publication_point(
&store,
&[0; 32],
&manifest_rsync_uri,
"rsync://example.test/other/",
&issuer_ca_fixture_der(),
@@ -149,6 +150,7 @@ fn try_build_fresh_publication_point_reports_missing_manifest_when_raw_store_is_
load_manifest_fixture();
let err = try_build_fresh_publication_point(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
&issuer_ca_fixture_der(),
@@ -192,6 +194,7 @@ fn try_build_fresh_publication_point_reports_missing_locked_file() {
let err = try_build_fresh_publication_point(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
&issuer_ca_fixture_der(),
@@ -254,6 +257,7 @@ fn try_build_fresh_publication_point_detects_hash_mismatch_via_repository_view_h
let err = try_build_fresh_publication_point(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
&issuer_ca_fixture_der(),
@@ -322,6 +326,7 @@ fn try_build_fresh_publication_point_uses_current_repo_index_without_repository_
let (fresh, _timing) = try_build_fresh_publication_point_with_timing(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
Some(&current_index),
@@ -356,6 +361,7 @@ fn try_build_fresh_publication_point_records_anti_rollback_meta_miss() {
let (_fresh, timing) = try_build_fresh_publication_point_with_timing(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
None,
@@ -388,7 +394,9 @@ fn try_build_fresh_publication_point_uses_anti_rollback_meta_hit_for_same_manife
validation_time,
);
store
.put_manifest_anti_rollback_meta(&previous_meta)
.update_ca_manifest_history::<StorageError>(&test_ca_identity(), |_| {
Ok(previous_meta.clone())
})
.expect("persist freshness");
put_complete_publication_point_current_objects(
&store,
@@ -400,6 +408,7 @@ fn try_build_fresh_publication_point_uses_anti_rollback_meta_hit_for_same_manife
let (_fresh, timing) = try_build_fresh_publication_point_with_timing(
&store,
&[0; 32],
&manifest_rsync_uri,
&publication_point_rsync_uri,
None,
@@ -527,8 +536,10 @@ fn sample_manifest_meta(
_base: &str,
bytes: &[u8],
now: time::OffsetDateTime,
) -> crate::repository::storage::ManifestAntiRollbackMeta {
crate::repository::storage::ManifestAntiRollbackMeta {
) -> CaManifestHistory {
CaManifestHistory {
reference_filename: manifest_filename(uri).into(),
generation: 1,
manifest_rsync_uri: uri.into(),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update),
@@ -557,7 +568,11 @@ fn history_survives_restart_and_rejects_rollback_without_advancing() {
}
{
let store = RocksStore::open(dir.path()).unwrap();
store.put_manifest_anti_rollback_meta(&meta).unwrap();
store
.update_ca_manifest_history::<StorageError>(&test_ca_identity(), |_| {
Ok(meta.clone())
})
.unwrap();
put_complete_publication_point_current_objects(
&store,
&manifest,
@@ -568,11 +583,12 @@ fn history_survives_restart_and_rejects_rollback_without_advancing() {
}
let store = RocksStore::open(dir.path()).unwrap();
assert_eq!(
store.get_manifest_anti_rollback_meta(&uri).unwrap(),
store.get_ca_manifest_history(&test_ca_identity()).unwrap(),
Some(meta.clone())
);
let result = try_build_fresh_publication_point_with_timing(
&store,
&[0; 32],
&uri,
&base,
None,
@@ -599,8 +615,15 @@ fn history_survives_restart_and_rejects_rollback_without_advancing() {
_ => unreachable!(),
}
assert_eq!(
store.get_manifest_anti_rollback_meta(&uri).unwrap(),
store.get_ca_manifest_history(&test_ca_identity()).unwrap(),
Some(meta)
);
}
}
fn test_ca_identity() -> CaIdentity {
let cert = crate::model::rc::ResourceCertificate::decode_der(&issuer_ca_fixture_der()).unwrap();
CaIdentity::from_spki([0; 32], &cert.tbs.subject_public_key_info)
}
include!("history_tests.rs");

View File

@@ -32,6 +32,7 @@ pub enum RunError {
/// 3) signed object processing (ROA/ASPA) and public report/CCR output
pub fn run_publication_point_once(
store: &RocksStore,
anchor_namespace: &[u8; 32],
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
@@ -46,6 +47,7 @@ pub fn run_publication_point_once(
validation_time: time::OffsetDateTime,
) -> Result<RunOutput, RunError> {
let handle = CaInstanceHandle {
anchor_namespace: *anchor_namespace,
depth: 0,
tal_id: "single-publication-point".to_string(),
parent_manifest_rsync_uri: None,
@@ -55,6 +57,12 @@ pub fn run_publication_point_once(
effective_as_resources: issuer_effective_as.cloned(),
rsync_base_uri: rsync_base_uri.to_string(),
manifest_rsync_uri: manifest_rsync_uri.to_string(),
manifest_sia_uris: crate::validation::ca_instance::ca_instance_uris_from_ca_certificate(
&crate::model::rc::ResourceCertificate::decode_der(issuer_ca_der)
.map_err(|e| RunError::Runner(e.to_string()))?,
)
.map_err(|e| RunError::Runner(e.to_string()))?
.manifest_sia_uris,
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
rrdp_notification_uri: rrdp_notification_uri.map(str::to_string),
};

View File

@@ -364,6 +364,10 @@ pub fn root_handle_from_trust_anchor(
) -> CaInstanceHandle {
let ta_rc = trust_anchor.ta_certificate.rc_ca.clone();
CaInstanceHandle {
anchor_namespace: <sha2::Sha256 as sha2::Digest>::digest(
&trust_anchor.tal.subject_public_key_info_der,
)
.into(),
depth: 0,
tal_id,
parent_manifest_rsync_uri: None,
@@ -373,6 +377,7 @@ pub fn root_handle_from_trust_anchor(
effective_as_resources: ta_rc.tbs.extensions.as_resources.clone(),
rsync_base_uri: ca_instance.rsync_base_uri.clone(),
manifest_rsync_uri: ca_instance.manifest_rsync_uri.clone(),
manifest_sia_uris: ca_instance.manifest_sia_uris.clone(),
publication_point_rsync_uri: ca_instance.publication_point_rsync_uri.clone(),
rrdp_notification_uri: ca_instance.rrdp_notification_uri.clone(),
}

View File

@@ -118,6 +118,7 @@ impl CaCertificateRef {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceHandle {
pub anchor_namespace: [u8; 32],
pub depth: usize,
pub tal_id: String,
pub parent_manifest_rsync_uri: Option<String>,
@@ -135,6 +136,7 @@ pub struct CaInstanceHandle {
pub effective_as_resources: Option<AsResourceSet>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub manifest_sia_uris: Vec<String>,
pub publication_point_rsync_uri: String,
pub rrdp_notification_uri: Option<String>,
}

View File

@@ -308,6 +308,7 @@ fn discover_children_from_fresh_snapshot_with_audit_with_issuer_der<P: Publicati
let child_hash = sha256_hex_from_32(&file.sha256);
children.push(DiscoveredChildCaInstance {
handle: CaInstanceHandle {
anchor_namespace: issuer.anchor_namespace,
depth: 0,
tal_id: issuer.tal_id.clone(),
parent_manifest_rsync_uri: Some(issuer.manifest_rsync_uri.clone()),
@@ -317,6 +318,7 @@ fn discover_children_from_fresh_snapshot_with_audit_with_issuer_der<P: Publicati
effective_as_resources: validated.effective_as_resources.clone(),
rsync_base_uri: uris.rsync_base_uri.clone(),
manifest_rsync_uri: uris.manifest_rsync_uri.clone(),
manifest_sia_uris: uris.manifest_sia_uris.clone(),
publication_point_rsync_uri: uris.publication_point_rsync_uri.clone(),
rrdp_notification_uri: uris.rrdp_notification_uri.clone(),
},

View File

@@ -27,10 +27,45 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
let snapshot_prepare_started = std::time::Instant::now();
let issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
.map_err(|detail| FreshPublicationPointStageError {
warnings: Vec::new(),
error: ManifestFreshError::IssuerCaLoadFailed { detail },
snapshot_prepare_ms: snapshot_prepare_started.elapsed().as_millis() as u64,
})?;
let issuer_ca_der: Arc<[u8]> = Arc::from(issuer_ca_der.as_ref());
let identity = ResourceCertificate::decode_der(&issuer_ca_der)
.map(|cert| {
crate::repository::storage::CaIdentity::from_spki(
ca.anchor_namespace,
&cert.tbs.subject_public_key_info,
)
})
.map_err(|e| FreshPublicationPointStageError {
warnings: Vec::new(),
error: ManifestFreshError::IssuerCaLoadFailed {
detail: e.to_string(),
},
snapshot_prepare_ms: snapshot_prepare_started.elapsed().as_millis() as u64,
})?;
let rename_warnings: Vec<Warning> = crate::validation::manifest::manifest_rename_warning(
self.store,
&identity,
&ca.manifest_sia_uris,
&ca.manifest_rsync_uri,
)
.map_err(|error| FreshPublicationPointStageError {
warnings: Vec::new(),
error,
snapshot_prepare_ms: snapshot_prepare_started.elapsed().as_millis() as u64,
})?
.into_iter()
.collect();
for warning in &rename_warnings {
crate::logging::emit(
crate::logging::Level::Warn,
"manifest_filename_changed",
|| serde_json::json!({"manifest_rsync_uri": ca.manifest_rsync_uri, "message": warning.message, "candidate_accepted": null}),
);
}
let fresh_publication_point = {
let _manifest_total = self
.timing
@@ -38,6 +73,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
.map(|t| t.span_phase("manifest_processing_total"));
process_manifest_publication_point_fresh_after_repo_sync_with_timing(
self.store,
&ca.anchor_namespace,
&ca.manifest_rsync_uri,
&ca.publication_point_rsync_uri,
self.current_repo_index.as_ref(),
@@ -51,6 +87,16 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
let snapshot_prepare_ms = snapshot_prepare_started.elapsed().as_millis() as u64;
let (fresh_point, snapshot_prepare_timing) =
fresh_publication_point.map_err(|error| FreshPublicationPointStageError {
warnings: rename_warnings
.iter()
.cloned()
.map(|mut warning| {
warning
.message
.push_str(&format!(" candidate_accepted=false reason={error}"));
warning
})
.collect(),
error,
snapshot_prepare_ms,
})?;
@@ -152,7 +198,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
self.policy,
)
};
let (discovered_children, child_audits, discovered_router_keys, warnings) = match out {
let (discovered_children, child_audits, discovered_router_keys, mut warnings) = match out {
Ok(out) => (out.children, out.audits, out.router_keys, Vec::new()),
Err(e) => (
Vec::new(),
@@ -188,6 +234,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
child_discovery_ms,
);
warnings.extend(rename_warnings);
Ok(FreshPublicationPointStage {
fresh_point,
issuer_ca_der,
@@ -205,7 +252,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
&self,
ca: &CaInstanceHandle,
fresh_point: &FreshValidatedPublicationPoint,
warnings: Vec<Warning>,
mut warnings: Vec<Warning>,
objects: crate::validation::objects::ObjectsOutput,
child_audits: Vec<ObjectAuditEntry>,
discovered_children: Vec<DiscoveredChildCaInstance>,
@@ -229,6 +276,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
snapshot_pack_ms,
);
let mut ccr_projection = None;
let mut ccr_projection_build_ms = 0;
let mut ccr_append_ms = 0;
if self.ccr_accumulator.is_some() {
@@ -254,9 +302,30 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
let ccr_manifest_projection =
crate::ccr::projection::from_snapshot(ca, &pack, subordinate_skis)?;
ccr_projection_build_ms = ccr_projection_build_started.elapsed().as_millis() as u64;
let ccr_append_started = std::time::Instant::now();
self.append_ccr_manifest_projection(&ccr_manifest_projection)?;
ccr_append_ms = ccr_append_started.elapsed().as_millis() as u64;
ccr_projection = Some(ccr_manifest_projection);
}
let (_, commit_warning) = crate::validation::manifest::commit_manifest_history(
self.store,
&fresh_point.history_candidate,
)
.map_err(|error| format!("persist manifest freshness failed: {error}"))?;
if let Some(warning) = commit_warning {
warnings.retain(|item| !item.message.starts_with("manifest filename changed:"));
warnings.push(warning);
}
for warning in &mut warnings {
if warning.message.starts_with("manifest filename changed:") {
warning.message.push_str(&format!(
" new_number={} new_this_update={} candidate_accepted=true",
hex::encode_upper(&pack.manifest_number_be),
pack.this_update.rfc3339_utc
));
}
}
if let Some(projection) = ccr_projection {
let started = std::time::Instant::now();
self.append_ccr_manifest_projection(&projection)?;
ccr_append_ms = started.elapsed().as_millis() as u64;
}
if let Some(timing) = self.timing.as_ref() {
timing.record_phase_nanos(
@@ -305,21 +374,6 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
audit_build_ms,
);
// Commit protocol freshness only after successful publication-point
// validation/finalization, independently of optional CCR output.
self.store
.put_manifest_anti_rollback_meta(
&crate::repository::storage::ManifestAntiRollbackMeta {
manifest_rsync_uri: pack.manifest_rsync_uri.clone(),
manifest_number_be: pack.manifest_number_be.clone(),
manifest_this_update: pack.this_update.clone(),
manifest_sha256: <sha2::Sha256 as sha2::Digest>::digest(&pack.manifest_bytes)
.to_vec(),
updated_at_validation_time: pack.verified_at.clone(),
},
)
.map_err(|error| format!("persist manifest freshness failed: {error}"))?;
Ok(FreshPublicationPointFinalizeOutput {
result: PublicationPointRunResult {
source: PublicationPointSource::Fresh,

View File

@@ -97,7 +97,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
let stage = match stage {
Ok(stage) => stage,
Err(stage_error) => {
let mut warnings = Vec::new();
let mut warnings = stage_error.warnings;
warnings.push(
Warning::new(format!(
"publication point processing failed: {}",

View File

@@ -13,6 +13,7 @@ pub(crate) struct FreshPublicationPointStage {
#[derive(Debug)]
pub(crate) struct FreshPublicationPointStageError {
pub(crate) warnings: Vec<Warning>,
pub(crate) error: ManifestFreshError,
pub(crate) snapshot_prepare_ms: u64,
}