Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/mv3-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ on:
paths:
- ".github/workflows/mv3-compatibility.yml"
- "crates/originweave-core/**"
- "crates/originweave-fingerprint/**"
- "extensions/originweave-privacy-guard/**"
- "scripts/ci/run_mv3_compatibility.py"
- "scripts/ci/run_web_audio_privacy.py"
- "tests/fixtures/mv3_basic/**"
- "tests/fixtures/web_audio_privacy/**"
- "tests/test_mv3_compatibility_contract.py"
- "tests/test_web_audio_privacy_contract.py"
- "docs/adr/0114-default-deny-web-audio-fingerprinting.md"
- "docs/doctoring/mv3-compatibility.md"
- "docs/doctoring/web-audio-privacy.md"

permissions:
contents: read
Expand Down Expand Up @@ -74,14 +81,16 @@ jobs:
"$CHROME_BIN" --version
"$CHROMEDRIVER_BIN" --version
python3 scripts/ci/run_mv3_compatibility.py | tee mv3-compatibility.json
python3 scripts/ci/run_web_audio_privacy.py | tee web-audio-privacy.json

- name: Upload bounded compatibility evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mv3-compatibility-${{ github.event.pull_request.head.sha || github.sha }}
path: |
mv3-download-sha256.txt
mv3-compatibility.json
web-audio-privacy.json
if-no-files-found: warn
retention-days: 7
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment.

### Added
- Added a default-deny Web Audio fingerprinting boundary for isolated Agent and Crawler profiles: exact-origin grants capped at 128 unique canonical origins, a deterministic Rust-rendered MAIN-world `document_start` guard, and a pinned-Chromium top-document/child-frame proof that blocks online, offline, prefixed, and AudioWorklet construction entry points (see ADR 0114).
- Added cross-surface platform coherence to the fingerprint kernel: `PresentationPlatform::hints_platform` is the single source of truth mapping each presentation platform to its canonical UA Client Hints platform, and `require_hints_coherence` fails closed on any contradiction so the presentation-platform, UA-token, and UA-CH-platform triad cannot leak a mismatched identity (see ADR 0113).
- Added bounded User-Agent Client Hints surfaces to the fingerprint kernel: ASCII brand/version validation with a 32-character name bound, enumerated architecture/bitness/platform tokens, a non-empty brand-list requirement, and the spec rule that a non-mobile user agent reports an empty model. Control-plane contract only, grounded in the User-Agent Client Hints draft (WICG, 2026); see ADR 0112.
- Added bounded stealth-normalization surfaces to the fingerprint kernel: enumerated canvas-noise classes, canonicalized WebGL renderer tokens with a 256-byte pre-normalization input ceiling, standard-rate Web Audio normalization, bounded WebRTC interface policy, and a fail-closed Canvas/WebGL/WebAudio/WebRtc surface-admission contract. This is a privacy-preserving control-plane contract with no real-browser or anti-evasion claim (see ADR 0111).
- Added bounded stealth-normalization surfaces to the fingerprint kernel: enumerated canvas-noise classes, canonicalized WebGL renderer tokens, standard-rate Web Audio normalization, bounded WebRTC interface policy, and a fail-closed Canvas/WebGL/WebAudio/WebRtc surface-admission contract. This is a privacy-preserving control-plane contract with no real-browser or anti-evasion claim (see ADR 0111).
- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate.
- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge.

Expand Down Expand Up @@ -102,6 +103,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves.

### Security
- Web Audio constructors now fail with a fixed `NotAllowedError` in the managed default profile unless a trusted policy grants the exact canonical origin; the guard has no storage, network, messaging, model, or secret authority and does not affect ordinary media-element playback.

- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority.
- Raw page content cannot become a trusted instruction.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/originweave-fingerprint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ homepage.workspace = true
publish = false

[dependencies]
originweave-core = { path = "../originweave-core" }
sha2 = "=0.10.9"

[lints]
Expand Down
2 changes: 2 additions & 0 deletions crates/originweave-fingerprint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
mod coherence;
mod stealth;
mod ua_hints;
mod web_audio_guard;

