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