diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 0ca683c0..eb443b87 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -9,6 +9,7 @@ mod capture_manifest; mod extraction_schema; +mod offline_replay; mod sensitive_access; mod warc_prov_bundle; mod warc_resource_record; @@ -23,6 +24,9 @@ pub use extraction_schema::{ ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, }; +pub use offline_replay::{ + OfflineReplayVerification, OfflineReplayVerificationError, verify_offline_capture_package, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, diff --git a/crates/originweave-evidence/src/offline_replay.rs b/crates/originweave-evidence/src/offline_replay.rs new file mode 100644 index 00000000..83407064 --- /dev/null +++ b/crates/originweave-evidence/src/offline_replay.rs @@ -0,0 +1,95 @@ +use std::fmt; + +use crate::{ + CaptureManifest, CaptureManifestValueBinding, CaptureManifestVerificationError, + ExtractionSchema, WarcProvBundle, WarcResourceRecord, +}; + +/// Credential-safe receipt proving one in-memory capture package matched its persisted identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfflineReplayVerification { + manifest_digest: String, + record_count: usize, + value_count: usize, +} + +impl OfflineReplayVerification { + /// Return the SHA-256 identity of the exact deterministic capture manifest. + #[must_use] + pub fn manifest_digest(&self) -> &str { + &self.manifest_digest + } + + /// Return the number of WARC/PROV record pairs verified by this receipt. + #[must_use] + pub const fn record_count(&self) -> usize { + self.record_count + } + + /// Return the number of schema-bound structured-value identities verified by this receipt. + #[must_use] + pub const fn value_count(&self) -> usize { + self.value_count + } +} + +/// A fail-closed offline capture-package verification failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfflineReplayVerificationError { + /// Persisted manifest bytes were not the exact deterministic serialization expected in memory. + ManifestBytes(CaptureManifestVerificationError), + /// Schema, WARC/PROV evidence, or structured-value identity did not match the expected manifest. + Evidence(CaptureManifestVerificationError), +} + +impl fmt::Display for OfflineReplayVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ManifestBytes(error) => write!( + formatter, + "offline replay persisted manifest bytes failed verification: {error}" + ), + Self::Evidence(error) => write!( + formatter, + "offline replay capture evidence failed verification: {error}" + ), + } + } +} + +impl std::error::Error for OfflineReplayVerificationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ManifestBytes(error) | Self::Evidence(error) => Some(error), + } + } +} + +/// Verify one already-materialized capture package without contacting or executing its source. +/// +/// Verification first requires `persisted_manifest_bytes` to equal the expected deterministic +/// manifest serialization byte-for-byte. It then reconstructs and verifies the schema-bound +/// WARC/PROV/value identity through [`CaptureManifest::verify_with_warc_values`]. The operation is +/// deliberately in-memory only: it performs no DNS, network, browser, JavaScript, external-reference, +/// secret, persistence, retention, signing, or authorization action and does not establish factual +/// correctness beyond the supplied evidence contracts. +pub fn verify_offline_capture_package( + expected_manifest: &CaptureManifest, + persisted_manifest_bytes: &[u8], + schema: &ExtractionSchema, + records: &[(&WarcResourceRecord, &WarcProvBundle)], + values: &[CaptureManifestValueBinding], +) -> Result { + expected_manifest + .verify_serialized_json(persisted_manifest_bytes) + .map_err(OfflineReplayVerificationError::ManifestBytes)?; + expected_manifest + .verify_with_warc_values(schema, records, values) + .map_err(OfflineReplayVerificationError::Evidence)?; + + Ok(OfflineReplayVerification { + manifest_digest: expected_manifest.manifest_digest(), + record_count: expected_manifest.records().len(), + value_count: expected_manifest.values().len(), + }) +} diff --git a/crates/originweave-evidence/tests/offline_capture_replay.rs b/crates/originweave-evidence/tests/offline_capture_replay.rs new file mode 100644 index 00000000..d432fcab --- /dev/null +++ b/crates/originweave-evidence/tests/offline_capture_replay.rs @@ -0,0 +1,195 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + CaptureManifest, CaptureManifestError, CaptureManifestValueBinding, + CaptureManifestVerificationError, EvidenceSourceKind, ExtractionCardinality, ExtractionField, + ExtractionSchema, ExtractionSourceChannel, ExtractionValueType, OfflineReplayVerificationError, + ProvenanceRecord, VerificationResult, WarcProvBundle, WarcResourceRecord, + verify_offline_capture_package, +}; + +const SOURCE_HASH: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VALUE_HASH: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DRIFTED_VALUE_HASH: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const RECORD_ID: &str = "urn:uuid:123e4567-e89b-12d3-a456-426614174000"; +const DATE: &str = "2026-08-26T00:00:00Z"; +const SOFTWARE_COMMIT_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn schema() -> ExtractionSchema { + let title = ExtractionField::new( + "title", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::NetworkResponse], + ) + .expect("title field"); + ExtractionSchema::new("catalog-v3", vec![title]).expect("schema") +} + +fn resource_record() -> WarcResourceRecord { + let provenance = ProvenanceRecord::new( + "https://example.com/item", + "body", + SOURCE_HASH, + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + ) + .expect("verified provenance"); + WarcResourceRecord::new( + RECORD_ID, + DATE, + "https://example.com/item", + "text/plain", + b"captured-payload".to_vec(), + provenance, + ) + .expect("WARC record") +} + +#[test] +fn offline_replay_verifies_exact_manifest_evidence_and_structured_result() { + let schema = schema(); + let record = resource_record(); + let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle"); + let value = + CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding"); + let manifest = CaptureManifest::new_with_warc_values( + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ) + .expect("manifest"); + let serialized_manifest = manifest.to_json(); + + let verification = verify_offline_capture_package( + &manifest, + serialized_manifest.as_bytes(), + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ) + .expect("offline replay verification"); + + assert_eq!(verification.manifest_digest(), manifest.manifest_digest()); + assert_eq!(verification.record_count(), 1); + assert_eq!(verification.value_count(), 1); +} + +#[test] +fn offline_replay_rejects_persisted_manifest_byte_drift_before_evidence_replay() { + let schema = schema(); + let record = resource_record(); + let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle"); + let value = + CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding"); + let manifest = CaptureManifest::new_with_warc_values( + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ) + .expect("manifest"); + let mut serialized_manifest = manifest.to_json().into_bytes(); + serialized_manifest.push(b' '); + + assert_eq!( + verify_offline_capture_package( + &manifest, + &serialized_manifest, + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ), + Err(OfflineReplayVerificationError::ManifestBytes( + CaptureManifestVerificationError::IdentityMismatch, + )) + ); +} + +#[test] +fn offline_replay_rejects_structured_result_identity_drift() { + let schema = schema(); + let record = resource_record(); + let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle"); + let value = + CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding"); + let drifted_value = CaptureManifestValueBinding::new("title", DRIFTED_VALUE_HASH, RECORD_ID) + .expect("drifted value binding"); + let manifest = CaptureManifest::new_with_warc_values( + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ) + .expect("manifest"); + let serialized_manifest = manifest.to_json(); + + assert_eq!( + verify_offline_capture_package( + &manifest, + serialized_manifest.as_bytes(), + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&drifted_value), + ), + Err(OfflineReplayVerificationError::Evidence( + CaptureManifestVerificationError::IdentityMismatch, + )) + ); +} + +#[test] +fn offline_replay_rejects_missing_warc_evidence() { + let schema = schema(); + let record = resource_record(); + let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle"); + let value = + CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding"); + let manifest = CaptureManifest::new_with_warc_values( + &schema, + &[(&record, &bundle)], + std::slice::from_ref(&value), + ) + .expect("manifest"); + let serialized_manifest = manifest.to_json(); + + assert_eq!( + verify_offline_capture_package( + &manifest, + serialized_manifest.as_bytes(), + &schema, + &[], + std::slice::from_ref(&value), + ), + Err(OfflineReplayVerificationError::Evidence( + CaptureManifestVerificationError::InvalidCandidate(CaptureManifestError::MissingRecord), + )) + ); +} + +#[test] +fn offline_replay_errors_preserve_typed_diagnostics_and_sources() { + let manifest_error = OfflineReplayVerificationError::ManifestBytes( + CaptureManifestVerificationError::IdentityMismatch, + ); + let evidence_error = OfflineReplayVerificationError::Evidence( + CaptureManifestVerificationError::IdentityMismatch, + ); + + assert_eq!( + manifest_error.to_string(), + "offline replay persisted manifest bytes failed verification: capture manifest identity does not match" + ); + assert_eq!( + evidence_error.to_string(), + "offline replay capture evidence failed verification: capture manifest identity does not match" + ); + assert_eq!( + std::error::Error::source(&manifest_error).map(ToString::to_string), + Some("capture manifest identity does not match".to_owned()) + ); + assert_eq!( + std::error::Error::source(&evidence_error).map(ToString::to_string), + Some("capture manifest identity does not match".to_owned()) + ); +}