pub use coherence::{CoherenceError, require_hints_coherence};
pub use stealth::{
Expand All @@ -33,6 +34,7 @@ pub use stealth::{
pub use ua_hints::{
ClientHintsError, HintsArchitecture, HintsBitness, HintsPlatform, UaBrand, UaClientHints,
};
pub use web_audio_guard::{WebAudioDecision, WebAudioFingerprintPolicy, WebAudioPolicyError};

use sha2::{Digest, Sha256};
use std::error::Error;
Expand Down
119 changes: 119 additions & 0 deletions crates/originweave-fingerprint/src/web_audio_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Default-deny Web Audio fingerprinting policy and deterministic guard asset.
//!
//! Web Audio exposes implementation-specific timing and digital-signal-
//! processing behavior that a page can combine into a device fingerprint. This
//! module binds exact-origin exceptions to a reviewed pre-document guard rather
//! than copying host audio characteristics or injecting random noise.

use originweave_core::Origin;
use std::collections::BTreeSet;
use std::error::Error;
use std::fmt;

const MAX_ALLOWED_ORIGINS: usize = 128;
const ALLOWLIST_MARKER: &str = "/* ORIGINWEAVE_ALLOWED_WEB_AUDIO_ORIGINS */";
const GUARD_SCRIPT_TEMPLATE: &str =
include_str!("../../../extensions/originweave-privacy-guard/web_audio_guard.js");

/// The result of evaluating one page origin against the Web Audio policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebAudioDecision {
/// Web Audio construction must be blocked because no exact grant exists.
BlockFingerprinting,
/// A trusted profile explicitly granted the exact canonical origin.
AllowExplicitOrigin,
}

impl WebAudioDecision {
/// Return whether the privacy guard must block Web Audio constructors.
#[must_use]
pub const fn blocks_fingerprinting(self) -> bool {
matches!(self, Self::BlockFingerprinting)
}

/// Return the stable credential-free denial reason for audit evidence.
#[must_use]
pub const fn reason_code(self) -> Option<&'static str> {
match self {
Self::BlockFingerprinting => Some("web_audio_fingerprinting_no_explicit_origin_grant"),
Self::AllowExplicitOrigin => None,
}
}
}

/// A bounded Web Audio privacy-policy configuration failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebAudioPolicyError {
/// The canonical allowlist exceeded its reviewed unique-origin ceiling.
TooManyAllowedOrigins {
/// Maximum number of unique canonical origins permitted by the policy.
maximum: usize,
/// Actual number of unique canonical origins supplied by the caller.
actual: usize,
},
}

impl fmt::Display for WebAudioPolicyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TooManyAllowedOrigins { maximum, actual } => write!(
formatter,
"web audio allowlist contains {actual} unique origins; maximum is {maximum}"
),
}
}
}

impl Error for WebAudioPolicyError {}

/// An immutable exact-origin policy for the reviewed Web Audio guard.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebAudioFingerprintPolicy {
allowed_origins: BTreeSet<Origin>,
}

impl WebAudioFingerprintPolicy {
/// Build a policy from canonical origins, deduplicating before bounding it.
pub fn new(allowed_origins: Vec<Origin>) -> Result<Self, WebAudioPolicyError> {
let allowed_origins = allowed_origins.into_iter().collect::<BTreeSet<_>>();
if allowed_origins.len() > MAX_ALLOWED_ORIGINS {
return Err(WebAudioPolicyError::TooManyAllowedOrigins {
maximum: MAX_ALLOWED_ORIGINS,
actual: allowed_origins.len(),
});
}
Ok(Self { allowed_origins })
}

/// Evaluate one exact canonical origin without subdomain or port widening.
#[must_use]
pub fn decision(&self, origin: &Origin) -> WebAudioDecision {
if self.allowed_origins.contains(origin) {
WebAudioDecision::AllowExplicitOrigin
} else {
WebAudioDecision::BlockFingerprinting
}
}

/// Return the number of unique exact-origin grants in this policy.
#[must_use]
pub fn allowed_origin_count(&self) -> usize {
self.allowed_origins.len()
}

/// Render the reviewed MAIN-world `document_start` guard deterministically.
///
/// [`Origin`] admits only canonical scheme/authority strings, so each value
/// is safe to place inside the generated JSON string literal without path,
/// quote, backslash, control-character, or user-information ambiguity.
#[must_use]
pub fn render_guard_script(&self) -> String {
let rendered_origins = self
.allowed_origins
.iter()
.map(|origin| format!(" \"{}\"", origin.as_str()))
.collect::<Vec<_>>()
.join(",\n");
GUARD_SCRIPT_TEMPLATE.replacen(ALLOWLIST_MARKER, &rendered_origins, 1)
}
}
128 changes: 128 additions & 0 deletions crates/originweave-fingerprint/tests/web_audio_fingerprint_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Web Audio fingerprint-blocking contracts for OriginWeave privacy profiles.
//!
//! These tests are intentionally added before production code. They define a
//! default-deny policy, exact-origin exceptions, bounded configuration, and a
//! deterministic pre-document guard script that blocks Web Audio constructors
//! before page JavaScript can create a silent fingerprint graph.
#![allow(clippy::expect_used)]

