diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ce5509..7e93a263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Credential-safe verified action-outcome evidence that binds action kind, canonical target origin, complete action-intent digest, bounded browser post-condition kind, same-clock dispatch and observation timestamps, and exact provenance; construction rejects unverified or rejected post-conditions and any observation timestamp that predates action dispatch while allowing equal coarse-clock ticks. +- Credential-safe structured-value evidence that binds a bounded field identifier and lowercase SHA-256 value digest to one exact OriginWeave node plus independently verified same-origin DOM/accessibility and network-response provenance, without carrying the raw extracted value. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 2839fd6b..6e41d7dd 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -21,7 +21,7 @@ pub use sensitive_access::{ use std::collections::BTreeMap; -use originweave_core::Origin; +use originweave_core::{ObservedNodeHandle, Origin}; const REDACTED: &str = "[REDACTED]"; @@ -37,6 +37,8 @@ pub const MAX_METADATA_NAME_BYTES: usize = 256; pub const MAX_METADATA_VALUE_BYTES: usize = 8_192; /// Maximum source URL or source-locator size retained in provenance metadata. pub const MAX_PROVENANCE_TEXT_BYTES: usize = 8_192; +/// Maximum byte length of one structured extracted-field identifier. +pub const MAX_STRUCTURED_FIELD_NAME_BYTES: usize = 128; /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -365,3 +367,137 @@ fn valid_sha256(source_hash: &str) -> bool { .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } + +/// A credential-safe proof bundle for one extracted structured value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StructuredValueEvidence { + field_name: String, + value_hash: String, + source_node: ObservedNodeHandle, + node_provenance: ProvenanceRecord, + network_provenance: ProvenanceRecord, +} + +impl StructuredValueEvidence { + /// Bind one structured field digest to exact node and network provenance. + pub fn new( + field_name: &str, + value_hash: &str, + source_node: ObservedNodeHandle, + node_provenance: ProvenanceRecord, + network_provenance: ProvenanceRecord, + ) -> Result { + if !valid_structured_field_name(field_name) { + return Err(StructuredValueEvidenceError::InvalidFieldName); + } + if !valid_sha256(value_hash) { + return Err(StructuredValueEvidenceError::InvalidValueHash); + } + if node_provenance.verification_result() != VerificationResult::Verified { + return Err(StructuredValueEvidenceError::NodeProvenanceNotVerified); + } + if !matches!( + node_provenance.source_kind(), + EvidenceSourceKind::DomTree | EvidenceSourceKind::AccessibilityTree + ) { + return Err(StructuredValueEvidenceError::NodeProvenanceKindMismatch); + } + if network_provenance.verification_result() != VerificationResult::Verified { + return Err(StructuredValueEvidenceError::NetworkProvenanceNotVerified); + } + if network_provenance.source_kind() != EvidenceSourceKind::NetworkResponse { + return Err(StructuredValueEvidenceError::NetworkProvenanceKindMismatch); + } + if node_provenance.source_origin() != source_node.origin() + || network_provenance.source_origin() != source_node.origin() + { + return Err(StructuredValueEvidenceError::SourceOriginMismatch); + } + Ok(Self { + field_name: field_name.to_owned(), + value_hash: value_hash.to_owned(), + source_node, + node_provenance, + network_provenance, + }) + } + + /// Return the bounded structured field identifier. + #[must_use] + pub fn field_name(&self) -> &str { + &self.field_name + } + + /// Return the lowercase SHA-256 digest of the canonical extracted value bytes. + #[must_use] + pub fn value_hash(&self) -> &str { + &self.value_hash + } + + /// Return the exact OriginWeave-owned source node. + #[must_use] + pub const fn source_node(&self) -> &ObservedNodeHandle { + &self.source_node + } + + /// Return the independently verified DOM/accessibility provenance for the source node. + #[must_use] + pub const fn node_provenance(&self) -> &ProvenanceRecord { + &self.node_provenance + } + + /// Return the independently verified network provenance associated with the value. + #[must_use] + pub const fn network_provenance(&self) -> &ProvenanceRecord { + &self.network_provenance + } +} + +/// A fail-closed reason why structured extraction evidence could not be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuredValueEvidenceError { + /// The structured field identifier was empty, oversized, or contained unsupported bytes. + InvalidFieldName, + /// The value digest was not a canonical lowercase SHA-256 identifier. + InvalidValueHash, + /// The node provenance did not carry independent verified status. + NodeProvenanceNotVerified, + /// The node provenance was not a DOM or accessibility observation. + NodeProvenanceKindMismatch, + /// The network provenance did not carry independent verified status. + NetworkProvenanceNotVerified, + /// The network provenance was not a structured network-response observation. + NetworkProvenanceKindMismatch, + /// Node or network provenance belonged to a different canonical origin than the source node. + SourceOriginMismatch, +} + +impl std::fmt::Display for StructuredValueEvidenceError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidFieldName => "invalid structured field name", + Self::InvalidValueHash => "invalid structured value hash", + Self::NodeProvenanceNotVerified => "node provenance is not verified", + Self::NodeProvenanceKindMismatch => { + "node provenance is not DOM or accessibility evidence" + } + Self::NetworkProvenanceNotVerified => "network provenance is not verified", + Self::NetworkProvenanceKindMismatch => { + "network provenance is not network-response evidence" + } + Self::SourceOriginMismatch => "provenance origin does not match source node origin", + }) + } +} + +impl std::error::Error for StructuredValueEvidenceError {} + +fn valid_structured_field_name(field_name: &str) -> bool { + if field_name.is_empty() || field_name.len() > MAX_STRUCTURED_FIELD_NAME_BYTES { + return false; + } + field_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + && field_name.bytes().any(|byte| byte.is_ascii_alphanumeric()) +} diff --git a/crates/originweave-evidence/tests/structured_value_provenance.rs b/crates/originweave-evidence/tests/structured_value_provenance.rs new file mode 100644 index 00000000..c5a28ba9 --- /dev/null +++ b/crates/originweave-evidence/tests/structured_value_provenance.rs @@ -0,0 +1,357 @@ +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, +}; +use originweave_evidence::{ + EvidenceSourceKind, MAX_STRUCTURED_FIELD_NAME_BYTES, ProvenanceRecord, StructuredValueEvidence, + StructuredValueEvidenceError, VerificationResult, +}; + +const VALID_VALUE_HASH: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VALID_SOURCE_HASH: &str = + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn node(source_origin: &Origin) -> Result { + ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + source_origin.clone(), + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string()) +} + +fn provenance( + source_url: &str, + locator: &str, + source_kind: EvidenceSourceKind, + verification_result: VerificationResult, +) -> Result { + ProvenanceRecord::new( + source_url, + locator, + VALID_SOURCE_HASH, + source_kind, + verification_result, + ) + .map_err(|error| format!("{error:?}")) +} + +fn valid_evidence() -> Result { + let source_origin = origin("https://app.example")?; + StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/form", + "ax:submit-result", + EvidenceSourceKind::AccessibilityTree, + VerificationResult::Verified, + )?, + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn structured_value_binds_digest_exact_node_and_verified_network_source() -> Result<(), String> { + let evidence = valid_evidence()?; + + assert_eq!(evidence.field_name(), "task_status"); + assert_eq!(evidence.value_hash(), VALID_VALUE_HASH); + assert_eq!(evidence.source_node().node_id(), 17); + assert_eq!( + evidence.node_provenance().source_kind(), + EvidenceSourceKind::AccessibilityTree + ); + assert_eq!( + evidence.network_provenance().source_kind(), + EvidenceSourceKind::NetworkResponse + ); + assert_eq!( + evidence.node_provenance().verification_result(), + VerificationResult::Verified + ); + assert_eq!( + evidence.network_provenance().verification_result(), + VerificationResult::Verified + ); + Ok(()) +} + +#[test] +fn malformed_field_identifiers_fail_closed() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + let network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + for field_name in ["", "---", "task status", "task/status"] { + let error = StructuredValueEvidence::new( + field_name, + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + network_source.clone(), + ) + .err() + .ok_or_else(|| format!("invalid field name unexpectedly accepted: {field_name:?}"))?; + assert_eq!(error, StructuredValueEvidenceError::InvalidFieldName); + } + + let oversized = "a".repeat(MAX_STRUCTURED_FIELD_NAME_BYTES + 1); + let error = StructuredValueEvidence::new( + &oversized, + VALID_VALUE_HASH, + node(&source_origin)?, + node_source, + network_source, + ) + .err() + .ok_or_else(|| "oversized field name unexpectedly accepted".to_owned())?; + assert_eq!(error, StructuredValueEvidenceError::InvalidFieldName); + Ok(()) +} + +#[test] +fn malformed_value_digest_fails_closed() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let error = StructuredValueEvidence::new( + "task_status", + "sha256:ABCDEF", + node(&source_origin)?, + provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "malformed value digest unexpectedly accepted".to_owned())?; + + assert_eq!(error, StructuredValueEvidenceError::InvalidValueHash); + Ok(()) +} + +#[test] +fn node_provenance_must_be_verified_node_evidence() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + let unverified_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/form", + "ax:submit-result", + EvidenceSourceKind::AccessibilityTree, + VerificationResult::Unverified, + )?, + network_source.clone(), + ) + .err() + .ok_or_else(|| "unverified node provenance unexpectedly accepted".to_owned())?; + assert_eq!( + unverified_error, + StructuredValueEvidenceError::NodeProvenanceNotVerified + ); + + let wrong_kind_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/api/task", + "response:other", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + network_source, + ) + .err() + .ok_or_else(|| "network evidence unexpectedly accepted as node provenance".to_owned())?; + assert_eq!( + wrong_kind_error, + StructuredValueEvidenceError::NodeProvenanceKindMismatch + ); + Ok(()) +} + +#[test] +fn network_provenance_must_be_verified_network_evidence() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + + let unverified_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Rejected, + )?, + ) + .err() + .ok_or_else(|| "rejected network provenance unexpectedly accepted".to_owned())?; + assert_eq!( + unverified_error, + StructuredValueEvidenceError::NetworkProvenanceNotVerified + ); + + let wrong_kind_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + provenance( + "https://app.example/form", + "dom:#network-lookalike", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "DOM evidence unexpectedly accepted as network provenance".to_owned())?; + assert_eq!( + wrong_kind_error, + StructuredValueEvidenceError::NetworkProvenanceKindMismatch + ); + Ok(()) +} + +#[test] +fn provenance_from_another_origin_cannot_prove_the_node_value() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let source_node = node(&source_origin)?; + let valid_node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + let valid_network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + let node_origin_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + source_node.clone(), + provenance( + "https://other.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + valid_network_source.clone(), + ) + .err() + .ok_or_else(|| "cross-origin node provenance unexpectedly accepted".to_owned())?; + assert_eq!( + node_origin_error, + StructuredValueEvidenceError::SourceOriginMismatch + ); + + let network_origin_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + source_node, + valid_node_source, + provenance( + "https://other.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "cross-origin network provenance unexpectedly accepted".to_owned())?; + assert_eq!( + network_origin_error, + StructuredValueEvidenceError::SourceOriginMismatch + ); + Ok(()) +} + +#[test] +fn structured_value_errors_are_stable_and_source_free() { + let cases = [ + ( + StructuredValueEvidenceError::InvalidFieldName, + "invalid structured field name", + ), + ( + StructuredValueEvidenceError::InvalidValueHash, + "invalid structured value hash", + ), + ( + StructuredValueEvidenceError::NodeProvenanceNotVerified, + "node provenance is not verified", + ), + ( + StructuredValueEvidenceError::NodeProvenanceKindMismatch, + "node provenance is not DOM or accessibility evidence", + ), + ( + StructuredValueEvidenceError::NetworkProvenanceNotVerified, + "network provenance is not verified", + ), + ( + StructuredValueEvidenceError::NetworkProvenanceKindMismatch, + "network provenance is not network-response evidence", + ), + ( + StructuredValueEvidenceError::SourceOriginMismatch, + "provenance origin does not match source node origin", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(std::error::Error::source(&error).is_none()); + } +}