diff --git a/.github/workflows/mv3-compatibility.yml b/.github/workflows/mv3-compatibility.yml index 13a9eec6c..f6b227c3a 100644 --- a/.github/workflows/mv3-compatibility.yml +++ b/.github/workflows/mv3-compatibility.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5bb74df..8a5778e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. diff --git a/Cargo.lock b/Cargo.lock index ca7a3ef12..f7c65dab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,7 @@ dependencies = [ name = "originweave-fingerprint" version = "0.1.0" dependencies = [ + "originweave-core", "sha2", ] diff --git a/crates/originweave-fingerprint/Cargo.toml b/crates/originweave-fingerprint/Cargo.toml index 595282d48..608ac4977 100644 --- a/crates/originweave-fingerprint/Cargo.toml +++ b/crates/originweave-fingerprint/Cargo.toml @@ -11,6 +11,7 @@ homepage.workspace = true publish = false [dependencies] +originweave-core = { path = "../originweave-core" } sha2 = "=0.10.9" [lints] diff --git a/crates/originweave-fingerprint/src/lib.rs b/crates/originweave-fingerprint/src/lib.rs index 7c73b0f5a..d793df095 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -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::{ @@ -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; diff --git a/crates/originweave-fingerprint/src/web_audio_guard.rs b/crates/originweave-fingerprint/src/web_audio_guard.rs new file mode 100644 index 000000000..98d29ad52 --- /dev/null +++ b/crates/originweave-fingerprint/src/web_audio_guard.rs @@ -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, +} + +impl WebAudioFingerprintPolicy { + /// Build a policy from canonical origins, deduplicating before bounding it. + pub fn new(allowed_origins: Vec) -> Result { + let allowed_origins = allowed_origins.into_iter().collect::>(); + 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::>() + .join(",\n"); + GUARD_SCRIPT_TEMPLATE.replacen(ALLOWLIST_MARKER, &rendered_origins, 1) + } +} diff --git a/crates/originweave-fingerprint/tests/web_audio_fingerprint_guard.rs b/crates/originweave-fingerprint/tests/web_audio_fingerprint_guard.rs new file mode 100644 index 000000000..c852867da --- /dev/null +++ b/crates/originweave-fingerprint/tests/web_audio_fingerprint_guard.rs @@ -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::>(); + + 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" + ); +} diff --git a/docs/README.md b/docs/README.md index f9529978a..55ff1fbfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/adr/0114-default-deny-web-audio-fingerprinting.md b/docs/adr/0114-default-deny-web-audio-fingerprinting.md new file mode 100644 index 000000000..efc792e3a --- /dev/null +++ b/docs/adr/0114-default-deny-web-audio-fingerprinting.md @@ -0,0 +1,147 @@ +# ADR 0114: Default-deny Web Audio fingerprinting + +- Status: Proposed +- Date: 2026-08-27 +- Supersedes: None +- Superseded by: None + +## Context + +A page does not need audible playback to use the Web Audio API as a fingerprinting surface. It can create an oscillator or other deterministic graph, inspect analyser or processor output, render an `OfflineAudioContext`, and combine implementation-specific numerical differences with other browser signals. Setting the final gain to zero only prevents sound from reaching the user; it does not prevent the browser from performing the measurements. + +ADR 0111 standardizes a reported Web Audio sample rate as part of a bounded presentation identity. That reduces one ambient signal but does not prevent a page from constructing analyser, processor, compressor, oscillator, offline-rendering, or worklet graphs. A privacy profile therefore requires an explicit authority decision before any page script captures the native Web Audio constructors. + +## Decision drivers + +- Prevent silent Web Audio computation from becoming an ambient re-identification channel. +- Apply the decision before page JavaScript, including in child frames. +- Preserve ordinary `