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

@@ -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`:

View File

@@ -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.

View File

@@ -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"

View File

@@ -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` |

View File

@@ -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:

View File

@@ -13,11 +13,8 @@ pub(crate) fn select_manifest_signed_object_location(
&& access_description.access_location == manifest_rsync_uri
})
.collect::<Vec<_>>();
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::<Result<Vec<_>, _>>()?;
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<usize, String> {
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]

View File

@@ -151,13 +151,14 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
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"

View File

@@ -697,24 +697,40 @@ fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result<BigUnsigned, ManifestProfi
if bytes.is_empty() {
return Err(ManifestProfileError::InvalidManifestNumber);
}
// Two's complement: for non-negative values, a leading 0x00 may be present.
if bytes[0] & 0x80 != 0 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let mut start = 0usize;
while start + 1 < bytes.len() && bytes[start] == 0 {
start += 1;
}
let mut minimal = bytes[start..].to_vec();
if minimal.is_empty() {
minimal.push(0);
}
if minimal.len() > 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<UtcTime, String> {
// Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z").
if !bytes.is_ascii() {

View File

@@ -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<ExternalRawStoreDb>,
external_repo_bytes: Option<ExternalRepoBytesDb>,
}

View File

@@ -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:";

View File

@@ -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(),

View File

@@ -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<u8>,
pub manifest_this_update: PackTime,
pub manifest_sha256: Vec<u8>,
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,

View File

@@ -41,6 +41,7 @@ impl RocksStore {
Ok(Self {
db,
manifest_history_commit: std::sync::Mutex::new(()),
external_raw_store: None,
external_repo_bytes: None,
})

View File

@@ -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<Option<ManifestAntiRollbackMeta>> {
identity: &CaIdentity,
) -> StorageResult<Option<CaManifestHistory>> {
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::<ManifestAntiRollbackMeta>(&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<E: From<StorageError>>(
&self,
metadata: &ManifestAntiRollbackMeta,
) -> StorageResult<()> {
metadata.validate_internal()?;
identity: &CaIdentity,
update: impl FnOnce(Option<&CaManifestHistory>) -> Result<CaManifestHistory, E>,
) -> Result<CaManifestHistory, E> {
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)
}
}

View File

@@ -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");

View File

@@ -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::<StorageError>(&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::<StorageError>(&id, |_| Ok(sample_ca_history())),
Err(StorageError::RocksDb(_))
));
assert_eq!(store.get_ca_manifest_history(&id).unwrap(), None);
}

View File

@@ -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<Vec<(String, Vec<u8>)>>
{
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(
<sha2::Sha256 as sha2::Digest>::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(
<sha2::Sha256 as sha2::Digest>::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(&current),
previous.as_ref(),
"failed candidate must not change history"
);
}
previous = Some(current);
}
}

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.
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(),
}
})?;
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();
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),
});
}
}
}
} else {
timing.anti_rollback_meta_miss = true;
}
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,
}

View File

@@ -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}");
}

View File

@@ -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:

View File

@@ -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'<snapshot xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="1">\n'
+''.join(g.publish(uri,path) for uri,path in paths)+'</snapshot>\n').encode()
http=case/'http/rrdp';http.mkdir(parents=True)
(http/'snapshot.xml').write_bytes(snap)
(http/'notification.xml').write_text(f'<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="1"><snapshot uri="https://127.0.0.1:{port}/rrdp/snapshot.xml" hash="{hashlib.sha256(snap).hexdigest()}"/></notification>')
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))