use originweave_core::Origin;
use originweave_fingerprint::{WebAudioDecision, WebAudioFingerprintPolicy, WebAudioPolicyError};

fn origin(value: &str) -> Origin {
Origin::parse(value).expect("test origin must satisfy the shared origin contract")
}

#[test]
fn default_policy_blocks_web_audio_fingerprinting_with_audit_reason() {
let policy = WebAudioFingerprintPolicy::default();
let decision = policy.decision(&origin("https://shop.example"));

assert_eq!(decision, WebAudioDecision::BlockFingerprinting);
assert!(decision.blocks_fingerprinting());
assert_eq!(
decision.reason_code(),
Some("web_audio_fingerprinting_no_explicit_origin_grant")
);
assert_eq!(policy.allowed_origin_count(), 0);
}

#[test]
fn explicit_grant_is_exact_origin_scoped() {
let policy = WebAudioFingerprintPolicy::new(vec![origin("https://shop.example")])
.expect("one valid grant must fit the bounded policy");

let allowed = policy.decision(&origin("https://shop.example:443"));
assert_eq!(allowed, WebAudioDecision::AllowExplicitOrigin);
assert!(!allowed.blocks_fingerprinting());
assert_eq!(allowed.reason_code(), None);

assert_eq!(
policy.decision(&origin("https://cdn.shop.example")),
WebAudioDecision::BlockFingerprinting
);
assert_eq!(
policy.decision(&origin("https://shop.example:8443")),
WebAudioDecision::BlockFingerprinting
);
}

#[test]
fn duplicate_grants_collapse_to_one_canonical_origin() {
let policy = WebAudioFingerprintPolicy::new(vec![
origin("https://shop.example"),
origin("https://shop.example:443"),
])
.expect("canonical duplicate grants must remain bounded");

assert_eq!(policy.allowed_origin_count(), 1);
}

#[test]
fn allowlist_rejects_more_than_the_bounded_unique_origin_count() {
let grants = (0..129)
.map(|index| origin(&format!("https://site-{index}.example")))
.collect::<Vec<_>>();

assert_eq!(
WebAudioFingerprintPolicy::new(grants),
Err(WebAudioPolicyError::TooManyAllowedOrigins {
maximum: 128,
actual: 129,
})
);
}

#[test]
fn rendered_guard_is_deterministic_and_contains_only_canonical_grants() {
let policy = WebAudioFingerprintPolicy::new(vec![
origin("https://z.example"),
origin("https://a.example:443"),
])
.expect("two exact grants must fit the bounded policy");

let first = policy.render_guard_script();
let second = policy.render_guard_script();
assert_eq!(first, second);
assert!(!first.contains("ORIGINWEAVE_ALLOWED_WEB_AUDIO_ORIGINS"));
assert!(first.contains("\"https://a.example\""));
assert!(first.contains("\"https://z.example\""));
assert!(
first
.find("https://a.example")
.expect("first origin must be rendered")
< first
.find("https://z.example")
.expect("second origin must be rendered")
);
}

#[test]
fn rendered_guard_blocks_every_web_audio_construction_entrypoint() {
let script = WebAudioFingerprintPolicy::default().render_guard_script();

for constructor in [
"AudioContext",
"webkitAudioContext",
"OfflineAudioContext",
"webkitOfflineAudioContext",
"AudioWorkletNode",
] {
assert!(script.contains(constructor), "missing {constructor}");
}
assert!(script.contains("NotAllowedError"));
assert!(script.contains("document_start"));
}

#[test]
fn policy_error_formats_a_stable_operator_message() {
let error = WebAudioPolicyError::TooManyAllowedOrigins {
maximum: 128,
actual: 129,
};

assert_eq!(
error.to_string(),
"web audio allowlist contains 129 unique origins; maximum is 128"
);
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a
- [ADR 0111: Bounded stealth-normalization surfaces](adr/0111-bounded-stealth-normalization-surfaces.md)
- [ADR 0112: Bounded User-Agent Client Hints](adr/0112-bounded-user-agent-client-hints.md)
- [ADR 0113: Cross-surface platform coherence](adr/0113-cross-surface-platform-coherence.md)
- [ADR 0114: Default-deny Web Audio fingerprinting](adr/0114-default-deny-web-audio-fingerprinting.md)

The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented.

Expand Down
Loading
Loading