diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ec06f02..4cc4096e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ 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 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). - 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. diff --git a/crates/originweave-fingerprint/src/lib.rs b/crates/originweave-fingerprint/src/lib.rs index cfc25f99b..29f2d8bdd 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -21,6 +21,17 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod stealth; +mod ua_hints; + +pub use stealth::{ + CanvasNoise, StealthError, StealthSurface, WebAudioRate, WebGlRendererToken, WebRtcInterface, + require_stealth_surfaces, +}; +pub use ua_hints::{ + ClientHintsError, HintsArchitecture, HintsBitness, HintsPlatform, UaBrand, UaClientHints, +}; + use sha2::{Digest, Sha256}; use std::error::Error; use std::fmt; diff --git a/crates/originweave-fingerprint/src/stealth.rs b/crates/originweave-fingerprint/src/stealth.rs new file mode 100644 index 000000000..a15230854 --- /dev/null +++ b/crates/originweave-fingerprint/src/stealth.rs @@ -0,0 +1,209 @@ +//! 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; + +/// Maximum renderer spelling length normalized before token classification. +const MAX_WEBGL_RENDERER_SPELLING_BYTES: usize = 256; + +/// 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 { + if spelling.len() > MAX_WEBGL_RENDERER_SPELLING_BYTES { + return None; + } + 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..09ba035fc --- /dev/null +++ b/crates/originweave-fingerprint/src/ua_hints.rs @@ -0,0 +1,315 @@ +//! 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 bytes. +const MAX_BRAND_NAME_LENGTH: usize = 32; + +/// The maximum accepted brand-version length in ASCII bytes. +const MAX_BRAND_VERSION_LENGTH: usize = 32; + +/// The maximum number of brand/version pairs retained in one UA-CH surface. +const MAX_BRAND_COUNT: usize = 16; + +/// The maximum accepted mobile-model length in UTF-8 bytes. +const MAX_MOBILE_MODEL_LENGTH: usize = 64; + +/// WICG GREASE-compatible separators admitted inside bounded brand names. +const BRAND_COMPATIBILITY_SEPARATORS: &[u8] = b" ()-./:;=?_"; + +fn is_valid_brand_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || BRAND_COMPATIBILITY_SEPARATORS.contains(&byte) +} + +/// 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 version exceeded the OriginWeave resource budget. + BrandVersionTooLong, + /// A brand name or version violated the bounded compatibility grammar. + InvalidBrandName, + /// A platform token was outside the enumerated low-entropy set. + InvalidPlatform, + /// A non-mobile user agent reported a non-empty model. + ModelWithoutMobile, + /// A mobile model exceeded the OriginWeave resource budget. + ModelTooLong, + /// A client-hints set carried no brand. + MissingBrand, + /// A client-hints set exceeded the bounded retained brand-list size. + TooManyBrands, +} + +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::BrandVersionTooLong => { + formatter.write_str("brand version must be at most 32 ASCII characters") + } + Self::InvalidBrandName => formatter.write_str( + "brand name must use bounded UA-CH-compatible ASCII and version must be non-empty dotted ASCII alphanumeric", + ), + 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::ModelTooLong => formatter.write_str("mobile model must be at most 64 bytes"), + Self::MissingBrand => { + formatter.write_str("a client-hints value must contain at least one brand") + } + Self::TooManyBrands => { + formatter.write_str("a client-hints value must contain at most 16 brands") + } + } + } +} + +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 must be non-empty ASCII and may contain alphanumerics plus the + /// separator bytes used by the WICG GREASE brand algorithm. Versions must + /// be non-empty dotted ASCII alphanumeric strings. The 32-byte name and + /// version caps are OriginWeave resource bounds, not UA Client Hints + /// specification limits. + pub fn new(name: &str, version: &str) -> Result { + if name.len() > MAX_BRAND_NAME_LENGTH { + return Err(ClientHintsError::BrandTooLong); + } + if version.len() > MAX_BRAND_VERSION_LENGTH { + return Err(ClientHintsError::BrandVersionTooLong); + } + if name.is_empty() + || !name.bytes().all(is_valid_brand_name_byte) + || version.is_empty() + || !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. Mobile model values are + /// capped at 64 UTF-8 bytes by OriginWeave's local resource budget. The + /// brand list must contain between one and 16 already-validated brands, so + /// retained presentation state cannot grow with an unbounded caller list. + 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 model.len() > MAX_MOBILE_MODEL_LENGTH { + return Err(ClientHintsError::ModelTooLong); + } + if brands.is_empty() { + return Err(ClientHintsError::MissingBrand); + } + if brands.len() > MAX_BRAND_COUNT { + return Err(ClientHintsError::TooManyBrands); + } + 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/tests/stealth_noise_surface.rs b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs new file mode 100644 index 000000000..cba0306ca --- /dev/null +++ b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs @@ -0,0 +1,146 @@ +//! 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 oversized_web_gl_renderer_spellings_fail_closed_before_normalization() { + let oversized = format!("ANGLE{}", "X".repeat(252)); + assert_eq!(oversized.len(), 257); + assert_eq!(WebGlRendererToken::canonical(&oversized), 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..6436d2ea7 --- /dev/null +++ b/crates/originweave-fingerprint/tests/ua_client_hints_surface.rs @@ -0,0 +1,314 @@ +//! 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 ua_brand_accepts_realistic_chromium_and_grease_names() { + assert!(UaBrand::new("Google Chrome", "131").is_ok()); + assert!(UaBrand::new("Not/A)Brand", "99").is_ok()); + assert!(UaBrand::new("Not_A Brand", "24.0.0.0").is_ok()); +} + +#[test] +fn empty_brand_name_or_version_fails_closed() { + assert_eq!( + UaBrand::new("", "131").expect_err("empty brand name"), + ClientHintsError::InvalidBrandName + ); + assert_eq!( + UaBrand::new("Chromium", "").expect_err("empty brand version"), + ClientHintsError::InvalidBrandName + ); +} + +#[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_versions_over_resource_limit_fail_closed() { + let boundary_version = "1".repeat(32); + assert!(UaBrand::new("Chromium", &boundary_version).is_ok()); + + let long_version = "1".repeat(33); + assert_eq!( + UaBrand::new("Chromium", &long_version).expect_err("long version"), + ClientHintsError::BrandVersionTooLong + ); +} + +#[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 mobile_models_over_resource_limit_fail_closed() { + let brand = UaBrand::new("Chromium", "131.0.0.0").expect("brand"); + let boundary_model = "M".repeat(64); + assert!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::Arm, + HintsBitness::Bit64, + true, + &boundary_model, + vec![brand.clone()], + ) + .is_ok() + ); + + let long_model = "M".repeat(65); + assert_eq!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::Arm, + HintsBitness::Bit64, + true, + &long_model, + vec![brand], + ), + Err(ClientHintsError::ModelTooLong) + ); +} + +#[test] +fn non_mobile_model_semantics_precede_model_length_budget() { + let long_model = "M".repeat(65); + assert_eq!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::Arm, + HintsBitness::Bit64, + false, + &long_model, + vec![UaBrand::new("Chromium", "131.0.0.0").expect("brand")], + ), + Err(ClientHintsError::ModelWithoutMobile) + ); +} + +#[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 brand_list_is_bounded_to_sixteen_entries() { + let brand = UaBrand::new("Chromium", "131.0.0.0").expect("brand"); + let boundary = vec![brand.clone(); 16]; + assert!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::X86, + HintsBitness::Bit64, + false, + "", + boundary, + ) + .is_ok() + ); + + let oversized = vec![brand; 17]; + assert_eq!( + UaClientHints::new( + HintsPlatform::Linux, + HintsArchitecture::X86, + HintsBitness::Bit64, + false, + "", + oversized, + ), + Err(ClientHintsError::TooManyBrands) + ); +} + +#[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::BrandVersionTooLong.to_string(), + "brand version must be at most 32 ASCII characters" + ); + assert_eq!( + ClientHintsError::ModelTooLong.to_string(), + "mobile model must be at most 64 bytes" + ); + assert_eq!( + ClientHintsError::InvalidBrandName.to_string(), + "brand name must use bounded UA-CH-compatible ASCII and version must be non-empty dotted ASCII alphanumeric" + ); + assert_eq!( + ClientHintsError::MissingBrand.to_string(), + "a client-hints value must contain at least one brand" + ); + assert_eq!( + ClientHintsError::TooManyBrands.to_string(), + "a client-hints value must contain at most 16 brands" + ); +} + +#[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/docs/README.md b/docs/README.md index 9998d2adc..622fc7e99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,8 @@ 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) 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..f3716e0dd --- /dev/null +++ b/docs/adr/0111-bounded-stealth-normalization-surfaces.md @@ -0,0 +1,136 @@ +# 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; spellings over 256 UTF-8 bytes are + rejected before case normalization and 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, renderer +spellings over the 256-byte normalization budget, 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..0001e463b --- /dev/null +++ b/docs/adr/0112-bounded-user-agent-client-hints.md @@ -0,0 +1,153 @@ +# 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 or explicit local + resource ceilings. +- Enforce the low-entropy rules the UA Client Hints draft itself defines + (for example, non-mobile user agents report an empty model). +- Admit realistic Chromium brand lists, including ordinary multi-word brands + and the punctuation used by the draft's GREASE algorithm, without widening + the contract to arbitrary Unicode or control bytes. +- 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. + +## Assumptions and authority boundaries + +- This ADR governs a Rust control-plane identity contract only. It does not + install a browser, intercept page script, override request headers, or read + host architecture, bitness, platform, or model values. +- UA-CH values are presentation evidence, not authority. They grant no origin, + destination, transport, extension, secret, approval, or agent-action right. +- An eventual Chromium adapter must prove that its low- and high-entropy UA-CH + values and request headers are coherent with the selected presentation + profile before page script can observe them. Until that adapter evidence + exists, this metadata contract must not be described as shipped browser + anti-fingerprinting or anti-detection behavior. +- Access-control, CAPTCHA, consent, and bot-management outcomes remain external + policy decisions. This contract never treats a challenge as something to + bypass. + +## 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 non-empty brand/version pair. Brand names admit + ASCII alphanumerics plus the separator bytes used by the WICG GREASE brand + algorithm (`SP`, `(`, `)`, `-`, `.`, `/`, `:`, `;`, `=`, `?`, `_`), so + values such as `Google Chrome`, `Not/A)Brand`, and `Not_A Brand` remain + representable. Versions are non-empty dotted ASCII alphanumeric strings. + Brand names and versions are each capped at 32 ASCII bytes as OriginWeave + resource bounds; neither ceiling is a UA Client Hints specification limit. +- `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 one through 16 validated brands, requires an + empty `model` when `mobile` is false per the draft's processing model, and + caps a mobile model at 64 UTF-8 bytes. The 16-brand and 64-byte model limits + are OriginWeave resource bounds rather than UA Client Hints specification + limits. + +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 +brand names or versions, empty brand names or versions, brand bytes outside the +bounded compatibility set, version bytes outside dotted ASCII alphanumeric +syntax, over-length mobile model values, an empty brand list, a brand list with +more than 16 entries, and a non-mobile set with a non-empty model. The +non-mobile empty-model coherence rule is checked before the local model-size +ceiling so a contradictory non-mobile identity retains its semantic failure +class even when its model string is also too long. + +## 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. The admitted brand-name separators are a reviewed compatibility +set from the current WICG GREASE algorithm rather than an unbounded printable +ASCII allowance; quote, backslash, controls, and Unicode remain rejected. The +32-byte brand/version, 16-entry brand-list, and 64-byte mobile-model ceilings +are local resource budgets and must not be presented as requirements of the +WICG specification. + +## Tests and acceptance evidence + +`ua_client_hints_surface.rs` exercises each surface: ordinary and realistic +Chromium/GREASE brand names, empty and invalid brand/version values, the local +brand-name and brand-version length bounds, every architecture/bitness/platform +token and its rejection, empty brand lists, the 16-entry retained brand-list +boundary, the mobile-model resource bound, mobile with model, and non-mobile +with model including semantic-error precedence. 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. Within this proposed branch, the constructors now reject brand +versions above 32 ASCII bytes, brand lists above 16 entries, and mobile models +above 64 UTF-8 bytes instead of retaining unbounded presentation strings or +lists. 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. + +## Supersession / reversal conditions + +This ADR is superseded if a later reviewed decision defines a different +UA Client Hints presentation model, adds a cohort-backed default selection +contract, or moves the authoritative coherence boundary into a pinned browser +adapter with equivalent fail-closed evidence. It is reversed if OriginWeave +stops claiming a bounded UA-CH presentation surface and removes these types +and tests without a replacement. + +## References + +Web Platform Incubator Community Group. (2026, February 10). *User-Agent Client Hints* +(Draft Community Group Report). https://wicg.github.io/ua-client-hints/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 13bd22be5..a9fffa042 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,8 +55,10 @@ Proposed ADR files are reviewable target architecture without becoming Accepted | [0013](0013-manifest-v3-extension-authority.md) | Manifest V3 compatibility and extension-to-Agent authority | Proposed | Chromium extension compatibility evidence, profile separation, extension grants, native-messaging boundary and release claims | | [0014](0014-architecture-decision-governance.md) | Architecture decision acceptance governance | Proposed | ADR lifecycle authority, reviewer eligibility, solo-maintainer hold and re-enablement conditions | | [0110](0110-privacy-preserving-presentation-identity.md) | Privacy-preserving presentation identity | Proposed | bounded normalization without access-control evasion | +| [0111](0111-bounded-stealth-normalization-surfaces.md) | Bounded stealth-normalization surfaces | Proposed | canvas/WebGL/WebAudio/WebRTC bounded enumerated classes and surface admission | +| [0112](0112-bounded-user-agent-client-hints.md) | Bounded User-Agent Client Hints | Proposed | UA-CH bounded enumerated tokens, brand grammar, and cross-field coherence | -ADR 0013, ADR 0014, and ADR 0110 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; all three decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +ADR 0013, ADR 0014, ADR 0110, ADR 0111, and ADR 0112 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; all five decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. ### Proposed decisions introduced by active feature work diff --git a/docs/doctoring.md b/docs/doctoring.md index f7ae47786..aad7c13c7 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -35,7 +35,18 @@ cited cohort evidence defines a meaningful anonymity set. A future Chromium adapter must apply all claimed surfaces before page script and prove that no ambient host value leaks. Camoufox is reviewed only as implementation precedent for native-layer consistency, not as policy authority for anti-detect, CAPTCHA, -or access-control circumvention. +or access-control circumvention. Render and media surfaces (canvas readback, +WebGL renderer tokens, Web Audio sample rate, WebRTC interface exposure) are +themselves strong re-identification signals (Laperdrix et al., 2020), so +OriginWeave models them as bounded enumerated classes with a fail-closed +surface-admission contract (see ADR 0110, ADR 0111) rather than per-session +randomization, which W3C guidance warns can create new distinguishers. The +legacy `User-Agent` header packs "quite a bit of information ... [that] form[s] +the basis for fingerprinting schemes of all sorts" (Web Platform Incubator +Community Group, 2026), so OriginWeave bounds the User-Agent Client Hints +object with enumerated architecture/bitness/platform tokens, an at-most-32 +ASCII brand-name limit, a non-empty brand list, and the draft's coherence rule +that a non-mobile user agent reports an empty model (see ADR 0112). The 25 August 2026 WebDriver BiDi Editor's Draft exposes locale, media, screen, user-agent, viewport, and time-zone emulation commands, but it does not define a @@ -228,6 +239,8 @@ Unicode-RS Project Developers. (2025). *unicode-normalization 0.1.25* [Computer Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ +Web Platform Incubator Community Group. (2026, February 10). *User-Agent Client Hints* (Draft Community Group Report). https://wicg.github.io/ua-client-hints/ + World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ World Wide Web Consortium. (2025, September 25). *Mitigating browser fingerprinting in Web specifications*. https://www.w3.org/TR/fingerprinting-guidance/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 240f91c60..9c470f9c1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,6 +42,8 @@ Representative active workstreams at this snapshot were: |---|---|---| | Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | | Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | +| Stealth surface normalization | stacked `feat/stealth-normalize-surfaces` on #229 | Proposed ADR 0111 adds bounded canvas-noise classes, canonicalized WebGL renderer tokens, standard-rate Web Audio normalization, bounded WebRTC interface policy, and fail-closed Canvas/WebGL/WebAudio/WebRtc surface admission; control-plane contract only, no real-browser or anti-evasion claim | +| UA Client Hints surface | stacked `feat/ua-client-hints-surface` on #233 | Proposed ADR 0112 bounds the `navigator.userAgentData` / `Sec-CH-UA*` surface: ASCII brand grammar with 32-char name bound, enumerated architecture/bitness/platform tokens, non-empty brand list, and the draft rule that non-mobile user agents report an empty model; control-plane contract only, no browser claim | | Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | | Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | | Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | diff --git a/tests/test_adr_index_provenance.py b/tests/test_adr_index_provenance.py index 2fcc88541..08a551be8 100644 --- a/tests/test_adr_index_provenance.py +++ b/tests/test_adr_index_provenance.py @@ -21,9 +21,15 @@ def test_presentation_identity_adr_is_branch_only_until_integration(self) -> Non "### Proposed decisions introduced by documentation reconciliation", 1 )[1].split("## Index completeness rule", 1)[0] adr = "[0110](0110-privacy-preserving-presentation-identity.md)" + adr_stealth = "[0111](0111-bounded-stealth-normalization-surfaces.md)" + adr_ua_hints = "[0112](0112-bounded-user-agent-client-hints.md)" self.assertNotIn(adr, baseline) self.assertIn(adr, branch_only) + self.assertNotIn(adr_stealth, baseline) + self.assertIn(adr_stealth, branch_only) + self.assertNotIn(adr_ua_hints, baseline) + self.assertIn(adr_ua_hints, branch_only) if __name__ == "__main__":