diff --git a/src/storage.rs b/src/storage.rs index e7a5b42..4ce04e2 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -19,7 +19,7 @@ pub use config::{ ALL_COLUMN_FAMILY_NAMES, CF_CHILD_CERTIFICATE_CACHE_PROJECTION, CF_MANIFEST_REPLAY_META, CF_PUBLICATION_POINT_CACHE_PROJECTION, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW, CF_ROA_CACHE_PROJECTION, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, - CF_TRANSPORT_PREFETCH, CF_VCIR, column_family_descriptors, + CF_TRANSPORT_PREFETCH, CF_VCIR, CF_VCIR_FAILED_FETCH_REUSE_IDENTITY, column_family_descriptors, }; use keys::*; use pack::compute_sha256_32; @@ -585,6 +585,56 @@ impl VcirInstanceGate { } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VcirFailedFetchReuseIdentity { + #[serde(rename = "c")] + #[serde(with = "serde_bytes_32")] + pub current_ca_sha256: [u8; 32], + #[serde(rename = "t")] + #[serde(with = "serde_bytes_32")] + pub ta_context_digest: [u8; 32], + #[serde(rename = "p")] + #[serde(with = "serde_bytes_32")] + pub parent_context_digest: [u8; 32], + #[serde(rename = "f")] + #[serde(with = "serde_bytes_32")] + pub policy_fingerprint: [u8; 32], + #[serde(rename = "nb")] + pub effective_not_before: PackTime, + #[serde(rename = "nu")] + pub effective_until: PackTime, +} + +impl VcirFailedFetchReuseIdentity { + pub fn validate_internal(&self) -> StorageResult<()> { + let effective_not_before = parse_time( + "vcir_failed_fetch_reuse_identity.effective_not_before", + &self.effective_not_before, + )?; + let effective_until = parse_time( + "vcir_failed_fetch_reuse_identity.effective_until", + &self.effective_until, + )?; + if effective_not_before >= effective_until { + return Err(StorageError::InvalidData { + entity: "vcir_failed_fetch_reuse_identity.effective_window", + detail: "effective_not_before must be before effective_until".to_string(), + }); + } + Ok(()) + } + + pub fn contains_validation_time(&self, validation_time: time::OffsetDateTime) -> bool { + let Ok(effective_not_before) = self.effective_not_before.parse() else { + return false; + }; + let Ok(effective_until) = self.effective_until.parse() else { + return false; + }; + validation_time >= effective_not_before && validation_time < effective_until + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VcirChildEntry { pub child_manifest_rsync_uri: String, @@ -1062,6 +1112,7 @@ pub struct RoaCacheObjectMeta { pub source_object_hash: [u8; 32], pub ee_serial: Vec, pub crl_uri: String, + pub earliest_safe_reuse_time: PackTime, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1127,6 +1178,8 @@ pub struct RoaCacheObjectProjection { pub ee_serial: Option>, #[serde(rename = "c", default, skip_serializing_if = "Option::is_none")] pub crl_uri: Option, + #[serde(rename = "n", default, skip_serializing_if = "Option::is_none")] + pub earliest_safe_reuse_time_unix: Option, #[serde(rename = "x")] pub outputs_effective_until_unix: i64, #[serde(rename = "o")] @@ -1150,6 +1203,15 @@ impl RoaCacheObjectProjection { if let Some(crl_uri) = &self.crl_uri { validate_non_empty("roa_cache_projection.entries[].crl_uri", crl_uri)?; } + if let Some(earliest_safe_reuse_time_unix) = self.earliest_safe_reuse_time_unix + && earliest_safe_reuse_time_unix >= self.outputs_effective_until_unix + { + return Err(StorageError::InvalidData { + entity: "roa_cache_projection.entries[].effective_window", + detail: "earliest_safe_reuse_time_unix must be before outputs_effective_until_unix" + .to_string(), + }); + } if self.outputs.is_empty() { return Err(StorageError::InvalidData { entity: "roa_cache_projection.entries[]", @@ -1867,6 +1929,9 @@ impl RoaCacheProjection { vcir: &ValidatedCaInstanceResult, context: Option<&RoaCacheProjectionContext>, ) -> StorageResult> { + let Some(context) = context else { + return Ok(None); + }; let mut issuer_ca_sha256_hex = None; let mut crl_sha256_by_uri = Vec::new(); for artifact in &vcir.related_artifacts { @@ -1893,13 +1958,18 @@ impl RoaCacheProjection { } crl_sha256_by_uri.sort_by(|left, right| left.uri.cmp(&right.uri)); - let meta_by_uri = context.map(|context| { - context - .object_meta - .iter() - .map(|meta| (meta.source_object_uri.as_str(), meta)) - .collect::>() - }); + let meta_by_uri = context + .object_meta + .iter() + .map(|meta| (meta.source_object_uri.as_str(), meta)) + .collect::>(); + let vcir_validation_time = + vcir.last_successful_validation_time + .parse() + .map_err(|detail| StorageError::InvalidData { + entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix", + detail, + })?; let mut entries: Vec = Vec::new(); let mut entry_index_by_uri: HashMap = HashMap::new(); for output in &vcir.local_outputs { @@ -1915,23 +1985,26 @@ impl RoaCacheProjection { entity: "roa_cache_projection.entries[].outputs_effective_until_unix", detail, })?; - let meta = meta_by_uri - .as_ref() - .and_then(|meta| meta.get(output.source_object_uri.as_str()).copied()); - if context.is_some() && meta.is_none() { + let Some(meta) = meta_by_uri.get(output.source_object_uri.as_str()).copied() else { continue; + }; + if meta.source_object_hash != output.source_object_hash { + return Err(StorageError::InvalidData { + entity: "roa_cache_projection.entries[]", + detail: format!( + "metadata source object hash mismatch for {}", + output.source_object_uri + ), + }); } - if let Some(meta) = meta { - if meta.source_object_hash != output.source_object_hash { - return Err(StorageError::InvalidData { - entity: "roa_cache_projection.entries[]", - detail: format!( - "metadata source object hash mismatch for {}", - output.source_object_uri - ), - }); - } - } + let earliest_safe_reuse_time_unix = meta + .earliest_safe_reuse_time + .parse() + .map(|time| time.max(vcir_validation_time).unix_timestamp()) + .map_err(|detail| StorageError::InvalidData { + entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix", + detail, + })?; if let Some(entry_index) = entry_index_by_uri.get(output.source_object_uri.as_str()) { let entry = &mut entries[*entry_index]; if entry.source_object_hash != output.source_object_hash { @@ -1952,8 +2025,9 @@ impl RoaCacheProjection { entries.push(RoaCacheObjectProjection { source_object_uri: output.source_object_uri.clone(), source_object_hash: output.source_object_hash, - ee_serial: meta.map(|meta| meta.ee_serial.clone()), - crl_uri: meta.map(|meta| meta.crl_uri.clone()), + ee_serial: Some(meta.ee_serial.clone()), + crl_uri: Some(meta.crl_uri.clone()), + earliest_safe_reuse_time_unix: Some(earliest_safe_reuse_time_unix), outputs_effective_until_unix: projected_output_effective_until, outputs: vec![projected_output], }); @@ -1968,8 +2042,8 @@ impl RoaCacheProjection { manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), instance_effective_until: vcir.instance_gate.instance_effective_until.clone(), issuer_ca_sha256_hex, - parent_context_digest: context.map(|context| context.parent_context_digest), - policy_fingerprint: context.map(|context| context.policy_fingerprint), + parent_context_digest: Some(context.parent_context_digest), + policy_fingerprint: Some(context.policy_fingerprint), crl_sha256_by_uri, entries, }; @@ -2774,6 +2848,24 @@ fn write_roa_cache_projection_to_batch( Ok(()) } +fn write_vcir_failed_fetch_reuse_identity_to_batch( + failed_fetch_reuse_identity_cf: &ColumnFamily, + batch: &mut WriteBatch, + manifest_rsync_uri: &str, + failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>, +) -> StorageResult<()> { + let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri); + match failed_fetch_reuse_identity { + Some(identity) => { + identity.validate_internal()?; + let value = encode_cbor(identity, "vcir_failed_fetch_reuse_identity")?; + batch.put_cf(failed_fetch_reuse_identity_cf, key.as_bytes(), value); + } + None => batch.delete_cf(failed_fetch_reuse_identity_cf, key.as_bytes()), + } + Ok(()) +} + fn write_publication_point_cache_projection_to_batch( projection_cf: &ColumnFamily, batch: &mut WriteBatch, @@ -3267,6 +3359,19 @@ impl RocksStore { self.put_vcir_with_publication_point_cache_projection(vcir, None) } + pub fn put_vcir_with_failed_fetch_reuse_identity( + &self, + vcir: &ValidatedCaInstanceResult, + failed_fetch_reuse_identity: &VcirFailedFetchReuseIdentity, + ) -> StorageResult<()> { + self.put_vcir_with_projection_action( + vcir, + None, + PublicationPointCacheProjectionWriteAction::Keep, + Some(failed_fetch_reuse_identity), + ) + } + pub fn put_vcir_with_publication_point_cache_projection( &self, vcir: &ValidatedCaInstanceResult, @@ -3288,6 +3393,7 @@ impl RocksStore { vcir, roa_cache_context, publication_point_projection_action, + None, ) } @@ -3296,9 +3402,11 @@ impl RocksStore { vcir: &ValidatedCaInstanceResult, roa_cache_context: Option<&RoaCacheProjectionContext>, publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>, + failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>, ) -> StorageResult<()> { vcir.validate_internal()?; let vcir_cf = self.cf(CF_VCIR)?; + let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?; @@ -3308,6 +3416,12 @@ impl RocksStore { let key = vcir_key(&vcir.manifest_rsync_uri); let value = encode_cbor(vcir, "vcir")?; batch.put_cf(vcir_cf, key.as_bytes(), value); + write_vcir_failed_fetch_reuse_identity_to_batch( + failed_fetch_reuse_identity_cf, + &mut batch, + &vcir.manifest_rsync_uri, + failed_fetch_reuse_identity, + )?; let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri); let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?; batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value); @@ -3371,6 +3485,21 @@ impl RocksStore { vcir: &ValidatedCaInstanceResult, roa_cache_context: Option<&RoaCacheProjectionContext>, publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>, + ) -> StorageResult { + self.replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity( + vcir, + roa_cache_context, + publication_point_projection_action, + None, + ) + } + + pub(crate) fn replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity( + &self, + vcir: &ValidatedCaInstanceResult, + roa_cache_context: Option<&RoaCacheProjectionContext>, + publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>, + failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>, ) -> StorageResult { let mut timing = VcirReplaceTimingBreakdown { rss_before_kb: process_vm_rss_kb(), @@ -3385,6 +3514,7 @@ impl RocksStore { let batch_build_started = std::time::Instant::now(); let vcir_cf = self.cf(CF_VCIR)?; + let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?; @@ -3397,6 +3527,12 @@ impl RocksStore { timing.vcir_value_bytes = vcir_value.len() as u64; batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value); timing.rss_after_vcir_encode_kb = process_vm_rss_kb(); + write_vcir_failed_fetch_reuse_identity_to_batch( + failed_fetch_reuse_identity_cf, + &mut batch, + &vcir.manifest_rsync_uri, + failed_fetch_reuse_identity, + )?; let replay_meta_encode_started = std::time::Instant::now(); let replay_meta = ManifestReplayMeta::from_vcir(vcir); @@ -3465,6 +3601,27 @@ impl RocksStore { Ok(Some(vcir)) } + pub fn get_vcir_failed_fetch_reuse_identity( + &self, + manifest_rsync_uri: &str, + ) -> StorageResult> { + let cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?; + let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri); + let Some(bytes) = self + .db + .get_cf(cf, key.as_bytes()) + .map_err(|e| StorageError::RocksDb(e.to_string()))? + else { + return Ok(None); + }; + let identity = decode_cbor::( + &bytes, + "vcir_failed_fetch_reuse_identity", + )?; + identity.validate_internal()?; + Ok(Some(identity)) + } + pub fn get_manifest_replay_meta( &self, manifest_rsync_uri: &str, @@ -4308,11 +4465,18 @@ impl RocksStore { pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> { let vcir_cf = self.cf(CF_VCIR)?; + let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let mut batch = WriteBatch::default(); let key = vcir_key(manifest_rsync_uri); batch.delete_cf(vcir_cf, key.as_bytes()); + let failed_fetch_reuse_identity_key = + vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri); + batch.delete_cf( + failed_fetch_reuse_identity_cf, + failed_fetch_reuse_identity_key.as_bytes(), + ); let replay_key = manifest_replay_meta_key(manifest_rsync_uri); batch.delete_cf(replay_cf, replay_key.as_bytes()); let projection_key = roa_cache_projection_key(manifest_rsync_uri); diff --git a/src/storage/config.rs b/src/storage/config.rs index ffbd539..bd4d282 100644 --- a/src/storage/config.rs +++ b/src/storage/config.rs @@ -4,6 +4,7 @@ pub const CF_REPOSITORY_VIEW: &str = "repository_view"; pub const CF_RAW_BY_HASH: &str = "raw_by_hash"; pub const CF_RAW_BLOB: &str = "raw_blob"; pub const CF_VCIR: &str = "vcir"; +pub const CF_VCIR_FAILED_FETCH_REUSE_IDENTITY: &str = "vcir_failed_fetch_reuse_identity"; pub const CF_MANIFEST_REPLAY_META: &str = "manifest_replay_meta"; pub const CF_ROA_CACHE_PROJECTION: &str = "roa_cache_projection"; pub const CF_PUBLICATION_POINT_CACHE_PROJECTION: &str = "publication_point_cache_projection"; @@ -18,6 +19,7 @@ pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[ CF_RAW_BY_HASH, CF_RAW_BLOB, CF_VCIR, + CF_VCIR_FAILED_FETCH_REUSE_IDENTITY, CF_MANIFEST_REPLAY_META, CF_ROA_CACHE_PROJECTION, CF_PUBLICATION_POINT_CACHE_PROJECTION, @@ -32,6 +34,8 @@ 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 VCIR_KEY_PREFIX: &str = "vcir:"; +pub(super) const VCIR_FAILED_FETCH_REUSE_IDENTITY_KEY_PREFIX: &str = + "vcir_failed_fetch_reuse_identity:"; pub(super) const MANIFEST_REPLAY_META_KEY_PREFIX: &str = "manifest_replay_meta:"; pub(super) const ROA_CACHE_PROJECTION_KEY_PREFIX: &str = "roa_cache_projection:"; pub(super) const PUBLICATION_POINT_CACHE_PROJECTION_KEY_PREFIX: &str = diff --git a/src/storage/keys.rs b/src/storage/keys.rs index 2469963..4aa6cfb 100644 --- a/src/storage/keys.rs +++ b/src/storage/keys.rs @@ -26,6 +26,10 @@ pub(super) fn vcir_key(manifest_rsync_uri: &str) -> String { format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}") } +pub(super) fn vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri: &str) -> String { + format!("{VCIR_FAILED_FETCH_REUSE_IDENTITY_KEY_PREFIX}{manifest_rsync_uri}") +} + pub(super) fn manifest_replay_meta_key(manifest_rsync_uri: &str) -> String { format!("{MANIFEST_REPLAY_META_KEY_PREFIX}{manifest_rsync_uri}") } diff --git a/src/storage/tests.rs b/src/storage/tests.rs index 9a91998..4dabbef 100644 --- a/src/storage/tests.rs +++ b/src/storage/tests.rs @@ -284,6 +284,28 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult { } } +fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCacheProjectionContext { + let roa_output = vcir + .local_outputs + .iter() + .find(|output| { + output.output_type == VcirOutputType::Vrp + && output.source_object_type == VcirSourceObjectType::Roa + }) + .expect("sample VCIR has a ROA output"); + RoaCacheProjectionContext { + parent_context_digest: [0x31; 32], + policy_fingerprint: [0x32; 32], + object_meta: vec![RoaCacheObjectMeta { + source_object_uri: roa_output.source_object_uri.clone(), + source_object_hash: roa_output.source_object_hash, + ee_serial: vec![0x01], + crl_uri: vcir.current_crl_rsync_uri.clone(), + earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(), + }], + } +} + #[test] fn vcir_ccr_manifest_projection_validate_accepts_valid_projection() { let projection = sample_ccr_manifest_projection( @@ -332,7 +354,8 @@ fn vcir_ccr_manifest_projection_validate_rejects_invalid_fields() { #[test] fn roa_cache_projection_from_vcir_keeps_only_roa_vrp_outputs() { let vcir = sample_vcir("rsync://example.test/repo/current.mft"); - let projection = RoaCacheProjection::from_vcir(&vcir) + let context = roa_cache_projection_context(&vcir); + let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context)) .expect("projection build") .expect("projection exists"); @@ -366,7 +389,8 @@ fn roa_cache_projection_groups_multiple_outputs_by_roa_uri() { vcir.local_outputs.push(second); vcir.summary.local_vrp_count = 2; - let projection = RoaCacheProjection::from_vcir(&vcir) + let context = roa_cache_projection_context(&vcir); + let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context)) .expect("projection build") .expect("projection exists"); @@ -748,7 +772,8 @@ fn roa_cache_projection_rejects_duplicate_uri_with_different_hash() { vcir.local_outputs.push(duplicate); vcir.summary.local_vrp_count = 2; - let err = RoaCacheProjection::from_vcir(&vcir) + let context = roa_cache_projection_context(&vcir); + let err = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context)) .expect_err("same ROA URI with different hash must fail"); assert!(err.to_string().contains("source object hash mismatch")); } diff --git a/src/validation/objects.rs b/src/validation/objects.rs index afce190..e94c606 100644 --- a/src/validation/objects.rs +++ b/src/validation/objects.rs @@ -506,6 +506,7 @@ pub struct CachedRoaValidationResult { source_object_hash: [u8; 32], ee_serial: Option>, crl_uri: Option, + earliest_safe_reuse_time_unix: i64, outputs_effective_until_unix: i64, outputs: Vec, } @@ -543,6 +544,12 @@ impl RoaValidationCacheView { } for entry in &projection.entries { + let Some(earliest_safe_reuse_time_unix) = entry.earliest_safe_reuse_time_unix else { + continue; + }; + if time::OffsetDateTime::from_unix_timestamp(earliest_safe_reuse_time_unix).is_err() { + continue; + } let outputs = entry .outputs .iter() @@ -563,6 +570,7 @@ impl RoaValidationCacheView { source_object_hash: entry.source_object_hash, ee_serial: entry.ee_serial.clone(), crl_uri: entry.crl_uri.clone(), + earliest_safe_reuse_time_unix, outputs_effective_until_unix: entry.outputs_effective_until_unix, outputs, }, @@ -674,7 +682,9 @@ impl RoaValidationCacheView { if cached.outputs.is_empty() { return_entry_gate!(RoaCacheLookupResult::Miss); } - if cached.outputs_effective_until_unix <= validation_time.unix_timestamp() { + if validation_time.unix_timestamp() < cached.earliest_safe_reuse_time_unix + || cached.outputs_effective_until_unix <= validation_time.unix_timestamp() + { return_entry_gate!(RoaCacheLookupResult::ExpiredBlocked); } @@ -760,6 +770,10 @@ impl RoaValidationCacheView { source_object_hash: file.sha256, ee_serial: ee_serial.clone(), crl_uri: crl_uri.to_string(), + earliest_safe_reuse_time: PackTime::from_utc_offset_datetime( + time::OffsetDateTime::from_unix_timestamp(cached.earliest_safe_reuse_time_unix) + .expect("cached ROA safe reuse time must be valid"), + ), }), }; metrics.materialize_nanos = metrics @@ -821,6 +835,14 @@ fn crl_valid_at_time( validation_time >= this_update && validation_time < next_update } +fn roa_cache_earliest_safe_reuse_time( + ee_not_before: time::OffsetDateTime, + crl_this_update: time::OffsetDateTime, + validation_time: time::OffsetDateTime, +) -> PackTime { + PackTime::from_utc_offset_datetime(ee_not_before.max(crl_this_update).max(validation_time)) +} + fn record_roa_cache_block( stats: &mut RoaValidationCacheStats, lookup_result: &RoaCacheLookupResult, @@ -2736,6 +2758,11 @@ fn process_roa_with_issuer( source_object_hash: file.sha256, ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be, crl_uri: issuer_crl_rsync_uri.to_string(), + earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time( + ee.resource_cert.tbs.validity_not_before, + verified_crl.crl.this_update.utc, + validation_time, + ), }; if !collect_vcir_local_outputs { return Ok((vrps, Vec::new(), Some(cache_object_meta))); @@ -2866,6 +2893,11 @@ fn process_roa_with_issuer_parallel_cached( source_object_hash: file.sha256, ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be, crl_uri: issuer_crl_rsync_uri.clone(), + earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time( + ee.resource_cert.tbs.validity_not_before, + verified_crl.crl.this_update.utc, + validation_time, + ), }; if !collect_vcir_local_outputs { return Ok((vrps, Vec::new(), Some(cache_object_meta))); @@ -3908,6 +3940,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: vec![0x01], crl_uri: TEST_CRL_URI.to_string(), + earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(), }], }), ) @@ -4299,7 +4332,7 @@ mod tests { issuer_der, crl_hash, roa_hash, - fixed_time("2026-06-04T00:00:00Z"), + fixed_time("2026-06-04T01:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let projection = sample_roa_cache_projection(&vcir, roa_hash); @@ -4594,6 +4627,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: Some(vec![0x01]), crl_uri: None, + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: vec![output.clone()], }, @@ -4610,6 +4644,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: None, crl_uri: Some(TEST_CRL_URI.to_string()), + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: vec![output.clone()], }, @@ -4626,6 +4661,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: Some(vec![0x01]), crl_uri: Some(TEST_CRL_URI.to_string()), + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: vec![output.clone()], }, @@ -4652,6 +4688,7 @@ mod tests { source_object_hash: [0xff; 32], ee_serial: Some(vec![0x01]), crl_uri: Some(TEST_CRL_URI.to_string()), + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: vec![output.clone()], }, @@ -4668,6 +4705,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: Some(vec![0x01]), crl_uri: Some(TEST_CRL_URI.to_string()), + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: Vec::new(), }, @@ -4688,6 +4726,7 @@ mod tests { source_object_hash: roa_hash, ee_serial: Some(vec![0x01]), crl_uri: Some(TEST_CRL_URI.to_string()), + earliest_safe_reuse_time_unix: validation_time.unix_timestamp(), outputs_effective_until_unix, outputs: vec![output], }, diff --git a/src/validation/tree_runner.rs b/src/validation/tree_runner.rs index c1ffd3e..beacade 100644 --- a/src/validation/tree_runner.rs +++ b/src/validation/tree_runner.rs @@ -33,8 +33,9 @@ use crate::storage::{ PublicationPointCacheProjectionWriteAction, RawByHashEntry, RoaCacheProjectionContext, RocksStore, ValidatedCaInstanceResult, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, VcirCcrManifestProjection, VcirChildEntry, - VcirInstanceGate, VcirLocalOutput, VcirLocalOutputPayload, VcirOutputType, VcirRelatedArtifact, - VcirReplaceTimingBreakdown, VcirSourceObjectType, VcirSummary, + VcirFailedFetchReuseIdentity, VcirInstanceGate, VcirLocalOutput, VcirLocalOutputPayload, + VcirOutputType, VcirRelatedArtifact, VcirReplaceTimingBreakdown, VcirSourceObjectType, + VcirSummary, }; use crate::sync::repo::{ sync_publication_point, sync_publication_point_replay, sync_publication_point_replay_delta, @@ -1751,6 +1752,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> { self.store, ca, &fresh_err, + self.policy, self.validation_time, ) .map_err(|e| format!("failed fetch VCIR projection failed: {e}"))?; @@ -4256,6 +4258,7 @@ fn project_current_instance_vcir_on_failed_fetch( store: &RocksStore, ca: &CaInstanceHandle, fresh_err: &ManifestFreshError, + policy: &Policy, validation_time: time::OffsetDateTime, ) -> Result { let mut warnings = Vec::new(); @@ -4264,80 +4267,57 @@ fn project_current_instance_vcir_on_failed_fetch( .get_vcir(&ca.manifest_rsync_uri) .map_err(|e| format!("load VCIR failed: {e}"))? else { - warnings.push( - Warning::new(format!("manifest failed fetch: {fresh_err}")) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - warnings.push( - Warning::new( - "no latest validated result for current CA instance; no cached output reused", - ) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - return Ok(VcirReuseProjection { - source: PublicationPointSource::FailedFetchNoCache, - vcir: None, - ccr_manifest_projection: None, - snapshot: None, - objects: empty_objects_output(), - child_audits: Vec::new(), - discovered_children: Vec::new(), - warnings, - }); + return Ok(failed_fetch_no_cache_projection( + ca, + fresh_err, + None, + "no latest validated result for current CA instance; no cached output reused", + )); }; if !vcir.audit_summary.failed_fetch_eligible { - warnings.push( - Warning::new(format!("manifest failed fetch: {fresh_err}")) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - warnings.push( - Warning::new( - "latest VCIR is not marked failed-fetch eligible; no cached output reused", - ) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - return Ok(VcirReuseProjection { - source: PublicationPointSource::FailedFetchNoCache, - vcir: Some(vcir), - ccr_manifest_projection: None, - snapshot: None, - objects: empty_objects_output(), - child_audits: Vec::new(), - discovered_children: Vec::new(), - warnings, - }); + return Ok(failed_fetch_no_cache_projection( + ca, + fresh_err, + Some(vcir), + "latest VCIR is not marked failed-fetch eligible; no cached output reused", + )); } - let instance_effective_until = - parse_snapshot_time_value(&vcir.instance_gate.instance_effective_until)?; - if validation_time > instance_effective_until { - warnings.push( - Warning::new(format!("manifest failed fetch: {fresh_err}")) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - warnings.push( - Warning::new( - "latest VCIR instance_gate expired; current instance contributes no cached output", - ) - .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) - .with_context(&ca.manifest_rsync_uri), - ); - return Ok(VcirReuseProjection { - source: PublicationPointSource::FailedFetchNoCache, - vcir: Some(vcir), - ccr_manifest_projection: None, - snapshot: None, - objects: empty_objects_output(), - child_audits: Vec::new(), - discovered_children: Vec::new(), - warnings, - }); + let reuse_identity = match store.get_vcir_failed_fetch_reuse_identity(&ca.manifest_rsync_uri) { + Ok(Some(identity)) => identity, + Ok(None) => { + return Ok(failed_fetch_no_cache_projection( + ca, + fresh_err, + Some(vcir), + "latest VCIR reuse identity is missing; no cached output or child reused", + )); + } + Err(error) => { + return Ok(failed_fetch_no_cache_projection( + ca, + fresh_err, + Some(vcir), + &format!( + "latest VCIR reuse identity is invalid ({error}); no cached output or child reused" + ), + )); + } + }; + if !failed_fetch_reuse_identity_matches_current( + &reuse_identity, + &vcir, + ca, + policy, + validation_time, + ) { + return Ok(failed_fetch_no_cache_projection( + ca, + fresh_err, + Some(vcir), + "latest VCIR reuse identity does not match the current CA context or time window; no cached output or child reused", + )); } let ccr_manifest_projection = reuse_ccr_manifest_projection_from_vcir(ca, &vcir)?; @@ -4367,6 +4347,73 @@ fn project_current_instance_vcir_on_failed_fetch( }) } +fn failed_fetch_no_cache_projection( + ca: &CaInstanceHandle, + fresh_err: &ManifestFreshError, + vcir: Option, + reason: &str, +) -> VcirReuseProjection { + let warnings = vec![ + Warning::new(format!("manifest failed fetch: {fresh_err}")) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + Warning::new(reason) + .with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) + .with_context(&ca.manifest_rsync_uri), + ]; + VcirReuseProjection { + source: PublicationPointSource::FailedFetchNoCache, + vcir, + ccr_manifest_projection: None, + snapshot: None, + objects: empty_objects_output(), + child_audits: Vec::new(), + discovered_children: Vec::new(), + warnings, + } +} + +fn failed_fetch_reuse_identity_for_fresh_result( + ca: &CaInstanceHandle, + policy: &Policy, + validation_time: time::OffsetDateTime, + effective_until: PackTime, +) -> Result { + let current_ca_sha256 = ca.ca_certificate_sha256_32().ok_or_else(|| { + "current CA certificate hash unavailable for VCIR reuse identity".to_string() + })?; + let identity = VcirFailedFetchReuseIdentity { + current_ca_sha256, + ta_context_digest: ta_context_digest_for_ca(ca), + parent_context_digest: parent_context_digest_for_ca(ca), + policy_fingerprint: publication_point_cache_policy_fingerprint(policy), + effective_not_before: PackTime::from_utc_offset_datetime(validation_time), + effective_until, + }; + identity + .validate_internal() + .map_err(|error| error.to_string())?; + Ok(identity) +} + +fn failed_fetch_reuse_identity_matches_current( + cached: &VcirFailedFetchReuseIdentity, + vcir: &ValidatedCaInstanceResult, + ca: &CaInstanceHandle, + policy: &Policy, + validation_time: time::OffsetDateTime, +) -> bool { + let Some(current_ca_sha256) = ca.ca_certificate_sha256_32() else { + return false; + }; + cached.current_ca_sha256 == current_ca_sha256 + && cached.ta_context_digest == ta_context_digest_for_ca(ca) + && cached.parent_context_digest == parent_context_digest_for_ca(ca) + && cached.policy_fingerprint == publication_point_cache_policy_fingerprint(policy) + && cached.effective_until == vcir.instance_gate.instance_effective_until + && cached.contains_validation_time(validation_time) +} + #[cfg(test)] fn reconstruct_snapshot_from_vcir( store: &RocksStore, @@ -5280,8 +5327,14 @@ fn persist_vcir_for_fresh_result_with_timing( .expect("publication point projection must exist when guard is not active"), ) }; + let failed_fetch_reuse_identity = failed_fetch_reuse_identity_for_fresh_result( + ca, + policy, + validation_time, + vcir.instance_gate.instance_effective_until.clone(), + )?; let replace_timing = store - .replace_vcir_manifest_replay_meta_and_projection_action( + .replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity( &vcir, Some(&RoaCacheProjectionContext { parent_context_digest: parent_context_digest_for_ca(ca), @@ -5289,6 +5342,7 @@ fn persist_vcir_for_fresh_result_with_timing( object_meta: objects.roa_cache_object_meta.clone(), }), publication_point_cache_projection_action, + Some(&failed_fetch_reuse_identity), ) .map_err(|e| format!("store VCIR and manifest replay meta failed: {e}"))?; timing.replace_vcir_ms = replace_vcir_started.elapsed().as_millis() as u64; diff --git a/src/validation/tree_runner/tests.rs b/src/validation/tree_runner/tests.rs index db84613..4ea534b 100644 --- a/src/validation/tree_runner/tests.rs +++ b/src/validation/tree_runner/tests.rs @@ -753,6 +753,28 @@ fn sample_vcir_for_projection( } } +fn put_vcir_for_failed_fetch_reuse( + store: &RocksStore, + ca: &CaInstanceHandle, + policy: &Policy, + vcir: &ValidatedCaInstanceResult, +) { + let validation_time = vcir + .last_successful_validation_time + .parse() + .expect("parse VCIR validation time"); + let identity = failed_fetch_reuse_identity_for_fresh_result( + ca, + policy, + validation_time, + vcir.instance_gate.instance_effective_until.clone(), + ) + .expect("build VCIR failed-fetch reuse identity"); + store + .put_vcir_with_failed_fetch_reuse_identity(vcir, &identity) + .expect("put reusable VCIR"); +} + #[test] fn never_http_fetcher_returns_error() { let f = NeverHttpFetcher; @@ -3827,7 +3849,6 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() { let repo_bytes_db = store_dir.path().join("repo-bytes.db"); let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db) .expect("open rocksdb with external repo bytes"); - store.put_vcir(&vcir).expect("put vcir"); store .put_blob_bytes_batch(&[(child_cert_hash.clone(), g.child_ca_der.clone())]) .expect("put child cert repo bytes"); @@ -3852,6 +3873,8 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() { publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()), }; + let policy = Policy::default(); + put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir); let projection = project_current_instance_vcir_on_failed_fetch( &store, @@ -3859,6 +3882,7 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() { &ManifestFreshError::RepoSyncFailed { detail: "synthetic".to_string(), }, + &policy, now, ) .expect("project vcir"); @@ -3950,6 +3974,7 @@ fn project_current_instance_vcir_returns_no_output_when_instance_gate_expired() &ManifestFreshError::RepoSyncFailed { detail: "synthetic".to_string(), }, + &Policy::default(), now, ) .expect("project vcir"); @@ -3975,7 +4000,6 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() { let repo_bytes_db = store_dir.path().join("repo-bytes.db"); let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db) .expect("open rocksdb with external repo bytes"); - store.put_vcir(&vcir).expect("put vcir"); store .put_blob_bytes_batch(&[(child_cert_hash, b"child-cert".to_vec())]) .expect("put child cert repo bytes"); @@ -3993,6 +4017,8 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() { publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), rrdp_notification_uri: None, }; + let policy = Policy::default(); + put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir); let projection = project_current_instance_vcir_on_failed_fetch( &store, @@ -4000,6 +4026,7 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() { &ManifestFreshError::HashMismatch { rsync_uri: "rsync://example.test/repo/issuer/a.roa".to_string(), }, + &policy, now, ) .expect("project vcir"); @@ -4048,6 +4075,7 @@ fn project_current_instance_vcir_returns_no_output_when_latest_result_missing() &ManifestFreshError::RepoSyncFailed { detail: "synthetic".to_string(), }, + &Policy::default(), now, ) .expect("project without cached vcir"); @@ -4099,6 +4127,7 @@ fn project_current_instance_vcir_returns_no_output_when_latest_result_is_ineligi &ManifestFreshError::RepoSyncFailed { detail: "synthetic".to_string(), }, + &Policy::default(), now, ) .expect("project ineligible vcir"); @@ -4128,7 +4157,6 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() { let store_dir = tempfile::tempdir().expect("store dir"); let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); - store.put_vcir(&vcir).expect("put vcir"); let ca = CaInstanceHandle { depth: 0, @@ -4143,6 +4171,8 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() { publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), rrdp_notification_uri: None, }; + let policy = Policy::default(); + put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir); let err = project_current_instance_vcir_on_failed_fetch( &store, @@ -4150,6 +4180,7 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() { &ManifestFreshError::RepoSyncFailed { detail: "synthetic".to_string(), }, + &policy, now, ) .unwrap_err();