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 11ec06f02..8a5778e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +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, 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. @@ -99,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/coherence.rs b/crates/originweave-fingerprint/src/coherence.rs new file mode 100644 index 000000000..c3c866721 --- /dev/null +++ b/crates/originweave-fingerprint/src/coherence.rs @@ -0,0 +1,47 @@ +//! Cross-surface platform-coherence contracts for stealth presentations. +//! +//! The presentation platform, the JavaScript UA token, and the UA Client +//! Hints platform are three surfaces a page can reconcile into one identity. +//! If an adapter presents one platform in the static profile and a different +//! one in `navigator.userAgentData`, the contradiction is itself a +//! reidentification signal. This module binds the hints platform to the +//! presentation platform with a deterministic, fail-closed check. It +//! performs no evasion and never reads the host. + +use crate::{PresentationPlatform, UaClientHints}; +use std::error::Error; +use std::fmt; + +/// A cross-surface coherence failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoherenceError { + /// The UA Client Hints platform contradicted the presentation platform. + HintsPlatformMismatch, +} + +impl fmt::Display for CoherenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HintsPlatformMismatch => formatter + .write_str("UA Client Hints platform contradicts the presentation platform"), + } + } +} + +impl Error for CoherenceError {} + +/// Require the presented UA Client Hints to agree with the presentation +/// platform. +/// +/// The canonical hints token for `presentation` comes from +/// [`PresentationPlatform::hints_platform`]; any other hints platform fails +/// closed so an adapter cannot surface a contradicting identity. +pub fn require_hints_coherence( + hints: &UaClientHints, + presentation: PresentationPlatform, +) -> Result<(), CoherenceError> { + if hints.platform() != presentation.hints_platform() { + return Err(CoherenceError::HintsPlatformMismatch); + } + Ok(()) +} diff --git a/crates/originweave-fingerprint/src/lib.rs b/crates/originweave-fingerprint/src/lib.rs index cfc25f99b..d793df095 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -21,6 +21,21 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod coherence; +mod stealth; +mod ua_hints; +mod web_audio_guard; + +pub use coherence::{CoherenceError, require_hints_coherence}; +pub use stealth::{ + CanvasNoise, StealthError, StealthSurface, WebAudioRate, WebGlRendererToken, WebRtcInterface, + require_stealth_surfaces, +}; +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; use std::fmt; @@ -282,6 +297,20 @@ impl PresentationPlatform { Self::Linux => "Linux x86_64", } } + + /// Return the canonical UA Client Hints platform token for this family. + /// + /// A page reconciles the presentation platform with + /// `navigator.userAgentData.platform`, so the two must agree; the mapping + /// is the single source of truth an adapter consumes (see ADR 0112). + #[must_use] + pub const fn hints_platform(self) -> HintsPlatform { + match self { + Self::Windows => HintsPlatform::Windows, + Self::MacOS => HintsPlatform::MacOs, + Self::Linux => HintsPlatform::Linux, + } + } } /// A lowercase SHA-256 digest identifier bound to one canonical profile. diff --git a/crates/originweave-fingerprint/src/stealth.rs b/crates/originweave-fingerprint/src/stealth.rs new file mode 100644 index 000000000..4c394337f --- /dev/null +++ b/crates/originweave-fingerprint/src/stealth.rs @@ -0,0 +1,203 @@ +//! Bounded stealth-normalization surfaces for browser presentation. +//! +//! A page can observe rendered and media surfaces that carry more entropy +//! than static profile fields: canvas readback noise, WebGL renderer tokens, +//! Web Audio sample-rate reporting, and WebRTC interface exposure. The W3C +//! Fingerprinting Guidance prefers standardized, bounded values over +//! independent per-session randomization, and longitudinal fingerprint +//! research shows that renderer and audio surfaces are strong +//! re-identification vectors (Laperdrix, Bielova, Baudry, & Avoine, 2020). +//! This module exposes the deterministic, evidence-bound contract those +//! surfaces must satisfy before an adapter may claim a complete stealth +//! presentation. It deliberately performs no evasion: it never defeats an +//! access-control, CAPTCHA, or bot-management gate, and never reads the host. + +use std::error::Error; +use std::fmt; + +/// A page-observable render or media surface that a stealth adapter must +/// prove before admission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StealthSurface { + /// Canvas pixel and text rendering observations. + Canvas, + /// WebGL vendor, renderer, and UNMASKED extension observations. + WebGL, + /// WebAudio sample-rate and analyser observations. + WebAudio, + /// WebRTC interface candidate observations. + WebRtc, +} + +const REQUIRED_STEALTH_SURFACES: [StealthSurface; 4] = [ + StealthSurface::Canvas, + StealthSurface::WebGL, + StealthSurface::WebAudio, + StealthSurface::WebRtc, +]; + +/// Validate that an adapter overrides every required stealth surface. +/// +/// The first missing surface is reported in stable contract order. Extra, +/// duplicate, or reordered supported entries do not change admission, so +/// feature negotiation stays order independent. +pub fn require_stealth_surfaces(supported: &[StealthSurface]) -> Result<(), StealthError> { + for required in REQUIRED_STEALTH_SURFACES { + if !supported.contains(&required) { + return Err(StealthError::MissingSurface(required)); + } + } + Ok(()) +} + +/// A validation failure when assembling a stealth presentation surface set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StealthError { + /// A canvas noise class was outside the enumerated supported set. + InvalidCanvasNoise, + /// A WebAudio sample rate was not a supported standard rate. + InvalidSampleRate, + /// An adapter claims a stealth surface it cannot override. + MissingSurface(StealthSurface), +} + +impl fmt::Display for StealthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidCanvasNoise => formatter + .write_str("canvas noise class must be one of the enumerated supported values"), + Self::InvalidSampleRate => { + formatter.write_str("web audio sample rate must be a supported standard rate") + } + Self::MissingSurface(surface) => { + write!( + formatter, + "adapter cannot override required {surface:?} stealth surface" + ) + } + } + } +} + +impl Error for StealthError {} + +/// A bounded, deterministic canvas pixel-noise class. +/// +/// Classes map to small closed ranges of least-significant pixel bits so an +/// adapter can widen or narrow noise without presenting a freshly randomized +/// per-session value, which W3C guidance warns can create new distinguishers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasNoise { + /// No injected pixel noise; the smallest observed-distortion class. + Crisp, + /// A single least-significant-bit noise class. + Smooth, + /// A two-bit noise class. + Diffuse, +} + +impl CanvasNoise { + /// Map an enumerated class index onto a noise class, rejecting others. + pub const fn quantize(class: u8) -> Result { + match class { + 0 => Ok(Self::Crisp), + 1 => Ok(Self::Smooth), + 2 => Ok(Self::Diffuse), + _ => Err(StealthError::InvalidCanvasNoise), + } + } + + /// Return the bounded least-significant bit shift for this class. + #[must_use] + pub const fn bit_shift(self) -> u8 { + match self { + Self::Crisp => 0, + Self::Smooth => 1, + Self::Diffuse => 2, + } + } +} + +/// A standardized WebGL renderer token that does not name the host GPU. +/// +/// Adapters expose one of these tokens instead of surfacing vendor-specific +/// GPU model strings, which fingerprinting research identifies as a strong +/// re-identification signal (Laperdrix et al., 2020). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebGlRendererToken { + /// ANGLE over a hardware driver family. + Angle, + /// Software rendering with no identifying driver string. + Standard, +} + +impl WebGlRendererToken { + /// Canonicalize a known renderer spelling onto a bounded token. + /// + /// Known software-renderer markers take precedence over an `ANGLE` + /// prefix because Chromium's SwiftShader renderer is itself ANGLE-backed. + /// Unrecognized spellings fail closed to `None` rather than being echoed + /// to a new class, so an adapter cannot widen the token set by fiat. + #[must_use] + pub fn canonical(spelling: &str) -> Option { + let upper = spelling.to_ascii_uppercase(); + if upper.contains("SOFTWARE") || upper.contains("SWIFTSHADER") { + Some(Self::Standard) + } else if upper.starts_with("ANGLE") { + Some(Self::Angle) + } else { + None + } + } +} + +/// A supported WebAudio sample rate in hertz. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebAudioRate { + /// The standard 44.1 kHz rate. + Rate44100, + /// The standard 48 kHz rate. + Rate48000, +} + +impl WebAudioRate { + /// Normalize an observed sample rate onto a supported standard rate. + pub fn normalize(rate_hz: u32) -> Result { + match rate_hz { + 44_100 => Ok(Self::Rate44100), + 48_000 => Ok(Self::Rate48000), + _ => Err(StealthError::InvalidSampleRate), + } + } + + /// Return the exact hertz value for this rate. + #[must_use] + pub const fn rate_hz(self) -> u32 { + match self { + Self::Rate44100 => 44_100, + Self::Rate48000 => 48_000, + } + } +} + +/// A bounded WebRTC interface-candidate policy. +/// +/// This is policy only; the kernel never creates a peer connection or exposes +/// an address. Variant names describe the page-visible candidate behavior +/// directly so adapter code cannot mistake candidate disclosure for a safe +/// privacy mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebRtcInterface { + /// The adapter deliberately exposes direct interface candidates. + DirectCandidates, + /// The adapter publishes only mDNS-candidate interfaces. + MDnsOnly, +} + +impl WebRtcInterface { + /// Whether this policy exposes local interface candidates directly. + #[must_use] + pub fn exposes_candidates(self) -> bool { + matches!(self, Self::DirectCandidates) + } +} diff --git a/crates/originweave-fingerprint/src/ua_hints.rs b/crates/originweave-fingerprint/src/ua_hints.rs new file mode 100644 index 000000000..7fe208579 --- /dev/null +++ b/crates/originweave-fingerprint/src/ua_hints.rs @@ -0,0 +1,271 @@ +//! Bounded User-Agent Client Hints surfaces for browser presentation. +//! +//! A page can request high-entropy UA Client Hints — architecture, bitness, +//! platform, platform version, model — in addition to the low-entropy +//! brand/mobile hints a Chromium user agent sends on every request. If an +//! adapter presents a static profile but lets the real UA-CH surface leak, +//! a page reconciles the contradiction and the host is reidentified. The +//! User-Agent Client Hints specification (WICG, 2026) bounds the low-entropy +//! platform object and requires non-mobile user agents to report an empty +//! model. This module exposes the deterministic contract those hints must +//! satisfy while performing no evasion and never reading the host. + +use std::error::Error; +use std::fmt; + +/// The maximum accepted brand-name length in ASCII characters. +const MAX_BRAND_NAME_LENGTH: usize = 32; + +/// A validation failure when assembling a UA Client Hints surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientHintsError { + /// A brand name exceeded the bounded ASCII length. + BrandTooLong, + /// A brand name contained a non-ASCII-alpha or non-digit character. + InvalidBrandName, + /// A platform token was outside the enumerated low-entropy set. + InvalidPlatform, + /// A non-mobile user agent reported a non-empty model. + ModelWithoutMobile, + /// A client-hints set carried no brand. + MissingBrand, +} + +impl fmt::Display for ClientHintsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BrandTooLong => { + formatter.write_str("brand name must be at most 32 ASCII characters") + } + Self::InvalidBrandName => { + formatter.write_str("brand name and version must use ASCII letters and digits") + } + Self::InvalidPlatform => formatter.write_str( + "platform must be one of the enumerated UA Client Hints platform values", + ), + Self::ModelWithoutMobile => { + formatter.write_str("a non-mobile user agent must report an empty model") + } + Self::MissingBrand => { + formatter.write_str("a client-hints value must contain at least one brand") + } + } + } +} + +impl Error for ClientHintsError {} + +/// One brand/version pair from a UA brand list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UaBrand { + name: String, + version: String, +} + +impl UaBrand { + /// Validate one brand/version token pair. + /// + /// Names and versions must be ASCII alphanumeric or dotted numerals, and + /// the name must be at most 32 characters, matching the WICG brand + /// grammar requirement. + pub fn new(name: &str, version: &str) -> Result { + if name.len() > MAX_BRAND_NAME_LENGTH { + return Err(ClientHintsError::BrandTooLong); + } + if !name.bytes().all(|byte| byte.is_ascii_alphanumeric()) + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.') + { + return Err(ClientHintsError::InvalidBrandName); + } + Ok(Self { + name: name.to_owned(), + version: version.to_owned(), + }) + } + + /// Return the brand name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Return the brand version. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } +} + +/// A bounded CPU-architecture token from the UA Client Hints hint set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HintsArchitecture { + /// The `x86` architecture token. + X86, + /// The `arm` architecture token. + Arm, +} + +impl HintsArchitecture { + /// Map a submitted hint token onto a bounded architecture class. + /// + /// Unknown architecture values fail closed rather than widening the set. + #[must_use] + pub fn from_token(token: &str) -> Option { + match token { + "x86" => Some(Self::X86), + "arm" => Some(Self::Arm), + _ => None, + } + } + + /// Return the exact architecture token this class represents. + #[must_use] + pub const fn token(self) -> &'static str { + match self { + Self::X86 => "x86", + Self::Arm => "arm", + } + } +} + +/// A bounded CPU bitness token from the UA Client Hints hint set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HintsBitness { + /// The `32` bitness token. + Bit32, + /// The `64` bitness token. + Bit64, +} + +impl HintsBitness { + /// Map a recognized bitness token onto a class, rejecting others. + #[must_use] + pub fn from_token(token: &str) -> Option { + match token { + "32" => Some(Self::Bit32), + "64" => Some(Self::Bit64), + _ => None, + } + } + + /// Return the canonical bitness token this class represents. + #[must_use] + pub const fn token(self) -> &'static str { + match self { + Self::Bit32 => "32", + Self::Bit64 => "64", + } + } +} + +/// A low-entropy platform token a user agent reports by default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HintsPlatform { + /// The `Windows` platform token. + Windows, + /// The `macOS` platform token. + MacOs, + /// The `Linux` platform token. + Linux, +} + +impl HintsPlatform { + /// Normalize a reported platform token onto an enumerated class. + pub fn normalize(token: &str) -> Result { + match token { + "Windows" => Ok(Self::Windows), + "macOS" => Ok(Self::MacOs), + "Linux" => Ok(Self::Linux), + _ => Err(ClientHintsError::InvalidPlatform), + } + } + + /// Return the canonical platform token this class represents. + #[must_use] + pub const fn token(self) -> &'static str { + match self { + Self::Windows => "Windows", + Self::MacOs => "macOS", + Self::Linux => "Linux", + } + } +} + +/// A validated, bounded UA Client Hints surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UaClientHints { + platform: HintsPlatform, + architecture: HintsArchitecture, + bitness: HintsBitness, + mobile: bool, + model: String, + brands: Vec, +} + +impl UaClientHints { + /// Validate and build a UA Client Hints surface. + /// + /// The model must be empty when `mobile` is false, and the brand list + /// must be non-empty. Every brand is validated by [`UaBrand::new`]. + pub fn new( + platform: HintsPlatform, + architecture: HintsArchitecture, + bitness: HintsBitness, + mobile: bool, + model: &str, + brands: Vec, + ) -> Result { + if !mobile && !model.is_empty() { + return Err(ClientHintsError::ModelWithoutMobile); + } + if brands.is_empty() { + return Err(ClientHintsError::MissingBrand); + } + Ok(Self { + platform, + architecture, + bitness, + mobile, + model: model.to_owned(), + brands, + }) + } + + /// Return the low-entropy platform token. + #[must_use] + pub const fn platform(&self) -> HintsPlatform { + self.platform + } + + /// Return the enumerated architecture class. + #[must_use] + pub const fn architecture(&self) -> HintsArchitecture { + self.architecture + } + + /// Return the enumerated bitness class. + #[must_use] + pub const fn bitness(&self) -> HintsBitness { + self.bitness + } + + /// Return whether this user agent prefers a mobile experience. + #[must_use] + pub const fn mobile(&self) -> bool { + self.mobile + } + + /// Return the model name, empty for non-mobile user agents. + #[must_use] + pub fn model(&self) -> &str { + &self.model + } + + /// Return the validated brand list. + #[must_use] + pub fn brands(&self) -> &[UaBrand] { + &self.brands + } +} 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/profile_coherence_surface.rs b/crates/originweave-fingerprint/tests/profile_coherence_surface.rs new file mode 100644 index 000000000..6dd111e2a --- /dev/null +++ b/crates/originweave-fingerprint/tests/profile_coherence_surface.rs @@ -0,0 +1,112 @@ +//! Cross-surface platform-coherence contracts for stealth presentations. +//! +//! A page can reconcile the static profile, the UA string, and the UA Client +//! Hints object into one identity. If an adapter presents a `Windows` +//! presentation platform but reports `macOS` UA Client Hints (or a mismatched +//! UA token), the contradiction is itself a reidentification signal. These +//! tests bind the hints platform and UA token to the presentation platform so +//! the three surfaces stay mutually coherent. +#![allow(clippy::expect_used)] + +use originweave_fingerprint::{ + CoherenceError, HintsArchitecture, HintsBitness, HintsPlatform, PresentationPlatform, UaBrand, + UaClientHints, require_hints_coherence, +}; + +fn hints_for(platform: HintsPlatform) -> UaClientHints { + UaClientHints::new( + platform, + HintsArchitecture::from_token("x86").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + false, + "", + vec![UaBrand::new("Chromium", "131.0.0.0").expect("brand")], + ) + .expect("hints") +} + +#[test] +fn matching_hints_platform_is_accepted() { + let windows = hints_for(HintsPlatform::Windows); + assert_eq!( + require_hints_coherence(&windows, PresentationPlatform::Windows), + Ok(()) + ); + let macos = hints_for(HintsPlatform::MacOs); + assert_eq!( + require_hints_coherence(&macos, PresentationPlatform::MacOS), + Ok(()) + ); + let linux = hints_for(HintsPlatform::Linux); + assert_eq!( + require_hints_coherence(&linux, PresentationPlatform::Linux), + Ok(()) + ); +} + +#[test] +fn mismatched_hints_platform_fails_closed() { + let windows_hints = hints_for(HintsPlatform::Windows); + assert_eq!( + require_hints_coherence(&windows_hints, PresentationPlatform::MacOS), + Err(CoherenceError::HintsPlatformMismatch) + ); + assert_eq!( + require_hints_coherence(&windows_hints, PresentationPlatform::Linux), + Err(CoherenceError::HintsPlatformMismatch) + ); +} + +#[test] +fn every_presentation_platform_maps_to_its_canonical_hints_token() { + assert_eq!( + PresentationPlatform::Windows.hints_platform(), + HintsPlatform::Windows + ); + assert_eq!( + PresentationPlatform::MacOS.hints_platform(), + HintsPlatform::MacOs + ); + assert_eq!( + PresentationPlatform::Linux.hints_platform(), + HintsPlatform::Linux + ); +} + +#[test] +fn every_presentation_platform_maps_to_its_canonical_ua_token() { + assert_eq!(PresentationPlatform::Windows.user_agent_token(), "Win32"); + assert_eq!(PresentationPlatform::MacOS.user_agent_token(), "MacIntel"); + assert_eq!( + PresentationPlatform::Linux.user_agent_token(), + "Linux x86_64" + ); +} + +#[test] +fn coherence_error_has_deterministic_display() { + assert_eq!( + CoherenceError::HintsPlatformMismatch.to_string(), + "UA Client Hints platform contradicts the presentation platform" + ); +} + +#[test] +fn coherent_profile_round_trips_hints_to_presentation() { + for (presentation, hints) in [ + (PresentationPlatform::Windows, HintsPlatform::Windows), + (PresentationPlatform::MacOS, HintsPlatform::MacOs), + (PresentationPlatform::Linux, HintsPlatform::Linux), + ] { + let profile_hints = hints_for(hints); + assert_eq!( + profile_hints.platform(), + presentation.hints_platform(), + "canonical hints token must equal the presentation mapping" + ); + assert_eq!( + require_hints_coherence(&profile_hints, presentation), + Ok(()) + ); + } +} diff --git a/crates/originweave-fingerprint/tests/stealth_noise_surface.rs b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs new file mode 100644 index 000000000..329f7208a --- /dev/null +++ b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs @@ -0,0 +1,139 @@ +//! Realistic stealth-normalization contracts for the fingerprint kernel. +//! +//! These tests exercise the bounded render and media surfaces an adapter +//! must prove before it may claim a complete stealth presentation: canvas +//! noise quantization, WebGL renderer tokens, WebAudio sample-rate +//! normalization, WebRTC interface policy, and the surface admission +//! contract that forces fail-closed completeness. +#![allow(clippy::expect_used)] + +use originweave_fingerprint::{ + CanvasNoise, StealthError, StealthSurface, WebAudioRate, WebGlRendererToken, WebRtcInterface, + require_stealth_surfaces, +}; + +const COMPLETE_STEALTH_SURFACES: [StealthSurface; 4] = [ + StealthSurface::Canvas, + StealthSurface::WebGL, + StealthSurface::WebAudio, + StealthSurface::WebRtc, +]; + +#[test] +fn incomplete_adapter_support_fails_on_the_first_missing_surface() { + let supported = COMPLETE_STEALTH_SURFACES + .into_iter() + .filter(|surface| *surface != StealthSurface::WebGL) + .collect::>(); + + assert_eq!( + require_stealth_surfaces(&supported), + Err(StealthError::MissingSurface(StealthSurface::WebGL)) + ); +} + +#[test] +fn complete_adapter_surface_support_is_order_and_duplicate_independent() { + let mut supported = COMPLETE_STEALTH_SURFACES.to_vec(); + supported.reverse(); + supported.push(StealthSurface::Canvas); + + assert_eq!(require_stealth_surfaces(&supported), Ok(())); +} + +#[test] +fn empty_adapter_surface_support_reports_canvas_first() { + assert_eq!( + require_stealth_surfaces(&[]), + Err(StealthError::MissingSurface(StealthSurface::Canvas)) + ); +} + +#[test] +fn canvas_noise_quantizes_only_supported_classes() { + assert_eq!(CanvasNoise::quantize(0), Ok(CanvasNoise::Crisp)); + assert_eq!(CanvasNoise::quantize(1), Ok(CanvasNoise::Smooth)); + assert_eq!(CanvasNoise::quantize(2), Ok(CanvasNoise::Diffuse)); + assert_eq!( + CanvasNoise::quantize(3), + Err(StealthError::InvalidCanvasNoise) + ); +} + +#[test] +fn canvas_noise_class_bit_shift_is_bound_to_the_declared_class() { + assert_eq!(CanvasNoise::Crisp.bit_shift(), 0); + assert_eq!(CanvasNoise::Smooth.bit_shift(), 1); + assert_eq!(CanvasNoise::Diffuse.bit_shift(), 2); +} + +#[test] +fn web_gl_renderer_tokens_are_bounded_and_standardized() { + assert_eq!( + WebGlRendererToken::canonical("ANGLE (NVIDIA GeForce RTX 4090)"), + Some(WebGlRendererToken::Angle) + ); + assert_eq!( + WebGlRendererToken::canonical("WebKit Software Rendering"), + Some(WebGlRendererToken::Standard) + ); + assert_eq!( + WebGlRendererToken::canonical( + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)" + ), + Some(WebGlRendererToken::Standard) + ); + assert_eq!( + WebGlRendererToken::canonical("ANGLE (Google, Software Rendering)"), + Some(WebGlRendererToken::Standard) + ); + assert_eq!(WebGlRendererToken::canonical("Mozilla/5.0"), None); +} + +#[test] +fn web_audio_rates_normalize_only_standard_rates() { + assert_eq!(WebAudioRate::normalize(44_100), Ok(WebAudioRate::Rate44100)); + assert_eq!(WebAudioRate::normalize(48_000), Ok(WebAudioRate::Rate48000)); + assert_eq!( + WebAudioRate::normalize(22_050), + Err(StealthError::InvalidSampleRate) + ); +} + +#[test] +fn web_rtc_interface_policy_names_direct_candidate_disclosure_explicitly() { + assert!(WebRtcInterface::DirectCandidates.exposes_candidates()); + assert!(!WebRtcInterface::MDnsOnly.exposes_candidates()); +} + +#[test] +fn stealth_errors_implement_display_for_adapters() { + assert_eq!( + StealthError::InvalidCanvasNoise.to_string(), + "canvas noise class must be one of the enumerated supported values" + ); + assert_eq!( + StealthError::InvalidSampleRate.to_string(), + "web audio sample rate must be a supported standard rate" + ); +} + +#[test] +fn web_audio_rate_accessors_expose_exact_hertz() { + assert_eq!(WebAudioRate::Rate44100.rate_hz(), 44_100); + assert_eq!(WebAudioRate::Rate48000.rate_hz(), 48_000); +} + +#[test] +fn missing_surface_error_formats_cleanly_for_each_surface() { + let surfaces = [ + StealthSurface::Canvas, + StealthSurface::WebGL, + StealthSurface::WebAudio, + StealthSurface::WebRtc, + ]; + for surface in surfaces { + let err = StealthError::MissingSurface(surface); + assert!(err.to_string().contains("adapter cannot override required")); + } +} diff --git a/crates/originweave-fingerprint/tests/ua_client_hints_surface.rs b/crates/originweave-fingerprint/tests/ua_client_hints_surface.rs new file mode 100644 index 000000000..88ff7dd88 --- /dev/null +++ b/crates/originweave-fingerprint/tests/ua_client_hints_surface.rs @@ -0,0 +1,195 @@ +//! Realistic User-Agent Client Hints contracts for a stealth presentation. +//! +//! These tests exercise the bounded UA-CH surface an adapter must prove +//! before it can claim a coherent stealth identity: brand-name length and +//! grammar bounds, enumerated architecture/bitness/platform tokens, and the +//! spec rule that a non-mobile user agent reports an empty model. +//! Authority: User-Agent Client Hints Draft Community Group Report +//! (WICG, 2026). +#![allow(clippy::expect_used)] + +use originweave_fingerprint::{ + ClientHintsError, HintsArchitecture, HintsBitness, HintsPlatform, UaBrand, UaClientHints, +}; + +#[test] +fn ua_brand_accepts_ascii_bounded_names_and_versions() { + assert!(UaBrand::new("Chromium", "131.0.0.0").is_ok()); + assert!(UaBrand::new("a", "1").is_ok()); +} + +#[test] +fn brand_names_over_length_limit_fail_closed() { + let long_name = "X".repeat(33); + assert_eq!( + UaBrand::new(&long_name, "1.0").expect_err("long name"), + ClientHintsError::BrandTooLong + ); +} + +#[test] +fn brand_names_with_invalid_grammar_fail_closed() { + assert_eq!( + UaBrand::new("Chromium!", "1.0").expect_err("bad name"), + ClientHintsError::InvalidBrandName + ); +} + +#[test] +fn brand_versions_with_invalid_grammar_fail_closed() { + assert_eq!( + UaBrand::new("Chromium", "1.0-beta!").expect_err("bad version"), + ClientHintsError::InvalidBrandName + ); +} + +#[test] +fn hints_bound_architectures_to_enumerated_tokens() { + assert!(HintsArchitecture::from_token("x86").is_some()); + assert!(HintsArchitecture::from_token("arm").is_some()); + assert!(HintsArchitecture::from_token("m68k").is_none()); +} + +#[test] +fn hints_bitness_bound_to_enumerated_tokens() { + assert!(HintsBitness::from_token("32").is_some()); + assert!(HintsBitness::from_token("64").is_some()); + assert!(HintsBitness::from_token("128").is_none()); +} + +#[test] +fn hints_platform_normalizes_to_the_low_entropy_set() { + assert_eq!( + HintsPlatform::normalize("Windows"), + Ok(HintsPlatform::Windows) + ); + assert_eq!(HintsPlatform::normalize("macOS"), Ok(HintsPlatform::MacOs)); + assert_eq!(HintsPlatform::normalize("Linux"), Ok(HintsPlatform::Linux)); + assert_eq!( + HintsPlatform::normalize("AmazingOS"), + Err(ClientHintsError::InvalidPlatform) + ); +} + +#[test] +fn non_mobile_client_hints_require_an_empty_model() { + let ok = UaClientHints::new( + HintsPlatform::Windows, + HintsArchitecture::from_token("x86").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + false, + "", + vec![UaBrand::new("Chromium", "131.0.0.0").expect("brand")], + ); + assert!(ok.is_ok()); + + let contradiction = UaClientHints::new( + HintsPlatform::Windows, + HintsArchitecture::from_token("x86").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + false, + "Pixel 2 XL", + vec![UaBrand::new("Chromium", "131.0.0.0").expect("brand")], + ); + assert_eq!(contradiction, Err(ClientHintsError::ModelWithoutMobile)); +} + +#[test] +fn mobile_hints_may_carry_a_model_without_exceeding_the_set() { + let mobile = UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::from_token("arm").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + true, + "Pixel 2 XL", + vec![UaBrand::new("Chromium", "131.0.0.0").expect("brand")], + ); + assert!(mobile.is_ok()); +} + +#[test] +fn empty_brand_list_fails_closed() { + assert_eq!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::from_token("x86").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + false, + "", + vec![], + ), + Err(ClientHintsError::MissingBrand) + ); +} + +#[test] +fn client_hints_error_has_deterministic_display() { + assert_eq!( + ClientHintsError::InvalidPlatform.to_string(), + "platform must be one of the enumerated UA Client Hints platform values" + ); + assert_eq!( + ClientHintsError::ModelWithoutMobile.to_string(), + "a non-mobile user agent must report an empty model" + ); + assert_eq!( + ClientHintsError::BrandTooLong.to_string(), + "brand name must be at most 32 ASCII characters" + ); + assert_eq!( + ClientHintsError::InvalidBrandName.to_string(), + "brand name and version must use ASCII letters and digits" + ); + assert_eq!( + ClientHintsError::MissingBrand.to_string(), + "a client-hints value must contain at least one brand" + ); +} + +#[test] +fn every_public_accessor_exposes_the_validated_value() { + let brand = UaBrand::new("Chromium", "131.0.0.0").expect("brand"); + assert_eq!(brand.name(), "Chromium"); + assert_eq!(brand.version(), "131.0.0.0"); + + assert_eq!( + HintsArchitecture::from_token("x86").expect("x").token(), + "x86" + ); + assert_eq!( + HintsArchitecture::from_token("arm").expect("a").token(), + "arm" + ); + + assert_eq!(HintsBitness::from_token("32").expect("b").token(), "32"); + assert_eq!(HintsBitness::from_token("64").expect("b").token(), "64"); + + assert_eq!( + HintsPlatform::normalize("Windows").expect("w").token(), + "Windows" + ); + assert_eq!( + HintsPlatform::normalize("macOS").expect("m").token(), + "macOS" + ); + assert_eq!( + HintsPlatform::normalize("Linux").expect("l").token(), + "Linux" + ); + + let hints = UaClientHints::new( + HintsPlatform::Windows, + HintsArchitecture::from_token("x86").expect("arch"), + HintsBitness::from_token("64").expect("bits"), + false, + "", + vec![brand.clone()], + ) + .expect("hints"); + assert_eq!(hints.platform(), HintsPlatform::Windows); + assert_eq!(hints.architecture(), HintsArchitecture::X86); + assert_eq!(hints.bitness(), HintsBitness::Bit64); + assert!(!hints.mobile()); + assert_eq!(hints.model(), ""); + assert_eq!(hints.brands(), [brand]); +} 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 9998d2adc..55ff1fbfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,10 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a - [ADR 0013: Manifest V3 compatibility and extension-to-Agent authority](adr/0013-manifest-v3-extension-authority.md) - [ADR 0014: Architecture decision acceptance governance](adr/0014-architecture-decision-governance.md) - [ADR 0110: Privacy-preserving presentation identity](adr/0110-privacy-preserving-presentation-identity.md) +- [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/0111-bounded-stealth-normalization-surfaces.md b/docs/adr/0111-bounded-stealth-normalization-surfaces.md new file mode 100644 index 000000000..011f2847a --- /dev/null +++ b/docs/adr/0111-bounded-stealth-normalization-surfaces.md @@ -0,0 +1,134 @@ +# ADR 0111: Bounded stealth-normalization surfaces + +- **Status:** Proposed +- **Date:** 2026-08-27 + +## Context + +Browser pages can observe more than the static profile fields modeled by +[ADR 0110](0110-privacy-preserving-presentation-identity.md): canvas pixel +readback, WebGL vendor and renderer strings, Web Audio sample-rate reporting, +and WebRTC interface-candidate exposure. Longitudinal fingerprint research +shows these rendered and media surfaces carry entropy sufficient to reidentify +a browser across sessions (Laperdrix, Bielova, Baudry, & Avoine, 2020; Cao, +Li, & Wijmans, 2017), so an adapter that controls only the static profile +leaks most of the identifying signal a page can measure. + +The W3C Fingerprinting Guidance prefers standardized, bounded values over +independent per-session randomization, because freshly randomized values can +create new distinguishers and reduce usability (World Wide Web Consortium, +2025). Camoufox is implementation precedent for native-layer consistency, not +policy authority: OriginWeave does not claim CAPTCHA bypass, bot-management +evasion, impersonation, or access-control circumvention (see +[`docs/PRD.md`](../../docs/PRD.md), PRD-CRAWL-003). + +## Decision drivers + +- Reduce the entropy available to a page from render and media surfaces + without requiring per-session randomization. +- Keep every stealth surface bound to documented, enumerated values so the + adapter can prove coverage and a reviewer can audit the value set. +- Fail closed when an adapter cannot prove it overrides a required surface. +- Keep browser authority independent from model output and page content. +- Produce deterministic evidence identities for replay and audit. +- Never read the host, never create a peer connection, and never defeat an + access-control gate. + +## Assumptions and authority boundaries + +- This ADR governs the Rust control-plane contract only. It does not select a + default stealth profile, does not read network interfaces, and does not + grant origin, transport, extension, secret, or action authority. +- The kernel never shadows/overrides a page's own choice to disclose or an + access-control decision. A CAPTCHA or consent challenge is recorded as + blocked/degraded, not solved. +- WebRTC policy is policy metadata; the kernel never acts as a peer + connection factory. + +## Options considered + +- **Expose host renderer values:** rejected because the real GPU, driver, and + audio hardware names are high-entropy reidentifiers. +- **Randomize noise per session:** rejected because W3C guidance warns fresh + random values can be more identifying and are not reproducible. +- **Provide bounded enumerated classes and require full-surface admission:** + selected. + +## Decision + +OriginWeave will model render/media stealth surfaces in the Rust fingerprint +kernel using bounded, enumerated classes and a fail-closed surface-admission +contract. This slice adds: + +- `CanvasNoise` — three bounded least-significant-bit classes with a `bit_shift` + accessor (Crisp, Smooth, Diffuse) and a strict `quantize` guard. +- `WebGlRendererToken` — canonicalization of renderer spellings to either an + `Angle` or `Standard` bounded token; unknown spellings fail closed. +- `WebAudioRate` — normalization to 44_100 or 48_000 Hz standard rates only. +- `WebRtcInterface` — either `DirectCandidates` (the adapter deliberately + exposes direct interface candidates) or `MDnsOnly` (candidates are + mDNS-published), a policy statement, never a network action. The explicit + variant naming prevents callers from mistaking direct candidate disclosure + for a privacy-preserving enabled/disabled mode. +- `require_stealth_surfaces` — requires Canvas, WebGL, WebAudio, and WebRtc + coverage in stable order, duplicative and order independent. + +The surface admission check does not itself apply the stealth; it is a +control-plane contract a future pinned Chromium adapter must prove with a +real-browser test. + +## Consequences + +The fingerprint container gains a deterministic, testable stealth surface +that is purely a contract. No real browser is yet claimed: any final adapter +must apply every listed surface before page script and prove no ambient host +value leaks. This slice does not make stealth or anti-detection a shipped +browser capability. + +## Failure and degraded behavior + +Construction rejects unknown sample rates, unknown WebGL tokens, and unknown +noise classes with typed errors. An adapter claiming fewer than all required +surfaces fails closed with the first missing surface in contract order. + +## Security, privacy, and governance impact + +The surface classes are identity evidence only; they do not authenticate, +authorize, or grant. Deterministic admission checks make adapter claims +auditable. + +## Tests and acceptance evidence + +- `stealth_noise_surface.rs` exercises full coverage and duplicate checks for + each surface, off-by-reorder, off-duplicate, empty lists, and every class + value; production functions/lines/regions/branches are covered by the + workspace coverage gate. +- `web_gl_renderer_token` canonicalization accepts known spellings and + rejects unknown renderer strings. +- Browser acceptance remains a pinned real-Chromium pre-script injection test + and is not claimed by this slice. + +## Migration and rollback + +The new surface types are additive and do not change the digest serialization +of existing `PresentationProfile`. Rollback removes the stealth surface types +and tests; no persisted schema changes are introduced. + +## Open follow-ups + +- A real pinned-Chromium adapter that applies every listed surface before page + script, with no host fallback, is required before any browser-capability + claim. +- mDNS WebRTC candidate policy requires a release-time adapter test that + cannot disclose local interface candidates. + +## Supersession / reversal conditions + +This ADR is superseded if a later decision selects per-session randomization +(cohort evidence required) or defines additional renderer/audio surfaces. +It is reversed if the surface-admission contract is removed without a +replacement. + +## References + +See [`../doctoring.md`](../doctoring.md#browser-fingerprinting-and-presentation-identity). diff --git a/docs/adr/0112-bounded-user-agent-client-hints.md b/docs/adr/0112-bounded-user-agent-client-hints.md new file mode 100644 index 000000000..d2ff84ea5 --- /dev/null +++ b/docs/adr/0112-bounded-user-agent-client-hints.md @@ -0,0 +1,101 @@ +# ADR 0112: Bounded User-Agent Client Hints surfaces + +- **Status:** Proposed +- **Date:** 2026-08-27 + +## Context + +A user agent exposes Client Hints that carry more detail than the legacy +`User-Agent` header: brand and version lists, architecture, bitness, platform, +platform version, model, and mobileness. The legacy header incurs "quite a bit +of information packed into those strings ... form[ing] the basis for +fingerprinting schemes of all sorts" (Web Platform Incubator Community Group, +2026). An adapter that presents a static `PresentationProfile` (ADR 0110) while +letting the real UA Client Hints object leak exposes a direct, reconcilable +contradiction: a page requests high-entropy hints, compares them to the +profile, and reidentifies the host. + +## Decision drivers + +- Reduce the entropy a page can recover from `navigator.userAgentData` and + the `Sec-CH-UA*` headers beyond the static profile. +- Keep every hint bounded to documented, enumerated values. +- Enforce the low-entropy rules the UA Client Hints draft itself defines + (for example, non-mobile user agents report an empty model). +- Fail closed when an adapter cannot prove a coherent hint set. +- Produce deterministic, credential-free evidence; never read the host and + never evade an access-control or CAPTCHA gate. + +## Options considered + +- **Expose host hint values:** rejected because on-disk architecture, bitness, + and model strings are re-identifying. +- **Randomize hint values per session:** rejected because W3C guidance warns + fresh random values can be more distinguishing and are not reproducible. +- **Provide bounded enumerated classes and enforce the spec's coherence + rules:** selected. + +## Decision + +OriginWeave will model UA Client Hints in the Rust fingerprint kernel using +bounded, enumerated classes plus the spec's cross-field coherence rules. This +slice adds: + +- `UaBrand` — validates one brand/version pair: ASCII alphanumeric names + (with dotted numerals in versions) and an at-most-32-char name, matching the + draft's brand grammar. +- `HintsArchitecture` (`x86`, `arm`) and `HintsBitness` (`32`, `64`) — bounded, + enumerated architecture/bitness tokens. +- `HintsPlatform::normalize` — maps to `Windows`, `macOS`, `Linux` and rejects + any other token. +- `UaClientHints::new` — requires a non-empty brand list, and requires an + empty `model` when `mobile` is false, per the draft's processing model. + +Admission checks are a control-plane contract only; they do not install a +browser or override real headers. + +## Consequences + +The fingerprint container gains a deterministic, testable UA-CH surface which +is purely a contract. No real browser is yet claimed: a future pinned Chromium +adapter must apply every listed hint surface before page script and prove no +ambient host value leaks. This does not make stealth or anti-detection a +shipped browser capability. + +## Failure and degraded behavior + +Construction rejects unknown architecture/bitness/platform tokens, over-length +or non-ASCII brand names, non-lowercase hint values where applicable, an empty +brand list, and a non-mobile set with a non-empty model. + +## Security, privacy, and governance impact + +Hints are identity evidence only and grant no origin, transport, extension, +secret, or action authority. Deterministic checks make adapter claims +auditable. + +## Tests and acceptance evidence + +`ua_client_hints_surface.rs` exercises each surface: valid and invalid brand +names and versions, every architecture/bitness/platform token and its +rejection, empty brand lists, mobile with model, and non-mobile with model. +The workspace coverage gate enforces 100% functions, lines, regions, and +branches. Browser acceptance remains out of scope. + +## Migration and rollback + +The new types are additive and do not change existing `PresentationProfile` +digests. Rollback removes the UA Client Hints types and tests without schema +changes. + +## Open follow-ups + +- A real pinned-Chromium adapter that applies the full brand/version list, + low- and high-entropy hint set, and platform coherence before page script. +- A release-time acceptance test that cannot read the host architecture or + bitness. + +## Reference + +Web Platform Incubator Community Group. (2025). *User-Agent Client Hints* +(Draft Community Group Report, 2026-02-10). https://wicg.github.io/ua-client-hints/ \ No newline at end of file diff --git a/docs/adr/0113-cross-surface-platform-coherence.md b/docs/adr/0113-cross-surface-platform-coherence.md new file mode 100644 index 000000000..db1281c32 --- /dev/null +++ b/docs/adr/0113-cross-surface-platform-coherence.md @@ -0,0 +1,85 @@ +# ADR 0113: Cross-surface platform coherence + +- **Status:** Proposed +- **Date:** 2026-08-27 + +## Context + +A page can reconcile several surfaces into one browser identity: the static +presentation profile (ADR 0110), the JavaScript `navigator.platform` token, +and the UA Client Hints platform object (ADR 0112). If an adapter presents a +`Windows` presentation profile but a `macOS` UA Client Hints platform, the +contradiction is itself a reidentification signal and negates the privacy +benefit of bounding each surface independently. Camoufox-style stealth +requires cross-surface coherence: every observable surface must describe the +same platform, or the union of surfaces leaks more than any single surface. + +## Decision drivers + +- Guarantee the presentation platform, its UA token, and the UA Client Hints + platform always agree. +- Keep the mapping deterministic and enumerated so an adapter cannot widen it. +- Fail closed on any mismatch; never read the host. +- Produce a single source of truth for the platform-to-hints mapping. + +## Options considered + +- **Let the adapter choose hints independently:** rejected because the + platform surfaces could contradict. +- **Duplicate the mapping in each module:** rejected because a reviewer could + not prove the maps agree. +- **Bind the hints platform to the presentation platform in one method and a + fail-closed coherence check:** selected. + +## Decision + +`PresentationPlatform::hints_platform` is the single source of truth mapping +each presentation platform to its canonical UA Client Hints platform +(`Windows` -> `Windows`, `MacOS` -> `macOS`, `Linux` -> `Linux`). +`require_hints_coherence` rejects any `UaClientHints` whose platform differs +from the canonical mapping for the presented platform. The existing +`user_agent_token` mapping completes the triad, so a page reconciling +`navigator.platform`, `userAgentData.platform`, and the profile cannot observe +a cross-surface contradiction. + +## Consequences + +Adapters that call `require_hints_coherence` before presenting a profile prove +platform agreement as a checked precondition. This remains a pure +control-plane contract; it does not install a browser or override real +surfaces. + +## Failure and degraded behavior + +Any hints platform other than the canonical mapping for the presented +platform returns `CoherenceError::HintsPlatformMismatch`. + +## Security, privacy, and governance impact + +The coherence check is identity evidence only and grants no origin, transport, +extension, secret, or action authority. It makes the platform triad auditable +and non-contradictory. + +## Tests and acceptance evidence + +`profile_coherence_surface.rs` covers every accepted mapping, every mismatch +across platforms, the canonical `hints_platform` and `user_agent_token` +mappings, the deterministic error text, and a round-trip coherence check. +The workspace coverage gate enforces 100% functions, lines, regions, and +branches. Browser acceptance remains out of scope. + +## Migration and rollback + +The new method and function are additive. Rollback removes them and the tests +without schema changes or digest impact. + +## Open follow-ups + +- A real pinned-Chromium adapter must prove the triad is applied before page + script and that no ambient host value leaks. +- Subsequent cross-surface coherence (for example viewport-to-screen or + language-to-platform) can reuse the same fail-closed pattern. + +## References + +See [`../doctoring.md`](../doctoring.md#browser-fingerprinting-and-presentation-identity). \ No newline at end of file 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 `