Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions crates/originweave-core/src/browser_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentEpoch, BrowserRegistryError> {
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)
}
Comment thread
seonghobae marked this conversation as resolved.

/// 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -640,6 +673,7 @@ mod tests {
expected: expected_values[0],
actual: actual_values[0],
},
BrowserRegistryError::ContextOriginNotBound,
BrowserRegistryError::OriginChangedWithoutDocumentAdvance,
BrowserRegistryError::IdentifierSpaceExhausted,
BrowserRegistryError::DocumentEpochExhausted,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use std::error::Error;
use std::io;

use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin};

fn first_origin() -> Result<Origin, io::Error> {
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, io::Error> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
6 changes: 6 additions & 0 deletions tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading