diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8155383..811a93d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Explicit `BrowserAuthorityRegistry::bind_context_origin` registration that binds one canonical origin to the exact current browser session, browsing context, and document epoch before origin-sensitive protocol use, rejecting cross-session ownership and same-epoch origin changes without granting navigation or action authority. +- Explicit `BrowserAuthorityRegistry::require_context_origin` revalidation that requires the exact registered canonical origin for the current browser session, browsing context, and document epoch before origin-sensitive protocol use, failing closed when binding is absent or mismatched without granting navigation or action authority. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4ec84522b..477117e4e 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -227,6 +227,34 @@ impl BrowserAuthorityRegistry { Ok(epoch) } + /// Revalidate the canonical origin bound to the exact current browser document. + /// + /// This read-only immediate-use boundary lets a trusted browser adapter prove that the exact + /// OriginWeave session/context still has the expected canonical origin in its current document + /// epoch. It fails closed when the current document has no origin binding, including directly + /// after [`Self::advance_document`], and rejects a different origin without mutating registry + /// state. The returned epoch is descriptive current state, not a reusable capability. + /// + /// This method does not authenticate the adapter or browser process, derive the current origin + /// from Chromium, authorize a destination or action, perform browser I/O, or attest that the + /// caller-supplied origin came from the running browser. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + let expected_origin = self + .context_origin + .get(&browsing_context) + .ok_or(BrowserRegistryError::ContextOriginNotBound)?; + if expected_origin != origin { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Ok(epoch) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. /// /// Call this whenever navigation or document replacement invalidates actionable node identity. @@ -323,6 +351,8 @@ pub enum BrowserRegistryError { /// Session supplied by the current caller. actual: BrowserSessionId, }, + /// The current document has no canonical origin bound to the browsing context. + ContextOriginNotBound, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, /// The registry exhausted one of its monotonic internal identifier spaces. @@ -351,6 +381,9 @@ impl fmt::Display for BrowserRegistryError { expected.value(), actual.value() ), + Self::ContextOriginNotBound => formatter.write_str( + "browsing context has no canonical origin bound for the current document", + ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), Self::IdentifierSpaceExhausted => { @@ -640,6 +673,7 @@ mod tests { expected: expected_values[0], actual: actual_values[0], }, + BrowserRegistryError::ContextOriginNotBound, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs new file mode 100644 index 000000000..7f35fab1d --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_revalidation.rs @@ -0,0 +1,97 @@ +use std::error::Error; +use std::io; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +fn first_origin() -> Result { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid")) +} + +fn second_origin() -> Result { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid")) +} + +#[test] +fn current_context_origin_must_be_bound_before_revalidation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.require_context_origin(session, context, &origin), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!( + registry.require_context_origin(session, context, &origin), + Ok(epoch) + ); + Ok(()) +} + +#[test] +fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert!( + registry + .require_context_origin(session, context, &first) + .is_ok() + ); + Ok(()) +} + +#[test] +fn document_rotation_requires_fresh_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + let next_epoch = registry.advance_document(context)?; + assert_eq!( + registry.require_context_origin(session, context, &first), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + registry.bind_context_origin(session, context, &second)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Ok(next_epoch) + ); + Ok(()) +} + +#[test] +fn context_origin_revalidation_preserves_session_ownership() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + registry.bind_context_origin(owner, context, &origin)?; + assert_eq!( + registry.require_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 07ddea2d5..34a92da9c 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -181,6 +181,12 @@ def test_context_origin_binding_is_recorded_in_the_changelog(self) -> None: changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") self.assertIn("BrowserAuthorityRegistry::bind_context_origin", changelog) + def test_context_origin_revalidation_is_recorded_in_the_changelog(self) -> None: + """The public origin-revalidation boundary must remain visible in release history.""" + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + self.assertIn("BrowserAuthorityRegistry::require_context_origin", changelog) + def test_database_contract_requires_two_word_snake_case(self) -> None: """Persistent naming policy must include the mandated canonical form."""