From 69215b13a7c048a2ce72c9a8c904f987c5b0c19f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:57:36 +0900 Subject: [PATCH 01/34] feat(fingerprint): add bounded stealth-normalization surfaces Add CanvasNoise classes, canonicalized WebGL renderer tokens, standard Web Audio normalization, bounded WebRTC interface policy, and a fail-closed Canvas/WebGL/WebAudio/WebRtc surface-admission contract. Test-first: the stealth_noise_surface integration tests fail against an empty crate and pass once the module lands. Production functions, lines, regions, and branches remain fully covered by the workspace coverage gate. --- crates/originweave-fingerprint/src/lib.rs | 7 + crates/originweave-fingerprint/src/stealth.rs | 201 ++++++++++++++++++ .../tests/stealth_noise_surface.rs | 129 +++++++++++ 3 files changed, 337 insertions(+) create mode 100644 crates/originweave-fingerprint/src/stealth.rs create mode 100644 crates/originweave-fingerprint/tests/stealth_noise_surface.rs diff --git a/crates/originweave-fingerprint/src/lib.rs b/crates/originweave-fingerprint/src/lib.rs index cfc25f99b..2960e649e 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -21,6 +21,13 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod stealth; + +pub use stealth::{ + CanvasNoise, StealthError, StealthSurface, WebAudioRate, WebGlRendererToken, WebRtcInterface, + require_stealth_surfaces, +}; + 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..12e45e04b --- /dev/null +++ b/crates/originweave-fingerprint/src/stealth.rs @@ -0,0 +1,201 @@ +//! 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. + /// + /// 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.starts_with("ANGLE") { + Some(Self::Angle) + } else if upper.contains("SOFTWARE") { + Some(Self::Standard) + } 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. It distinguishes explicit disclosure from mDNS-only +/// candidates so adapters fail closed unless they retain a route that hides +/// host scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebRtcInterface { + /// The adapter deliberately exposes interface candidates. + Disabled, + /// 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::Disabled) + } +} 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..1f33634cc --- /dev/null +++ b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs @@ -0,0 +1,129 @@ +//! 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("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_is_bounded_and_never_permits_leakage() { + assert!(WebRtcInterface::Disabled.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")); + } +} From 9ca8e7f9253f422eea433b3a32ba806c409bdf0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:57:45 +0900 Subject: [PATCH 02/34] docs(adr): record bounded stealth-normalization surfaces Add ADR 0111 for the bounded canvas/WebGL/WebAudio/WebRtc enumerated classes and fail-closed surface admission, and index it as a Proposed branch-only decision in both the ADR index and the documentation index. The Python ADR-provenance contract now also guards ADR 0111 placement. --- docs/README.md | 1 + ...-bounded-stealth-normalization-surfaces.md | 133 ++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 135 insertions(+) create mode 100644 docs/adr/0111-bounded-stealth-normalization-surfaces.md diff --git a/docs/README.md b/docs/README.md index 9998d2adc..d0d0a1c56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,7 @@ 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) 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..d574269eb --- /dev/null +++ b/docs/adr/0111-bounded-stealth-normalization-surfaces.md @@ -0,0 +1,133 @@ +# 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 `Disabled` (the adapter exposes candidates) or + `MDnsOnly` (candidates are mDNS-published), a policy statement, never a + network action. +- `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 +which it 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 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). \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md index 13bd22be5..9fa795650 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,6 +55,7 @@ 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 | 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. From 7f0368bc6b33e056ef382004110f15ae2b5f1bfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:57:53 +0900 Subject: [PATCH 03/34] docs(doctoring): cite render/media surface entropy for stealth bounds Extend the browser-fingerprinting section with the render/media surface re-identification basis (Laperdrix et al., 2020) and the bounded-class over per-session-randomization decision (W3C, 2025), and guard ADR 0111 provenance in the Python documentation contract. --- docs/doctoring.md | 7 ++++++- tests/test_adr_index_provenance.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index f7ae47786..2984f6d56 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -35,7 +35,12 @@ 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 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 diff --git a/tests/test_adr_index_provenance.py b/tests/test_adr_index_provenance.py index 2fcc88541..24a8ca0aa 100644 --- a/tests/test_adr_index_provenance.py +++ b/tests/test_adr_index_provenance.py @@ -21,9 +21,12 @@ 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)" self.assertNotIn(adr, baseline) self.assertIn(adr, branch_only) + self.assertNotIn(adr_stealth, baseline) + self.assertIn(adr_stealth, branch_only) if __name__ == "__main__": From 2814e2889d6230be2ae5c76d8c410866e0b66add Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:57:53 +0900 Subject: [PATCH 04/34] docs(changelog): record bounded stealth-normalization surfaces Add the stealth surface slice to the changelog Added section and to the product-technical-gap-baseline active workstream table as Proposed ADR 0111 control-plane evidence with no real-browser or anti-evasion claim. --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ec06f02..fa2e6d0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 240f91c60..4b2b6539e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,6 +42,7 @@ 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 | | 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 | From f4560c9977587168096d379005fb26beb7a2fdf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:05:39 -0700 Subject: [PATCH 05/34] test(fingerprint): require explicit WebRTC disclosure naming --- crates/originweave-fingerprint/tests/stealth_noise_surface.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-fingerprint/tests/stealth_noise_surface.rs b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs index 1f33634cc..0f4affbfd 100644 --- a/crates/originweave-fingerprint/tests/stealth_noise_surface.rs +++ b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs @@ -91,8 +91,8 @@ fn web_audio_rates_normalize_only_standard_rates() { } #[test] -fn web_rtc_interface_policy_is_bounded_and_never_permits_leakage() { - assert!(WebRtcInterface::Disabled.exposes_candidates()); +fn web_rtc_interface_policy_names_direct_candidate_disclosure_explicitly() { + assert!(WebRtcInterface::DirectCandidates.exposes_candidates()); assert!(!WebRtcInterface::MDnsOnly.exposes_candidates()); } From e1577e56fcfa5f8a27e71bef4321e69f872971af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:06:14 -0700 Subject: [PATCH 06/34] fix(fingerprint): make WebRTC disclosure policy explicit --- crates/originweave-fingerprint/src/stealth.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-fingerprint/src/stealth.rs b/crates/originweave-fingerprint/src/stealth.rs index 12e45e04b..2be1c7090 100644 --- a/crates/originweave-fingerprint/src/stealth.rs +++ b/crates/originweave-fingerprint/src/stealth.rs @@ -181,13 +181,13 @@ impl WebAudioRate { /// A bounded WebRTC interface-candidate policy. /// /// This is policy only; the kernel never creates a peer connection or exposes -/// an address. It distinguishes explicit disclosure from mDNS-only -/// candidates so adapters fail closed unless they retain a route that hides -/// host scope. +/// 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 interface candidates. - Disabled, + /// The adapter deliberately exposes direct interface candidates. + DirectCandidates, /// The adapter publishes only mDNS-candidate interfaces. MDnsOnly, } @@ -196,6 +196,6 @@ impl WebRtcInterface { /// Whether this policy exposes local interface candidates directly. #[must_use] pub fn exposes_candidates(self) -> bool { - matches!(self, Self::Disabled) + matches!(self, Self::DirectCandidates) } } From 0202e1338af647f10494dd3e353c68e4ce97dccf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:07:26 -0700 Subject: [PATCH 07/34] docs(adr): align WebRTC policy naming --- ...-bounded-stealth-normalization-surfaces.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/adr/0111-bounded-stealth-normalization-surfaces.md b/docs/adr/0111-bounded-stealth-normalization-surfaces.md index d574269eb..011f2847a 100644 --- a/docs/adr/0111-bounded-stealth-normalization-surfaces.md +++ b/docs/adr/0111-bounded-stealth-normalization-surfaces.md @@ -65,9 +65,11 @@ contract. This slice adds: - `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 `Disabled` (the adapter exposes candidates) or - `MDnsOnly` (candidates are mDNS-published), a policy statement, never a - network action. +- `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. @@ -78,17 +80,16 @@ real-browser test. ## Consequences The fingerprint container gains a deterministic, testable stealth surface -which it 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. +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 noise classes with typed errors. An adapter claiming fewer than all -required surfaces fails closed with the first missing surface in contract -order. +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 @@ -130,4 +131,4 @@ replacement. ## References -See [`../doctoring.md`](../doctoring.md#browser-fingerprinting-and-presentation-identity). \ No newline at end of file +See [`../doctoring.md`](../doctoring.md#browser-fingerprinting-and-presentation-identity). From 9b1c44ea80fe46e4495ce86d88eb9046fe9bfc1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:08:52 -0700 Subject: [PATCH 08/34] test(fingerprint): classify ANGLE software renderers --- .../tests/stealth_noise_surface.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-fingerprint/tests/stealth_noise_surface.rs b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs index 0f4affbfd..329f7208a 100644 --- a/crates/originweave-fingerprint/tests/stealth_noise_surface.rs +++ b/crates/originweave-fingerprint/tests/stealth_noise_surface.rs @@ -77,6 +77,16 @@ fn web_gl_renderer_tokens_are_bounded_and_standardized() { 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); } From 38557846816eec7394714618d3674bc5f274e52a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:09:24 -0700 Subject: [PATCH 09/34] fix(fingerprint): prioritize software renderer markers --- crates/originweave-fingerprint/src/stealth.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-fingerprint/src/stealth.rs b/crates/originweave-fingerprint/src/stealth.rs index 2be1c7090..4c394337f 100644 --- a/crates/originweave-fingerprint/src/stealth.rs +++ b/crates/originweave-fingerprint/src/stealth.rs @@ -134,15 +134,17 @@ pub enum WebGlRendererToken { 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.starts_with("ANGLE") { - Some(Self::Angle) - } else if upper.contains("SOFTWARE") { + if upper.contains("SOFTWARE") || upper.contains("SWIFTSHADER") { Some(Self::Standard) + } else if upper.starts_with("ANGLE") { + Some(Self::Angle) } else { None } From a0c7924c7add4807b6afec4eaa1fca977ba310e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:13:29 -0700 Subject: [PATCH 10/34] docs(adr): include ADR 0111 in branch provenance --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 9fa795650..4787cd7b8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,7 +57,7 @@ Proposed ADR files are reviewable target architecture without becoming Accepted | [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 | -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, and ADR 0111 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; all four decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. ### Proposed decisions introduced by active feature work From dc1c209ef1a748870593fea6b90a5e0cc6b50a9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:17:09 +0900 Subject: [PATCH 11/34] feat(fingerprint): add bounded UA Client Hints surfaces Add UaBrand, HintsArchitecture, HintsBitness, HintsPlatform, and UaClientHints contracts bound to the User-Agent Client Hints draft (WICG, 2026): at-most-32-char ASCII brand names, enumerated architecture/bitness/platform tokens, required non-empty brand list, and the spec rule that a non-mobile user agent reports an empty model. Test-first: ua_client_hints_surface fails on unresolved imports and passes once the module lands; production functions, lines, regions, and branches remain 100% covered. --- crates/originweave-fingerprint/src/lib.rs | 4 + .../originweave-fingerprint/src/ua_hints.rs | 271 ++++++++++++++++++ .../tests/ua_client_hints_surface.rs | 195 +++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 crates/originweave-fingerprint/src/ua_hints.rs create mode 100644 crates/originweave-fingerprint/tests/ua_client_hints_surface.rs diff --git a/crates/originweave-fingerprint/src/lib.rs b/crates/originweave-fingerprint/src/lib.rs index 2960e649e..29f2d8bdd 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -22,11 +22,15 @@ #![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; 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/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]); +} From f0ba4ed347681e955db60b1c358aed3255296c2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:22:19 +0900 Subject: [PATCH 12/34] docs(adr): record bounded User-Agent Client Hints surfaces Add ADR 0112 for the bounded UA-CH surface (ASCII brand grammar with a 32-char name bound, enumerated architecture/bitness/platform tokens, non-empty brand list, empty-model rule for non-mobile user agents) and index it as Proposed branch-only in both documentation indexes. The Python ADR-provenance contract now guards ADR 0112 placement. --- docs/README.md | 1 + .../0112-bounded-user-agent-client-hints.md | 101 ++++++++++++++++++ docs/adr/README.md | 1 + tests/test_adr_index_provenance.py | 3 + 4 files changed, 106 insertions(+) create mode 100644 docs/adr/0112-bounded-user-agent-client-hints.md diff --git a/docs/README.md b/docs/README.md index d0d0a1c56..622fc7e99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,6 +86,7 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a - [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/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/README.md b/docs/adr/README.md index 4787cd7b8..780a444c4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -56,6 +56,7 @@ Proposed ADR files are reviewable target architecture without becoming Accepted | [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, ADR 0110, and ADR 0111 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; all four decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. diff --git a/tests/test_adr_index_provenance.py b/tests/test_adr_index_provenance.py index 24a8ca0aa..08a551be8 100644 --- a/tests/test_adr_index_provenance.py +++ b/tests/test_adr_index_provenance.py @@ -22,11 +22,14 @@ def test_presentation_identity_adr_is_branch_only_until_integration(self) -> Non )[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__": From 6c175b2fc991897dbf7ae90da2f738cb26580f98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:22:19 +0900 Subject: [PATCH 13/34] docs(doctoring): cite UA Client Hints draft for bounded UA-CH surfaces Add the WICG User-Agent Client Hints Draft Community Group Report (2026) reference and the legacy-User-Agent fingerprinting rationale to doctoring, and record the UA-CH slice in the changelog and product gap baseline. --- CHANGELOG.md | 1 + docs/doctoring.md | 10 +++++++++- docs/product-technical-gap-baseline.md | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2e6d0f1..874589557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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, 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/docs/doctoring.md b/docs/doctoring.md index 2984f6d56..aad7c13c7 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -40,7 +40,13 @@ 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. +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 @@ -233,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 4b2b6539e..9c470f9c1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -43,6 +43,7 @@ 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 | From 16b7b382aa08551d26b3503940cdbc237f4558e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:40:16 +0900 Subject: [PATCH 14/34] feat(fingerprint): bind presentation and UA-CH platform coherence Add PresentationPlatform::hints_platform as the single source of truth for the presentation-to-hints platform mapping and require_hints_coherence, which fails closed when a UaClientHints platform contradicts the presented presentation platform. This closes the classic cross-surface reidentification hole where a page reconciles navigator.platform, userAgentData.platform, and the profile into one identity. Test-first: profile_coherence_surface fails on unresolved imports and passes once the module lands; production functions, lines, regions, and branches remain 100% covered. --- .../originweave-fingerprint/src/coherence.rs | 47 ++++++++ crates/originweave-fingerprint/src/lib.rs | 16 +++ .../tests/profile_coherence_surface.rs | 112 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 crates/originweave-fingerprint/src/coherence.rs create mode 100644 crates/originweave-fingerprint/tests/profile_coherence_surface.rs 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 29f2d8bdd..7c73b0f5a 100644 --- a/crates/originweave-fingerprint/src/lib.rs +++ b/crates/originweave-fingerprint/src/lib.rs @@ -21,9 +21,11 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod coherence; mod stealth; mod ua_hints; +pub use coherence::{CoherenceError, require_hints_coherence}; pub use stealth::{ CanvasNoise, StealthError, StealthSurface, WebAudioRate, WebGlRendererToken, WebRtcInterface, require_stealth_surfaces, @@ -293,6 +295,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/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(()) + ); + } +} From 8a8c673dc6aacb1fab74089e4ba3e4186b4115fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:40:29 +0900 Subject: [PATCH 15/34] docs(adr): record cross-surface platform coherence Add ADR 0113 for the presentation-platform/UA-token/UA-CH-platform triad agreement and index it as Proposed branch-only in both documentation indexes. The Python ADR-provenance contract now guards ADR 0113 placement. --- docs/README.md | 1 + .../0113-cross-surface-platform-coherence.md | 85 +++++++++++++++++++ docs/adr/README.md | 1 + tests/test_adr_index_provenance.py | 3 + 4 files changed, 90 insertions(+) create mode 100644 docs/adr/0113-cross-surface-platform-coherence.md diff --git a/docs/README.md b/docs/README.md index 622fc7e99..f9529978a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,6 +87,7 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a - [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) 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/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/README.md b/docs/adr/README.md index 780a444c4..d05ff3d67 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,7 @@ Proposed ADR files are reviewable target architecture without becoming Accepted | [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 | +| [0113](0113-cross-surface-platform-coherence.md) | Cross-surface platform coherence | Proposed | presentation-platform, UA-token, and UA-CH-platform triad agreement | ADR 0013, ADR 0014, ADR 0110, and ADR 0111 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; all four decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. diff --git a/tests/test_adr_index_provenance.py b/tests/test_adr_index_provenance.py index 08a551be8..c374f9313 100644 --- a/tests/test_adr_index_provenance.py +++ b/tests/test_adr_index_provenance.py @@ -23,6 +23,7 @@ def test_presentation_identity_adr_is_branch_only_until_integration(self) -> Non 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)" + adr_coherence = "[0113](0113-cross-surface-platform-coherence.md)" self.assertNotIn(adr, baseline) self.assertIn(adr, branch_only) @@ -30,6 +31,8 @@ def test_presentation_identity_adr_is_branch_only_until_integration(self) -> Non self.assertIn(adr_stealth, branch_only) self.assertNotIn(adr_ua_hints, baseline) self.assertIn(adr_ua_hints, branch_only) + self.assertNotIn(adr_coherence, baseline) + self.assertIn(adr_coherence, branch_only) if __name__ == "__main__": From bcda8857d235abdcd733569c6d26b80c18420c25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 17:40:29 +0900 Subject: [PATCH 16/34] docs(changelog): record cross-surface platform coherence Add the platform-coherence slice to doctoring, the changelog, and the product gap baseline as Proposed ADR 0113 control-plane evidence with no real-browser claim. --- CHANGELOG.md | 1 + docs/doctoring.md | 6 +++++- docs/product-technical-gap-baseline.md | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 874589557..6ec9b8d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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 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. diff --git a/docs/doctoring.md b/docs/doctoring.md index aad7c13c7..1d08b4917 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -46,7 +46,11 @@ 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). +that a non-mobile user agent reports an empty model (see ADR 0112). The +presentation platform, its `navigator.platform` token, and the UA Client +Hints platform form a triad a page can reconcile; OriginWeave binds the three +in one deterministic mapping so a presented identity cannot contradict itself +across surfaces (see ADR 0113). 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9c470f9c1..34dd561cf 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,6 +44,7 @@ Representative active workstreams at this snapshot were: | 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 | +| Cross-surface platform coherence | stacked `feat/stealth-profile-coherence` on #234 | Proposed ADR 0113 binds the presentation-platform, UA-token, and UA-CH-platform triad through `PresentationPlatform::hints_platform` and `require_hints_coherence`; 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 | From 89204207aa83c168d5b9bcaa7405c2f4e7c3aa22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:04:37 -0700 Subject: [PATCH 17/34] test(fingerprint): define Web Audio blocking contract --- .../tests/web_audio_fingerprint_guard.rs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 crates/originweave-fingerprint/tests/web_audio_fingerprint_guard.rs 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..e8c972d5e --- /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" + ); +} From 4eff8226772a3f39e8f2535c918bbdaac3d96f1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:28:15 -0700 Subject: [PATCH 18/34] build(privacy): stage reviewed Web Audio materializer --- .../workflows/one-shot-web-audio-privacy.yml | 55 +++ .../src/web_audio_guard.rs | 130 +++++++ ...4-default-deny-web-audio-fingerprinting.md | 147 +++++++ docs/doctoring/web-audio-privacy.md | 70 ++++ .../originweave-privacy-guard/manifest.json | 17 + .../web_audio_guard.js | 45 +++ scripts/ci/materialize_web_audio_privacy.py | 96 +++++ scripts/ci/run_web_audio_privacy.py | 366 ++++++++++++++++++ tests/fixtures/web_audio_privacy/frame.html | 29 ++ tests/fixtures/web_audio_privacy/page.html | 38 ++ tests/test_web_audio_privacy_contract.py | 136 +++++++ 11 files changed, 1129 insertions(+) create mode 100644 .github/workflows/one-shot-web-audio-privacy.yml create mode 100644 crates/originweave-fingerprint/src/web_audio_guard.rs create mode 100644 docs/adr/0114-default-deny-web-audio-fingerprinting.md create mode 100644 docs/doctoring/web-audio-privacy.md create mode 100644 extensions/originweave-privacy-guard/manifest.json create mode 100644 extensions/originweave-privacy-guard/web_audio_guard.js create mode 100755 scripts/ci/materialize_web_audio_privacy.py create mode 100755 scripts/ci/run_web_audio_privacy.py create mode 100644 tests/fixtures/web_audio_privacy/frame.html create mode 100644 tests/fixtures/web_audio_privacy/page.html create mode 100644 tests/test_web_audio_privacy_contract.py diff --git a/.github/workflows/one-shot-web-audio-privacy.yml b/.github/workflows/one-shot-web-audio-privacy.yml new file mode 100644 index 000000000..994b96b5c --- /dev/null +++ b/.github/workflows/one-shot-web-audio-privacy.yml @@ -0,0 +1,55 @@ +name: One-shot Web Audio privacy materializer + +on: + push: + branches: + - feat/block-web-audio-fingerprinting + paths: + - .github/workflows/one-shot-web-audio-privacy.yml + +permissions: + contents: write + +concurrency: + group: one-shot-web-audio-privacy-${{ github.ref }} + cancel-in-progress: false + +jobs: + materialize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/block-web-audio-fingerprinting + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: 1.97.1 + components: clippy,rustfmt + - name: Apply exact-anchor materialization + run: python3 scripts/ci/materialize_web_audio_privacy.py + - name: Format and verify the materialized slice + run: | + set -euo pipefail + cargo fmt --all + python3 -m compileall -q scripts tests + python3 -m unittest discover -s tests -p 'test_*.py' + cargo check --locked --workspace --all-targets + cargo test --locked --workspace --all-targets + cargo clippy --locked --workspace --all-targets -- -D warnings + git diff --check + - name: Commit the verified one-shot result + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && { + echo "materializer produced no reviewed change" >&2 + exit 1 + } + git commit -m "feat(privacy): enforce default-deny Web Audio policy" + git push origin "HEAD:${GITHUB_REF_NAME}" 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..76c867724 --- /dev/null +++ b/crates/originweave-fingerprint/src/web_audio_guard.rs @@ -0,0 +1,130 @@ +//! 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, PartialEq, Eq)] +pub struct WebAudioFingerprintPolicy { + allowed_origins: BTreeSet, +} + +impl Default for WebAudioFingerprintPolicy { + fn default() -> Self { + Self { + allowed_origins: BTreeSet::new(), + } + } +} + +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/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 `