From 5f46837b61a09e04445a8906cd6f4f903552da5d Mon Sep 17 00:00:00 2001 From: yuyr Date: Fri, 16 Oct 2026 11:16:20 +0800 Subject: [PATCH] =?UTF-8?q?20261015=5F4=20=E4=BF=AE=E5=A4=8DManifest?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E4=B8=8E=E6=94=B9=E5=90=8D=E6=8A=97=E9=87=8D?= =?UTF-8?q?=E6=94=BE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + README.md | 2 + docker/examples/validate-policy.toml | 5 +- docs/conformance-matrix.md | 1 + docs/usage.md | 16 + src/ccr/manifest_location.rs | 33 +- src/cli/validate.rs | 3 +- src/model/manifest.rs | 42 ++- src/repository/storage.rs | 2 + src/repository/storage/config.rs | 1 - src/repository/storage/keys.rs | 10 +- src/repository/storage/models_core.rs | 32 +- src/repository/storage/store_lifecycle.rs | 1 + src/repository/storage/store_manifest.rs | 50 +-- src/repository/storage/tests.rs | 1 + .../storage/tests_parts/manifest_history.rs | 84 +++++ src/runtime/tests.rs | 284 ++++++++++++++- src/validation/ca_instance.rs | 5 + src/validation/from_tal.rs | 4 + src/validation/manifest.rs | 5 +- src/validation/manifest/history.rs | 160 +++++++++ src/validation/manifest/history_tests.rs | 325 ++++++++++++++++++ src/validation/manifest/models_and_process.rs | 111 +++--- src/validation/manifest/tests.rs | 35 +- src/validation/run.rs | 8 + src/validation/run_tree_from_tal/discovery.rs | 5 + src/validation/tree.rs | 2 + src/validation/tree_runner/discovery.rs | 2 + src/validation/tree_runner/fresh_pipeline.rs | 94 +++-- .../tree_runner/publication_point_runner.rs | 2 +- src/validation/tree_runner/types.rs | 1 + tests/manifest_history_e2e.rs | 9 + tests/support/generate_repository.py | 6 + tests/support/test_manifest_history.py | 148 ++++++++ 34 files changed, 1346 insertions(+), 149 deletions(-) create mode 100644 src/repository/storage/tests_parts/manifest_history.rs create mode 100644 src/validation/manifest/history.rs create mode 100644 src/validation/manifest/history_tests.rs create mode 100644 tests/manifest_history_e2e.rs create mode 100644 tests/support/test_manifest_history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 746c016..824d199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Implement RFC 9981 manifest rename handling with CA-key history in a TAL-key + namespace, strict thisUpdate progression, exact EE SIA location binding and + operator warnings. Commits recheck the latest history before publishing output. + Start with a fresh state directory; no old-database migration is provided. +- Report rejected manifests as partial validation results, including strict + completion failure when `require-complete` is enabled. - Detect RRDP session desynchronization from persisted notification delta hashes (RFC 9697), warn and recover using the current snapshot before applying deltas. - Select automatic TA certificates using `draft-ietf-sidrops-rpki-ta-tiebreaker-06`: diff --git a/README.md b/README.md index 1619592..1eca880 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ For continuous operation see [Usage](docs/usage.md#continuous-operation). - RRDP session desynchronization detection (RFC 9697) and automatic TA certificate selection (TA tiebreaker draft -06; unreleased). - HTTPS same-origin checks for RRDP references and redirects. +- Manifest filename changes and SIA location verification (RFC 9981), with + durable CA-key history and replay protection (unreleased; fresh state required). - Independent bounded repository and object worker pools. - CA, CRL, manifest, ROA, ASPA and BGPsec router-certificate processing. - CSV, CCR, JSON summaries and configurable text or JSON logs from TOML. diff --git a/docker/examples/validate-policy.toml b/docker/examples/validate-policy.toml index 9a30a2e..390d1df 100644 --- a/docker/examples/validate-policy.toml +++ b/docker/examples/validate-policy.toml @@ -1,7 +1,7 @@ # Container-path configuration showcase for validation policy and rsync. # Copy the file outside this directory and set CONFIG_FILE to its absolute path. -# The fixed validation time makes the example reproducible; update it or remove -# the line when using it against a current public repository. +# Live repositories use the current time. Set a fixed validation-time only +# when replaying a matching frozen input, for example 2026-09-22T00:00:00Z. config-version = 1 mode = "validate" out = "/output/policy" @@ -11,7 +11,6 @@ ccr-out = "/output/policy/result.ccr" publish-dir = "/output/publish" resource-validation-mode = "validation-update-03" # or "rfc6487" -validation-time = "2026-09-22T00:00:00Z" sync-preference = "rrdp-then-rsync" # or "rsync-only" rsync-timeout-secs = 30 rsync-command = "/usr/bin/rsync" diff --git a/docs/conformance-matrix.md b/docs/conformance-matrix.md index a55049d..da920d5 100644 --- a/docs/conformance-matrix.md +++ b/docs/conformance-matrix.md @@ -4,6 +4,7 @@ | Behaviour | Standard reference | Implementation | Evidence | | --- | --- | --- | --- | +| CA-key manifest history across filename changes, monotonic thisUpdate, exact EE SIA location matching, rename warnings and coordinated commits | [RFC 9981 §§2–4](https://www.rfc-editor.org/rfc/rfc9981.html) | `src/validation/manifest/`, `src/repository/storage/store_manifest.rs`, `src/validation/tree_runner/` | `history_tests.rs`, storage history fault tests, `tests/manifest_history_e2e.rs` | | Detect changed hashes for overlapping delta serials within a session, warn and recover with a snapshot | [RFC 9697 §§3–5](https://www.rfc-editor.org/rfc/rfc9697.html) | `src/repository/sync/rrdp/`, `src/repository/storage/models_summary.rs` | `tests_parts/desynchronization.rs`; `tests/security_recovery_e2e.rs` | | Choose between valid fetched and cached TA certificates by later notBefore, earlier notAfter, then freshly fetched | [TA tiebreaker draft -06 §2](https://datatracker.ietf.org/doc/html/draft-ietf-sidrops-rpki-ta-tiebreaker-06) | `src/cli/anchors.rs` | `anchors_tiebreaker_tests.rs`; `tests/security_recovery_e2e.rs` | diff --git a/docs/usage.md b/docs/usage.md index 8de9c4e..997500c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -86,6 +86,22 @@ An omitted `constraints` field discovers a same-stem file beside the TAL. An explicit path must exist and parse. Constraint rules follow the profile in the [standards matrix](conformance-matrix.md). +## Manifest history + +The unreleased RFC 9981 implementation requires a fresh state directory. It +directly uses CA public-key history within the TAL public-key namespace; there +is no old-database migration or fallback. Reuse that new directory on subsequent +runs to retain replay protection across restarts and CA certificate reissuance. + +A manifest filename change can restart the number sequence, but its thisUpdate +must still advance. Alternative CA SIA entries participate in the filename +comparison; changing only a directory does not reset the number. A rename is +reported to the operator, including when the candidate later fails validation. +The EE signedObject URI must exactly match the actual published rsync URI for +both RRDP and rsync. Failed manifests do not advance history or emit their +payloads; they make the CLI result partial and trigger exit code 3 when +`require-complete = true`. + ## Continuous operation Daemon configuration keeps the controller and validator settings together: diff --git a/src/ccr/manifest_location.rs b/src/ccr/manifest_location.rs index 791293f..eabf27a 100644 --- a/src/ccr/manifest_location.rs +++ b/src/ccr/manifest_location.rs @@ -13,11 +13,8 @@ pub(crate) fn select_manifest_signed_object_location( && access_description.access_location == manifest_rsync_uri }) .collect::>(); - let access_description = expect_single_matching_location( - manifest_rsync_uri, - matching.len(), - "parsed Manifest EE SIA", - )?; + let access_description = + expect_matching_location(manifest_rsync_uri, matching.len(), "parsed Manifest EE SIA")?; encode_access_description_der(matching[access_description]) } @@ -40,7 +37,7 @@ pub(crate) fn select_manifest_signed_object_location_from_der( }, ) .collect::, _>>()?; - let index = expect_single_matching_location( + let index = expect_matching_location( manifest_rsync_uri, matching.len(), "CCR manifest projection", @@ -56,16 +53,16 @@ pub(crate) fn encode_access_description_der( Ok(encode_sequence(&[oid, uri])) } -fn expect_single_matching_location( +fn expect_matching_location( manifest_rsync_uri: &str, matching_count: usize, source: &str, ) -> Result { - if matching_count == 1 { + if matching_count > 0 { return Ok(0); } Err(format!( - "{source} contains {matching_count} id-ad-signedObject locations matching manifest URI {manifest_rsync_uri}; expected exactly one" + "{source} contains {matching_count} id-ad-signedObject locations matching manifest URI {manifest_rsync_uri}; expected at least one (RFC 9981 §4)" )) } @@ -254,7 +251,7 @@ mod tests { } #[test] - fn rejects_missing_or_duplicate_matching_signed_object() { + fn rejects_missing_and_accepts_duplicate_matching_signed_object() { let missing = select_manifest_signed_object_location( MANIFEST_URI, &[access_description( @@ -272,8 +269,20 @@ mod tests { access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI), ], ) - .expect_err("duplicate signed object must fail"); - assert!(duplicate.contains("contains 2"), "{duplicate}"); + .expect("duplicate matching locations select the same signed object"); + assert_eq!( + duplicate, + encode_access_description_der(&access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI)) + .unwrap() + ); + assert_eq!( + select_manifest_signed_object_location_from_der( + MANIFEST_URI, + &[duplicate.clone(), duplicate.clone()] + ) + .unwrap(), + duplicate + ); } #[test] diff --git a/src/cli/validate.rs b/src/cli/validate.rs index 34d4389..57b453e 100644 --- a/src/cli/validate.rs +++ b/src/cli/validate.rs @@ -151,13 +151,14 @@ fn execute(config: &Config, run_id: &str) -> Result { serde_json::from_slice(&fs::read(root.join("summary.json")).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; let repository_failed = summary["repositoryFailedCount"].as_u64().unwrap_or(0); + let manifest_failed = summary["rejected_manifests"].as_u64().unwrap_or(0); // A healthy tiebreaker can prefer a cached TA after a successful download. // Only a failed refresh using cache makes the result partial. let cached = anchors .iter() .filter(|a| a["source"] == "cache" && a["refreshFailed"] == true) .count(); - let status = if failed > 0 || repository_failed > 0 || cached > 0 { + let status = if failed > 0 || repository_failed > 0 || manifest_failed > 0 || cached > 0 { "partial" } else { "success" diff --git a/src/model/manifest.rs b/src/model/manifest.rs index 18e0fec..a983c6e 100644 --- a/src/model/manifest.rs +++ b/src/model/manifest.rs @@ -697,24 +697,40 @@ fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result 20 { + // DER INTEGER content is limited to 20 octets, including a sign octet. + // This bounds positive numbers to 2^159-1 (RFC 9981 §1). + if bytes.len() > 20 { return Err(ManifestProfileError::ManifestNumberTooLong); } + if bytes[0] & 0x80 != 0 || (bytes.len() > 1 && bytes[0] == 0 && bytes[1] & 0x80 == 0) { + return Err(ManifestProfileError::InvalidManifestNumber); + } + let minimal = if bytes.len() > 1 && bytes[0] == 0 { + bytes[1..].to_vec() + } else { + bytes.to_vec() + }; Ok(BigUnsigned { bytes_be: minimal }) } +#[test] +fn manifest_number_rejects_values_beyond_159_bits_and_non_der_padding() { + let mut overflow = vec![0; 21]; + overflow[1] = 0x80; + assert!(der_integer_to_bigunsigned(&overflow).is_err()); + assert!(der_integer_to_bigunsigned(&[0, 1]).is_err()); + assert!(der_integer_to_bigunsigned(&[0, 0]).is_err()); + assert!(der_integer_to_bigunsigned(&[0x80]).is_err()); + let mut max = vec![0xff; 20]; + max[0] = 0x7f; + assert_eq!(der_integer_to_bigunsigned(&max).unwrap().bytes_be, max); + assert_eq!( + der_integer_to_bigunsigned(&[0, 0x80]).unwrap().bytes_be, + vec![0x80] + ); + assert_eq!(der_integer_to_bigunsigned(&[0]).unwrap().bytes_be, vec![0]); +} + fn parse_generalized_time_bytes(bytes: &[u8]) -> Result { // Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z"). if !bytes.is_ascii() { diff --git a/src/repository/storage.rs b/src/repository/storage.rs index d9012dc..03a8770 100644 --- a/src/repository/storage.rs +++ b/src/repository/storage.rs @@ -27,6 +27,8 @@ pub use pack::{PackBytes, PackFile, PackTime}; pub struct RocksStore { db: DB, + // The lock covers only history read/check/write, never network or validation. + manifest_history_commit: std::sync::Mutex<()>, external_raw_store: Option, external_repo_bytes: Option, } diff --git a/src/repository/storage/config.rs b/src/repository/storage/config.rs index db0034b..7b2e705 100644 --- a/src/repository/storage/config.rs +++ b/src/repository/storage/config.rs @@ -23,7 +23,6 @@ pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[ pub(super) const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:"; pub(super) const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:"; pub(super) const RAW_BLOB_KEY_PREFIX: &str = "rawblob:"; -pub(super) const MANIFEST_ANTI_ROLLBACK_KEY_PREFIX: &str = "manifest_anti_rollback:"; pub(super) const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:"; pub(super) const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:"; pub(super) const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:"; diff --git a/src/repository/storage/keys.rs b/src/repository/storage/keys.rs index 8012cb4..7e18389 100644 --- a/src/repository/storage/keys.rs +++ b/src/repository/storage/keys.rs @@ -20,8 +20,12 @@ pub(super) fn raw_blob_key(sha256_hex: &str) -> String { format!("{RAW_BLOB_KEY_PREFIX}{sha256_hex}") } -pub(super) fn manifest_anti_rollback_key(manifest_rsync_uri: &str) -> String { - format!("{MANIFEST_ANTI_ROLLBACK_KEY_PREFIX}{manifest_rsync_uri}") +pub(super) fn ca_manifest_key(identity: &super::CaIdentity) -> String { + format!( + "ca_manifest:{}:{}", + hex::encode(identity.anchor_namespace), + hex::encode(identity.spki_sha256) + ) } pub(super) fn rrdp_source_key(notify_uri: &str) -> String { @@ -94,7 +98,7 @@ pub(super) fn validate_manifest_number_be(field: &'static str, value: &[u8]) -> detail: "must not be empty".to_string(), }); } - if value.len() > 20 { + if value.len() > 20 || (value.len() == 20 && value[0] & 0x80 != 0) { return Err(StorageError::InvalidData { entity: field, detail: "must be at most 20 octets".to_string(), diff --git a/src/repository/storage/models_core.rs b/src/repository/storage/models_core.rs index 343591e..16d33c0 100644 --- a/src/repository/storage/models_core.rs +++ b/src/repository/storage/models_core.rs @@ -166,19 +166,43 @@ impl ValidatedManifestMeta { } } -/// Persistent manifest freshness metadata used by RFC 9286 anti-rollback -/// checks. It stores no validated object output. +/// A CA key within a stable TAL public-key namespace, independent of URIs. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CaIdentity { + pub anchor_namespace: [u8; 32], + pub spki_sha256: [u8; 32], +} + +impl CaIdentity { + pub fn from_spki(anchor_namespace: [u8; 32], ca_spki_der: &[u8]) -> Self { + Self { + anchor_namespace, + spki_sha256: compute_sha256_32(ca_spki_der), + } + } +} + +/// Protocol history retained independently of repository membership and CCR. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ManifestAntiRollbackMeta { +pub struct CaManifestHistory { + pub reference_filename: String, pub manifest_rsync_uri: String, pub manifest_number_be: Vec, pub manifest_this_update: PackTime, pub manifest_sha256: Vec, pub updated_at_validation_time: PackTime, + pub generation: u64, } -impl ManifestAntiRollbackMeta { +impl CaManifestHistory { pub fn validate_internal(&self) -> StorageResult<()> { + validate_non_empty("ca_manifest.reference_filename", &self.reference_filename)?; + if self.reference_filename.contains('/') || self.generation == 0 { + return Err(StorageError::InvalidData { + entity: "ca_manifest", + detail: "invalid reference filename or zero generation".into(), + }); + } validate_non_empty( "manifest_anti_rollback.manifest_rsync_uri", &self.manifest_rsync_uri, diff --git a/src/repository/storage/store_lifecycle.rs b/src/repository/storage/store_lifecycle.rs index f7347af..4821d69 100644 --- a/src/repository/storage/store_lifecycle.rs +++ b/src/repository/storage/store_lifecycle.rs @@ -41,6 +41,7 @@ impl RocksStore { Ok(Self { db, + manifest_history_commit: std::sync::Mutex::new(()), external_raw_store: None, external_repo_bytes: None, }) diff --git a/src/repository/storage/store_manifest.rs b/src/repository/storage/store_manifest.rs index 1a4096b..59402e6 100644 --- a/src/repository/storage/store_manifest.rs +++ b/src/repository/storage/store_manifest.rs @@ -1,36 +1,44 @@ -// Manifest rollback metadata is protocol state retained independently from -// repository objects and validation outputs. - +// History is keyed by CA public key in its TAL namespace, never by object URI. impl RocksStore { - pub fn get_manifest_anti_rollback_meta( + pub fn get_ca_manifest_history( &self, - manifest_rsync_uri: &str, - ) -> StorageResult> { + identity: &CaIdentity, + ) -> StorageResult> { let cf = self.cf(CF_MANIFEST_ANTI_ROLLBACK)?; - let key = manifest_anti_rollback_key(manifest_rsync_uri); let Some(bytes) = self .db - .get_cf(cf, key.as_bytes()) - .map_err(|error| StorageError::RocksDb(error.to_string()))? + .get_cf(cf, ca_manifest_key(identity).as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None); }; - let metadata = decode_cbor::(&bytes, "manifest_anti_rollback")?; - metadata.validate_internal()?; - Ok(Some(metadata)) + let history: CaManifestHistory = decode_cbor(&bytes, "ca_manifest")?; + history.validate_internal()?; + Ok(Some(history)) } - pub fn put_manifest_anti_rollback_meta( + /// Re-read and validate under the same lock as the write. RocksDB itself + /// excludes a second writable process opening this database. + pub(crate) fn update_ca_manifest_history>( &self, - metadata: &ManifestAntiRollbackMeta, - ) -> StorageResult<()> { - metadata.validate_internal()?; + identity: &CaIdentity, + update: impl FnOnce(Option<&CaManifestHistory>) -> Result, + ) -> Result { + let _guard = + self.manifest_history_commit + .lock() + .map_err(|_| StorageError::InvalidData { + entity: "ca_manifest", + detail: "history commit lock poisoned".into(), + })?; + let old = self.get_ca_manifest_history(identity)?; + let history = update(old.as_ref())?; + history.validate_internal()?; let cf = self.cf(CF_MANIFEST_ANTI_ROLLBACK)?; - let key = manifest_anti_rollback_key(&metadata.manifest_rsync_uri); - let value = encode_cbor(metadata, "manifest_anti_rollback")?; + let bytes = encode_cbor(&history, "ca_manifest")?; self.db - .put_cf(cf, key.as_bytes(), value) - .map_err(|error| StorageError::RocksDb(error.to_string()))?; - Ok(()) + .put_cf(cf, ca_manifest_key(identity).as_bytes(), bytes) + .map_err(|e| StorageError::RocksDb(e.to_string()))?; + Ok(history) } } diff --git a/src/repository/storage/tests.rs b/src/repository/storage/tests.rs index 725138e..5114132 100644 --- a/src/repository/storage/tests.rs +++ b/src/repository/storage/tests.rs @@ -50,3 +50,4 @@ fn opening_an_unsupported_state_schema_fails_without_rewriting() { include!("tests_parts/repository.rs"); include!("tests_parts/rrdp.rs"); include!("tests_parts/object_loading.rs"); +include!("tests_parts/manifest_history.rs"); diff --git a/src/repository/storage/tests_parts/manifest_history.rs b/src/repository/storage/tests_parts/manifest_history.rs new file mode 100644 index 0000000..0d5cab3 --- /dev/null +++ b/src/repository/storage/tests_parts/manifest_history.rs @@ -0,0 +1,84 @@ +fn sample_ca_history() -> CaManifestHistory { + CaManifestHistory { + reference_filename: "a.mft".into(), + manifest_rsync_uri: "rsync://example.net/r/a.mft".into(), + manifest_number_be: vec![1], + manifest_this_update: pack_time(100), + manifest_sha256: vec![1; 32], + updated_at_validation_time: pack_time(101), + generation: 1, + } +} + +#[test] +fn manifest_history_survives_restart_and_reports_corrupt_records() { + let td = tempfile::tempdir().unwrap(); + let id = CaIdentity::from_spki([1; 32], b"key"); + let history = sample_ca_history(); + { + let store = RocksStore::open(td.path()).unwrap(); + store + .update_ca_manifest_history::(&id, |_| Ok(history.clone())) + .unwrap(); + } + let mut store = RocksStore::open(td.path()).unwrap(); + assert_eq!(store.get_ca_manifest_history(&id).unwrap(), Some(history)); + let cf = store.cf(CF_MANIFEST_ANTI_ROLLBACK).unwrap(); + store + .db + .put_cf(cf, ca_manifest_key(&id), b"not CBOR") + .unwrap(); + assert!(matches!( + store.get_ca_manifest_history(&id), + Err(StorageError::Codec { .. }) + )); + store + .db + .put_cf( + cf, + ca_manifest_key(&id), + encode_cbor( + &CaManifestHistory { + generation: 0, + ..sample_ca_history() + }, + "test", + ) + .unwrap(), + ) + .unwrap(); + assert!(matches!( + store.get_ca_manifest_history(&id), + Err(StorageError::InvalidData { .. }) + )); + store.db.drop_cf(CF_MANIFEST_ANTI_ROLLBACK).unwrap(); + assert!(matches!( + store.get_ca_manifest_history(&id), + Err(StorageError::MissingColumnFamily(_)) + )); +} + +#[test] +fn manifest_history_write_failure_is_returned_and_does_not_create_success() { + let td = tempfile::tempdir().unwrap(); + drop(RocksStore::open(td.path()).unwrap()); + let db = DB::open_cf_descriptors_read_only( + &Options::default(), + td.path(), + column_family_descriptors(), + false, + ) + .unwrap(); + let store = RocksStore { + db, + manifest_history_commit: std::sync::Mutex::new(()), + external_raw_store: None, + external_repo_bytes: None, + }; + let id = CaIdentity::from_spki([1; 32], b"key"); + assert!(matches!( + store.update_ca_manifest_history::(&id, |_| Ok(sample_ca_history())), + Err(StorageError::RocksDb(_)) + )); + assert_eq!(store.get_ca_manifest_history(&id).unwrap(), None); +} diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 318cd3d..dc1dd51 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -57,6 +57,136 @@ fn synthetic_snapshot_delta_restart_rollback_and_log_equivalence() { .read_line(&mut ready) .unwrap(); assert_eq!(ready.trim(), "ready"); + // Exercise the public serial tree with CCR disabled independently from + // runtime's parallel coordinator, retaining history across real syncs. + { + struct NoRsync; + impl crate::repository::fetch::rsync::RsyncFetcher for NoRsync { + fn fetch_objects( + &self, + _: &str, + ) -> crate::repository::fetch::rsync::RsyncFetchResult)>> + { + Err(crate::repository::fetch::rsync::RsyncFetchError::Fetch( + "unexpected rsync fallback".into(), + )) + } + } + let discovery = crate::validation::from_tal::discover_root_ca_instance_from_tal_and_ta_der( + &std::fs::read(fixture.join("tal/custom.tal")).unwrap(), + &std::fs::read(fixture.join("ta/custom-ta.cer")).unwrap(), + None, + ) + .unwrap(); + let root = crate::validation::run_tree_from_tal::root_handle_from_trust_anchor( + &discovery.trust_anchor, + "serial-test".into(), + None, + &discovery.ca_instance, + ); + let ca = crate::model::rc::ResourceCertificate::decode_der( + &std::fs::read(fixture.join("cases/baseline-v1/repository/root/child.cer")).unwrap(), + ) + .unwrap(); + let identity = crate::repository::storage::CaIdentity::from_spki( + root.anchor_namespace, + &ca.tbs.subject_public_key_info, + ); + let http = crate::repository::fetch::http::BlockingHttpFetcher::new( + crate::repository::fetch::http::HttpFetcherConfig { + extra_root_certificates_pem: vec![ + std::fs::read(fixture.join("certs/rrdp-ca.pem")).unwrap(), + ], + ..Default::default() + }, + ) + .unwrap(); + let store = RocksStore::open(&tmp.path().join("serial-state")).unwrap(); + let policy = crate::validation::policy::Policy::default(); + let runner = crate::validation::tree_runner::Rpkiv1PublicationPointRunner { + store: &store, + policy: &policy, + http_fetcher: &http, + rsync_fetcher: &NoRsync, + validation_time: time::OffsetDateTime::from_unix_timestamp(1788825600).unwrap(), + timing: None, + download_log: None, + current_repo_index: None, + repo_sync_runtime: None, + parallel_phase2_config: None, + parallel_roa_worker_pool: None, + ccr_accumulator: None, + }; + for generation in 1..=2 { + let tree = crate::validation::tree::run_tree_serial( + root.clone(), + &runner, + &crate::validation::tree::TreeRunConfig { + build_ccr_accumulator: false, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(tree.vrps.len(), 1); + assert_eq!(tree.aspas.len(), 1); + assert_eq!( + store + .get_ca_manifest_history(&identity) + .unwrap() + .unwrap() + .generation, + generation + ); + } + let validation_time = time::OffsetDateTime::from_unix_timestamp(1788825600).unwrap(); + let single = crate::validation::from_tal::run_root_from_tal_url_once( + &store, + &policy, + &format!("https://127.0.0.1:{port}/tal/custom.tal"), + &http, + &NoRsync, + validation_time, + ) + .unwrap(); + assert_eq!( + single.run.publication_point_source, + crate::validation::manifest::PublicationPointSource::Fresh + ); + let uris = &discovery.ca_instance; + let root_der = &discovery.trust_anchor.ta_certificate.raw_der; + let direct = crate::validation::manifest::process_manifest_publication_point( + &store, + &root.anchor_namespace, + &policy, + &uris.manifest_rsync_uri, + &uris.publication_point_rsync_uri, + root_der, + None, + validation_time, + ) + .unwrap(); + assert_eq!( + direct.source, + crate::validation::manifest::PublicationPointSource::Fresh + ); + let fresh = + crate::validation::manifest::process_manifest_publication_point_fresh_after_repo_sync( + &store, + &root.anchor_namespace, + &uris.manifest_rsync_uri, + &uris.publication_point_rsync_uri, + root_der, + None, + validation_time, + true, + None, + ) + .unwrap(); + assert_eq!( + fresh.history_candidate.identity.anchor_namespace, + root.anchor_namespace + ); + } let db = tmp.path().join("state"); let run = |name: &str, expected_vrps: u64, workers: usize, log: &str| { let out = tmp.path().join(name); @@ -106,11 +236,22 @@ fn synthetic_snapshot_delta_restart_rollback_and_log_equivalence() { std::fs::read(first.join("vrps.csv")).unwrap(), std::fs::read(second.join("vrps.csv")).unwrap() ); - let uri = "rsync://rpki-local-rsync:873/custom/child/child.mft"; + let tal = crate::model::tal::Tal::decode_bytes( + &std::fs::read(fixture.join("tal/custom.tal")).unwrap(), + ) + .unwrap(); + let cert = crate::model::rc::ResourceCertificate::decode_der( + &std::fs::read(fixture.join("cases/baseline-v1/repository/root/child.cer")).unwrap(), + ) + .unwrap(); + let identity = crate::repository::storage::CaIdentity::from_spki( + ::digest(&tal.subject_public_key_info_der).into(), + &cert.tbs.subject_public_key_info, + ); let before = { let store = RocksStore::open(&db).unwrap(); store - .get_manifest_anti_rollback_meta(uri) + .get_ca_manifest_history(&identity) .unwrap() .expect("runtime must persist freshness") }; @@ -129,7 +270,7 @@ fn synthetic_snapshot_delta_restart_rollback_and_log_equivalence() { ); let after = { let store = RocksStore::open(&db).unwrap(); - store.get_manifest_anti_rollback_meta(uri).unwrap().unwrap() + store.get_ca_manifest_history(&identity).unwrap().unwrap() }; assert_eq!(after.manifest_number_be, vec![2]); // Use a fresh RRDP session to force fetching an older signed manifest. @@ -162,8 +303,143 @@ fn synthetic_snapshot_delta_restart_rollback_and_log_equivalence() { run("rollback-rejected", 0, 8, "info"); let store = RocksStore::open(&db).unwrap(); assert_eq!( - store.get_manifest_anti_rollback_meta(uri).unwrap(), + store.get_ca_manifest_history(&identity).unwrap(), Some(after) ); crate::logging::configure("info", "text").unwrap(); } + +#[test] +fn manifest_rename_alerts_follow_runtime_success_and_failed_candidates() { + let tmp = tempfile::tempdir().unwrap(); + let fixture = tmp.path().join("fixture"); + let port = free_port(); + assert!( + Command::new("python3") + .args([ + "tests/support/generate_repository.py", + "--rrdp-host", + "127.0.0.1", + "--rsync-host", + "127.0.0.1", + "--strict-names", + "--rrdp-port", + &port.to_string(), + "--output" + ]) + .arg(&fixture) + .status() + .unwrap() + .success() + ); + // Use the same signed adversarial publications as the real CLI test, but + // run the library coordinator in-process to inspect its report and history. + assert!(Command::new("python3").args(["-c", "import sys; from pathlib import Path; sys.path.insert(0, 'tests/support'); from test_manifest_history import create_cases; create_cases(Path(sys.argv[1]), int(sys.argv[2]))"]) + .arg(&fixture).arg(port.to_string()).status().unwrap().success()); + let active = tmp.path().join("active"); + std::os::unix::fs::symlink(fixture.join("history-cases/initial/http"), &active).unwrap(); + let mut server = ChildGuard( + Command::new("python3") + .arg("tests/support/serve_repository.py") + .arg(&active) + .arg(port.to_string()) + .arg(fixture.join("certs/rrdp-server.pem")) + .arg(fixture.join("certs/rrdp-server.key")) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let mut ready = String::new(); + BufReader::new(server.0.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready.trim(), "ready"); + let tal = crate::model::tal::Tal::decode_bytes( + &std::fs::read(fixture.join("tal/custom.tal")).unwrap(), + ) + .unwrap(); + let ca = crate::model::rc::ResourceCertificate::decode_der( + &std::fs::read(fixture.join("history-cases/initial/repository/root/child.cer")).unwrap(), + ) + .unwrap(); + let identity = crate::repository::storage::CaIdentity::from_spki( + ::digest(&tal.subject_public_key_info_der).into(), + &ca.tbs.subject_public_key_info, + ); + let db = tmp.path().join("state"); + let mut previous = None; + for (name, accepted, renamed) in [ + ("initial", true, false), + ("renamed", true, true), + ("stale-rename", false, true), + ("failed-file", false, true), + ("retry", true, true), + ] { + std::fs::remove_file(&active).unwrap(); + std::os::unix::fs::symlink( + fixture.join("history-cases").join(name).join("http"), + &active, + ) + .unwrap(); + let output = tmp.path().join(name); + std::fs::create_dir_all(&output).unwrap(); + run_config(RunConfig { + db_path: db.clone(), + tal_inputs: vec![TalInputSpec::from_file_path_with_ta( + fixture.join("tal/custom.tal"), + fixture.join("ta/custom-ta.cer"), + )], + http_root_cert_paths: vec![fixture.join("certs/rrdp-ca.pem")], + http_timeout_secs: 2, + max_ca_depth: 64, + rsync_timeout_secs: 1, + validation_time: Some(time::OffsetDateTime::from_unix_timestamp(1780272000).unwrap()), + summary_out_path: Some(output.join("summary.json")), + report_json_path: Some(output.join("report.json")), + ..Default::default() + }) + .unwrap(); + let summary: serde_json::Value = + serde_json::from_slice(&std::fs::read(output.join("summary.json")).unwrap()).unwrap(); + assert_eq!(summary["vrps"], u64::from(accepted), "{name}: {summary}"); + let report = std::fs::read_to_string(output.join("report.json")).unwrap(); + assert_eq!( + report.contains("manifest filename changed"), + renamed, + "{name}: {report}" + ); + if renamed { + assert!( + report.contains(if accepted { + "candidate_accepted=true" + } else { + "candidate_accepted=false" + }), + "{name}: {report}" + ); + } + let current = RocksStore::open(&db) + .unwrap() + .get_ca_manifest_history(&identity) + .unwrap() + .unwrap(); + if accepted { + assert_eq!( + current.generation, + previous + .as_ref() + .map_or(1, |h: &crate::repository::storage::CaManifestHistory| h + .generation + + 1) + ); + } else { + assert_eq!( + Some(¤t), + previous.as_ref(), + "failed candidate must not change history" + ); + } + previous = Some(current); + } +} diff --git a/src/validation/ca_instance.rs b/src/validation/ca_instance.rs index ed654f5..9ae7cec 100644 --- a/src/validation/ca_instance.rs +++ b/src/validation/ca_instance.rs @@ -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, /// 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 = None; let mut manifest: Option = None; + let mut manifest_sia_uris = Vec::new(); let mut notify: Option = 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, }) diff --git a/src/validation/from_tal.rs b/src/validation/from_tal.rs index 56f4d66..4b95acb 100644 --- a/src/validation/from_tal.rs +++ b/src/validation/from_tal.rs @@ -368,6 +368,10 @@ pub fn run_root_from_tal_url_once( let run = run_publication_point_once( store, + &::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, diff --git a/src/validation/manifest.rs b/src/validation/manifest.rs index f3b575f..a232fe4 100644 --- a/src/validation/manifest.rs +++ b/src/validation/manifest.rs @@ -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; diff --git a/src/validation/manifest/history.rs b/src/validation/manifest/history.rs new file mode 100644 index 0000000..f9b8e55 --- /dev/null +++ b/src/validation/manifest/history.rs @@ -0,0 +1,160 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestHistoryCandidate { + pub identity: CaIdentity, + pub manifest_sia_uris: Vec, + 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 { + 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), 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, 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 { + 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(), + }) +} diff --git a/src/validation/manifest/history_tests.rs b/src/validation/manifest/history_tests.rs new file mode 100644 index 0000000..7e45e3f --- /dev/null +++ b/src/validation/manifest/history_tests.rs @@ -0,0 +1,325 @@ +fn history_candidate(name: &str, number: Vec, 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"); +} diff --git a/src/validation/manifest/models_and_process.rs b/src/validation/manifest/models_and_process.rs index 1ba4d95..0a8c684 100644 --- a/src/validation/manifest/models_and_process.rs +++ b/src/validation/manifest/models_and_process.rs @@ -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, @@ -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, + }, + #[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 { 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 { 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 { 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(), diff --git a/src/validation/manifest/tests.rs b/src/validation/manifest/tests.rs index 8b3797e..1347e4f 100644 --- a/src/validation/manifest/tests.rs +++ b/src/validation/manifest/tests.rs @@ -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(¤t_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::(&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::(&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"); diff --git a/src/validation/run.rs b/src/validation/run.rs index c13472d..1d0b5b1 100644 --- a/src/validation/run.rs +++ b/src/validation/run.rs @@ -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 { 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), }; diff --git a/src/validation/run_tree_from_tal/discovery.rs b/src/validation/run_tree_from_tal/discovery.rs index ae09eff..705d664 100644 --- a/src/validation/run_tree_from_tal/discovery.rs +++ b/src/validation/run_tree_from_tal/discovery.rs @@ -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: ::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(), } diff --git a/src/validation/tree.rs b/src/validation/tree.rs index 8719c6a..7130cd0 100644 --- a/src/validation/tree.rs +++ b/src/validation/tree.rs @@ -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, @@ -135,6 +136,7 @@ pub struct CaInstanceHandle { pub effective_as_resources: Option, pub rsync_base_uri: String, pub manifest_rsync_uri: String, + pub manifest_sia_uris: Vec, pub publication_point_rsync_uri: String, pub rrdp_notification_uri: Option, } diff --git a/src/validation/tree_runner/discovery.rs b/src/validation/tree_runner/discovery.rs index 3bcbd6d..304d07c 100644 --- a/src/validation/tree_runner/discovery.rs +++ b/src/validation/tree_runner/discovery.rs @@ -308,6 +308,7 @@ fn discover_children_from_fresh_snapshot_with_audit_with_issuer_der 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 = 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, + mut warnings: Vec, objects: crate::validation::objects::ObjectsOutput, child_audits: Vec, discovered_children: Vec, @@ -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: ::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, diff --git a/src/validation/tree_runner/publication_point_runner.rs b/src/validation/tree_runner/publication_point_runner.rs index 4f62c3c..0435255 100644 --- a/src/validation/tree_runner/publication_point_runner.rs +++ b/src/validation/tree_runner/publication_point_runner.rs @@ -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: {}", diff --git a/src/validation/tree_runner/types.rs b/src/validation/tree_runner/types.rs index acc038f..17b747c 100644 --- a/src/validation/tree_runner/types.rs +++ b/src/validation/tree_runner/types.rs @@ -13,6 +13,7 @@ pub(crate) struct FreshPublicationPointStage { #[derive(Debug)] pub(crate) struct FreshPublicationPointStageError { + pub(crate) warnings: Vec, pub(crate) error: ManifestFreshError, pub(crate) snapshot_prepare_ms: u64, } diff --git a/tests/manifest_history_e2e.rs b/tests/manifest_history_e2e.rs new file mode 100644 index 0000000..482b76e --- /dev/null +++ b/tests/manifest_history_e2e.rs @@ -0,0 +1,9 @@ +#[test] +fn manifest_rename_and_location_binding_survive_real_cli_restarts() { + let status = std::process::Command::new("python3") + .arg("tests/support/test_manifest_history.py") + .arg(env!("CARGO_BIN_EXE_panda-rpki")) + .status() + .expect("start manifest history CLI driver"); + assert!(status.success(), "manifest history E2E failed: {status}"); +} diff --git a/tests/support/generate_repository.py b/tests/support/generate_repository.py index 7b7ade0..a69a03d 100644 --- a/tests/support/generate_repository.py +++ b/tests/support/generate_repository.py @@ -191,8 +191,10 @@ def sign_cert( as_ext: bytes | None = None, ca_repository: str | None = None, manifest_uri: str | None = None, + manifest_uris: list[str] | None = None, notify_uri: str | None = None, signed_object_uri: str | None = None, + signed_object_uris: list[str] | None = None, crl_uri: str | None = None, ca_issuer_uri: str | None = None, not_before: datetime = VALID_FROM, @@ -240,10 +242,14 @@ def sign_cert( sia_entries.append((SIA_CA_REPOSITORY, ca_repository)) if manifest_uri is not None: sia_entries.append((SIA_RPKI_MANIFEST, manifest_uri)) + for uri in manifest_uris or []: + sia_entries.append((SIA_RPKI_MANIFEST, uri)) if notify_uri is not None: sia_entries.append((SIA_RPKI_NOTIFY, notify_uri)) if signed_object_uri is not None: sia_entries.append((SIA_SIGNED_OBJECT, signed_object_uri)) + for uri in signed_object_uris or []: + sia_entries.append((SIA_SIGNED_OBJECT, uri)) if sia_entries: builder = builder.add_extension(x509.UnrecognizedExtension(OID_SIA, sia(sia_entries)), critical=False) if ip_ext is not None: diff --git a/tests/support/test_manifest_history.py b/tests/support/test_manifest_history.py new file mode 100644 index 0000000..8c98012 --- /dev/null +++ b/tests/support/test_manifest_history.py @@ -0,0 +1,148 @@ +"""Real signed manifests, CA reissuance, HTTPS RRDP and rsync across CLI restarts.""" +from __future__ import annotations +import argparse, base64, functools, hashlib, http.server, json, shutil, ssl, subprocess, sys, tempfile, threading +from datetime import timedelta +from pathlib import Path +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding +import generate_repository as g +from test_public_config import free_port, make_rsync_wrapper, read_json, run_checked, write_config + + +def create_cases(fixture: Path, port: int) -> list[dict]: + g.STRICT_NAMES = True + g.RRDP_HOST = '127.0.0.1'; g.RRDP_PORT = port + g.RRDP_URI = f'https://127.0.0.1:{port}/rrdp/notification.xml' + g.ROOT_REPO_URI = 'rsync://127.0.0.1:873/custom/root/' + g.CHILD_REPO_URI = 'rsync://127.0.0.1:873/custom/child/' + root_key = serialization.load_pem_private_key((fixture/'keys/root.key').read_bytes(), None) + child_key = serialization.load_pem_private_key((fixture/'keys/child.key').read_bytes(), None) + original = fixture/'cases/baseline-v1/repository' + root = x509.load_der_x509_certificate((original/'ta.cer').read_bytes()) + child = x509.load_der_x509_certificate((original/'root/child.cer').read_bytes()) + cases = [] + # Rejections are followed by valid retries against the same durable history. + specs = [ + ('initial', 'a.mft', 2**159-1, 100, ['a.mft'], None, 1, ''), + ('renamed', 'b.mft', 1, 101, ['b.mft'], None, 1, 'manifest filename changed'), + ('stale-rename', 'c.mft', 0, 100, ['c.mft'], None, 0, 'thisUpdate not more recent'), + ('failed-file', 'c.mft', 0, 102, ['c.mft'], None, 0, 'manifest file hash mismatch'), + ('retry', 'c.mft', 0, 102, ['c.mft'], None, 1, 'manifest filename changed'), + ('replay-a', 'a.mft', 2**159-1, 100, ['a.mft'], None, 0, 'thisUpdate not more recent'), + ('back-a', 'a.mft', 0, 103, ['a.mft'], None, 1, 'manifest filename changed'), + ('multi-sia', 'b.mft', 0, 104, ['b.mft', 'a.mft'], None, 0, 'manifestNumber not higher'), + ('multi-sia-new', 'b.mft', 1, 104, ['b.mft', 'a.mft'], None, 1, ''), + ('location-mismatch', 'b.mft', 2, 105, ['b.mft'], ['moved.mft'], 0, 'ManifestSiaUriMismatch'), + ('multiple-ee', 'b.mft', 2, 105, ['b.mft'], ['other.mft', 'b.mft', 'b.mft'], 1, ''), + ('same-number', 'b.mft', 2, 106, ['b.mft'], None, 0, 'manifestNumber not higher'), + ('same-time', 'b.mft', 3, 105, ['b.mft'], None, 0, 'thisUpdate not more recent'), + ('final', 'b.mft', 3, 107, ['b.mft'], None, 1, ''), + ] + work = fixture/'history-work'; work.mkdir() + def manifest(path, number, stamp, files, issuer, signing_key, repo, ca_uri, ee_names): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ee = g.sign_cert(x509.Name([g.common_name('History Manifest EE')]), issuer, key.public_key(), signing_key, + x509.random_serial_number(), False, key, signing_key, + ip_ext=g.ip_inherit(), as_ext=g.as_resources(None), + signed_object_uris=[repo+name for name in ee_names], crl_uri=repo+('root.crl' if repo==g.ROOT_REPO_URI else 'child.crl'), ca_issuer_uri=ca_uri) + content = g.seq(g.integer(number), g.generalized_time(g.VALID_FROM+timedelta(days=30,seconds=stamp)), + g.generalized_time(g.VALID_TO-timedelta(days=30)), g.oid(g.OID_SHA256), + g.seq(*(g.seq(g.tlv(0x16,name.encode('ascii')), g.bit_string(hashlib.sha256(data).digest())) for name,data in files))) + g.write_cms(content,g.OID_MANIFEST_ECONTENT,ee,key,path,work) + for index, (name, filename, number, stamp, ca_names, ee_names, vrps, error) in enumerate(specs, 1): + case = fixture/'history-cases'/name; repo=case/'repository' + shutil.copytree(original,repo) + for old in (repo/'child').glob('*.mft'): old.unlink() + new_child = g.sign_cert(child.subject,root.subject,child_key.public_key(),root_key,1000+index,True,child_key,root_key, + ip_ext=g.ip_resources([g.CHILD_PREFIX]),as_ext=g.as_resources([g.TEST_ASN]), + ca_repository=g.CHILD_REPO_URI, manifest_uris=[g.CHILD_REPO_URI+n for n in ca_names], notify_uri=g.RRDP_URI, + crl_uri=g.ROOT_REPO_URI+'root.crl', ca_issuer_uri=g.ROOT_REPO_URI+'child.cer') + (repo/'root/child.cer').write_bytes(new_child.public_bytes(Encoding.DER)) + # Keep the child's AIA-bound copy consistent too. + (repo/'child/child.cer').write_bytes(new_child.public_bytes(Encoding.DER)) + manifest(repo/'root/root.mft', index+10, index+200, + [(n,(repo/'root'/n).read_bytes()) for n in ('root.crl','child.cer')],root.subject,root_key,g.ROOT_REPO_URI,g.ROOT_REPO_URI+'ta.cer',['root.mft']) + manifest(repo/'child'/filename, number, stamp, + [(n,(repo/'child'/n).read_bytes()) for n in ('child.crl','valid.roa','valid.asa')],child.subject,child_key,g.CHILD_REPO_URI,g.CHILD_REPO_URI+'child.cer',ee_names or [filename]) + if name=='failed-file': (repo/'child/valid.roa').write_bytes(b'corrupted after signing manifest') + # Each new publication uses an independent session and a complete snapshot. + session = f'{index:08d}-2222-4333-8444-555555555555' + paths = [(g.ROOT_REPO_URI+n,repo/'root'/n) for n in ('root.crl','child.cer','root.mft')] + paths += [(g.CHILD_REPO_URI+n,repo/'child'/n) for n in ('child.crl','valid.roa','valid.asa',filename)] + snap = (f'\n' + +''.join(g.publish(uri,path) for uri,path in paths)+'\n').encode() + http=case/'http/rrdp';http.mkdir(parents=True) + (http/'snapshot.xml').write_bytes(snap) + (http/'notification.xml').write_text(f'') + cases.append(dict(name=name,root=case,vrps=vrps,error=error,renamed=name in {'renamed','stale-rename','failed-file','retry','replay-a','back-a'})) + return cases + + +def run(binary: str, evidence: Path|None=None) -> dict: + proof={'transports':[]} + with tempfile.TemporaryDirectory(prefix='panda-manifest-history-') as tmp: + base=Path(tmp);fixture=base/'fixture';port=free_port() + run_checked([sys.executable,str(Path(__file__).with_name('generate_repository.py')),'--output',str(fixture),'--rrdp-host','127.0.0.1','--rrdp-port',str(port),'--rsync-host','127.0.0.1','--strict-names']) + cases=create_cases(fixture,port) + active=base/'active';active.symlink_to(cases[0]['root']/'http',target_is_directory=True) + requests=[] + class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + requests.append(self.path);super().do_GET() + def log_message(self,*args):pass + server=http.server.ThreadingHTTPServer(('127.0.0.1',port),functools.partial(Handler,directory=str(active))) + tls=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);tls.load_cert_chain(fixture/'certs/rrdp-server.pem',fixture/'certs/rrdp-server.key');server.socket=tls.wrap_socket(server.socket,server_side=True) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + for transport in ('rrdp-then-rsync','rsync-only'): + transport_root=base/transport;transport_root.mkdir();state=transport_root/'state' + wrapper=transport_root/'rsync.py';wrapper_log=transport_root/'rsync.log' + make_rsync_wrapper(wrapper,fixture,wrapper_log) + text=wrapper.read_text().replace(str(fixture/'cases/baseline-v1/repository'),str(base/'repository')) + wrapper.write_text(text);wrapper.chmod(0o755) + runs=[];initial_csv=None + for case in [cases[0],dict(cases[0],name='restart-identical'),*cases[1:],dict(cases[-1],name='restart-final')]: + if active.is_symlink():active.unlink() + active.symlink_to(case['root']/'http',target_is_directory=True) + repository=base/'repository' + if repository.is_symlink():repository.unlink() + repository.symlink_to(case['root']/'repository',target_is_directory=True) + out=transport_root/case['name'];config=transport_root/'validate.toml' + write_config(config,fixture=fixture,output=out,state=state,ccr=out/'result.ccr',wrapper=wrapper,mirror=transport_root/'mirror',sync_preference=transport,rrdp_mode='auto',validation_time='2026-06-01T00:00:00Z',resource_mode='rfc6487',scope='publication-point') + config_text=config.read_text() + # Exercise CCR-off and one object worker, then the worker pool path. + if case['name']=='restart-identical': + config_text='\n'.join(line for line in config_text.splitlines() if not line.startswith('ccr-out ='))+'\n' + config_text=config_text.replace('parallel-phase2-object-workers = 2','parallel-phase2-object-workers = 1') + if not case['vrps']:config_text=config_text.replace('require-complete = true','require-complete = false') + config.write_text(config_text) + start=len(requests) + result=subprocess.run([binary,'validate','--config',str(config)],text=True,capture_output=True,timeout=120) + summary=read_json(out/'summary.json') + report=(out/'report.json').read_text() if (out/'report.json').exists() else '' + combined=result.stdout+result.stderr+report + assert summary['vrps']==case['vrps'],(transport,case['name'],summary,combined[-5000:]) + assert result.returncode==0,(case['name'],result.returncode,combined[-5000:]) + if case['error']:assert case['error'] in combined,(case['name'],case['error'],combined[-5000:]) + if case['renamed']:assert 'manifest filename changed' in combined,(case['name'],combined[-5000:]) + if case['name']=='multi-sia-new':assert 'manifest filename changed' not in combined + if case['vrps']: + current=(out/'vrps.csv').read_bytes() + if initial_csv is None:initial_csv=current + assert current==initial_csv,(case['name'],'legal output changed') + assert summary['resultStatus']=='success',summary + else:assert summary['resultStatus']=='partial',summary + seen=requests[start:] + if transport=='rsync-only':assert not seen,seen + else:assert not wrapper_log.exists(),'unexpected rsync fallback' + runs.append(dict(name=case['name'],exit_code=result.returncode,status=summary['resultStatus'],vrps=summary['vrps'],requests=seen,expected_error=case['error'])) + proof['transports'].append(dict(transport=transport,runs=runs)) + finally:server.shutdown();server.server_close();thread.join(timeout=5) + if evidence:evidence.write_text(json.dumps(proof,indent=2)+'\n') + return proof + +if __name__=='__main__': + p=argparse.ArgumentParser();p.add_argument('binary');p.add_argument('--evidence',type=Path);a=p.parse_args() + result=run(a.binary,a.evidence);print(json.dumps(result))