rustenium-identity 0.1.14

A versatile stealth overlay for rustenium
Documentation
//! CreepJS — the panel-by-panel fingerprint audit.
//!
//! CreepJS renders one hash per vector and marks a panel with a CSS class when it
//! caught something: `lies` when a prototype probe failed, `bold-fail` when the
//! value itself is outside the known-good set. Reading those classes is the whole
//! verdict; the hashes are only useful for comparing two runs.
//!
//! `cargo test --test creepjs -- --ignored --nocapture`

mod common;

use common::{Detector, dump};
use std::time::Duration;

const URL: &str = "https://abrahamjuliot.github.io/creepjs/";

/// Panels render progressively; the trash counter is the last thing to appear.
const READY: &str = r#"document.querySelectorAll('span.hash').length >= 20 ? 'yes' : 'no'"#;

/// Every panel, its hash, and the class that marks it as caught.
const SCRAPE: &str = r#"(() => {
  const panels = [...document.querySelectorAll('span.hash')].map((el) => {
    let label = '';
    for (let n = el.previousSibling; n; n = n.previousSibling) {
      const t = (n.textContent || '').trim();
      if (t) { label = t; break; }
    }
    const cls = [...el.classList].filter((c) => c !== 'hash');
    return { panel: label, hash: (el.textContent || '').trim(), flags: cls };
  }).filter((p) => p.panel);

  const text = document.body.innerText || '';
  const grab = (re) => { const m = text.match(re); return m ? m[1] : null; };

  return JSON.stringify({
    panels,
    flagged: panels.filter((p) => p.flags.length),
    trash: grab(/trash \((\d+)\)/),
    lies: grab(/lies \((\d+)\)/),
    likeHeadless: grab(/(\d+)% like headless/),
    headless: grab(/(\d+)% headless/),
    stealth: grab(/(\d+)% stealth/),
  });
})()"#;

/// How far the page got, for when it does not get all the way. CreepJS blocks on
/// its own worker: `getWorkerData` has to resolve before the panels render, so a
/// worker that never runs leaves the page permanently mid-render.
const PROGRESS: &str = r#"JSON.stringify({
    readyState: document.readyState,
    hashes: document.querySelectorAll('span.hash').length,
    bodyChars: (document.body && document.body.innerText || '').length,
    hasWorker: typeof Worker !== 'undefined',
    swRegistrations: (navigator.serviceWorker && navigator.serviceWorker.controller) ? 1 : 0,
    head: (document.body && document.body.innerText || '').slice(0, 200),
})"#;

#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn creepjs_flags_no_panel() {
    let mut d = Detector::open(1, URL).await;

    let settled = d.wait_until(READY, Duration::from_secs(240)).await;
    if !settled {
        let progress = d.eval(PROGRESS).await;
        dump("creepjs never settled — progress", &progress);
    }
    // The trash and lies counters land after the last panel hash.
    tokio::time::sleep(Duration::from_secs(10)).await;

    let report = d.eval(SCRAPE).await;
    dump("creepjs", &report);
    d.close().await;

    // An empty scrape must fail rather than vacuously pass: "no panel was
    // flagged" and "no panel was read" look identical in the assertion below.
    let panels = report["panels"].as_array().cloned().unwrap_or_default();
    assert!(
        !panels.is_empty(),
        "read no panels from creepjs — it did not finish rendering, so this run \
         proves nothing. See the progress dump above."
    );

    let flagged = report["flagged"].as_array().cloned().unwrap_or_default();
    let names: Vec<String> = flagged
        .iter()
        .map(|p| format!("{} [{}]", p["panel"].as_str().unwrap_or("?"), p["flags"]))
        .collect();

    assert!(
        flagged.is_empty(),
        "creepjs flagged {} panel(s): {}",
        flagged.len(),
        names.join(", ")
    );
}

/// CreepJS's low-entropy canvas probe, reproduced exactly.
///
/// It renders a 2x2 with an antialiased arc and requires the readback to be one
/// of eight values it has seen from real Blink builds. A miss sets
/// `LowerEntropy.CANVAS`, which bold-fails **both** the Canvas 2d panel and the
/// WebGL one — `webglHTML` tests `LowerEntropy.CANVAS || LowerEntropy.WEBGL` —
/// so a canvas problem is reported as a WebGL problem.
///
/// `hardware.js` skips canvases below 16x16 for exactly this reason: the probe is
/// low entropy on purpose, so noising it cannot buy unlinkability and can only
/// move the value off the table. This test says whether that skip is holding.
const LOW_ENTROPY: &str = r#"(() => {
  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d');
  canvas.width = 2;
  canvas.height = 2;
  context.fillStyle = '#000';
  context.fillRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = '#fff';
  context.fillRect(2, 2, 1, 1);
  context.beginPath();
  context.arc(0, 0, 2, 0, 1, true);
  context.closePath();
  context.fill();
  const value = context.getImageData(0, 0, 2, 2).data.join('');

  // An 8x8 read after clearRect must be all zeros, or creepjs documents
  // "pixel data modified" outright.
  canvas.width = 50; canvas.height = 50;
  context.clearRect(0, 0, canvas.width, canvas.height);
  const clearedMax = Math.max(...context.getImageData(0, 0, 8, 8).data);

  const BLINK = [
    '255255255255178178178255246246246255555555255',
    '255255255255192192192255240240240255484848255',
    '255255255255177177177255246246246255535353255',
    '255255255255128128128255191191191255646464255',
    '255255255255178178178255247247247255565656255',
    '255255255255174174174255242242242255474747255',
    '255255255255229229229255127127127255686868255',
    '255255255255192192192255244244244255535353255',
  ];
  return JSON.stringify({ value, known: BLINK.includes(value), clearedMax });
})()"#;

#[tokio::test]
#[ignore = "launches a browser"]
async fn canvas_low_entropy_stays_on_the_known_table() {
    let mut d = Detector::open(1, "about:blank").await;
    let r = d.eval(LOW_ENTROPY).await;
    dump("creepjs low-entropy canvas probe", &r);
    d.close().await;

    assert_eq!(
        r["clearedMax"].as_i64(),
        Some(0),
        "a cleared canvas read back non-zero: creepjs documents this as \
         'pixel data modified' on CanvasRenderingContext2D.getImageData"
    );
    assert_eq!(
        r["known"].as_bool(),
        Some(true),
        "the 2x2 low-entropy canvas read back {}, which is not one of the eight \
         values creepjs knows for Blink. That sets LowerEntropy.CANVAS, which \
         bold-fails the WebGL panel as well as Canvas 2d.",
        r["value"],
    );
}

/// Is the WebGL spoof itself detectable?
///
/// Separate from "was the panel flagged": a detector can conclude the *values*
/// are fine and still notice that `getParameter` has been replaced. These are the
/// probes that catch the replacement rather than the value:
///
/// * the renderer must actually be the persona's — otherwise everything below
///   passes because nothing was spoofed
/// * page and worker must agree; creepjs's `stealth.hasBadWebGL` is literally
///   `gpu !== workerGPU`
/// * `getParameter` must keep a native shape: `[native code]`, own properties of
///   exactly `length,name`, and no `[[Construct]]`
/// * reading 37445 *without* enabling `WEBGL_debug_renderer_info` must return
///   `null`, the way real Chrome does. `main_world.js` delegates to the original
///   first for exactly this probe
const SPOOF_AUDIT: &str = r#"(async () => {
  const out = {};
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl');
  out.hasContext = !!gl;
  if (!gl) return JSON.stringify(out);

  // The classic probe: these read null until the extension is enabled.
  out.beforeExtension = {
    vendor: gl.getParameter(37445),
    renderer: gl.getParameter(37446),
  };

  const ext = gl.getExtension('WEBGL_debug_renderer_info');
  out.vendor = ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : null;
  out.renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;

  // Native shape of the replacement.
  const fn = WebGLRenderingContext.prototype.getParameter;
  out.toString = Function.prototype.toString.call(fn);
  out.ownProps = Object.getOwnPropertyNames(fn).sort().join(',');
  out.name = fn.name;
  out.hasPrototype = 'prototype' in fn;
  try { new fn(); out.constructs = true; } catch (e) { out.constructs = e.constructor.name !== 'TypeError'; }

  // Worker scope must report the same card.
  out.workerRenderer = await new Promise((resolve) => {
    try {
      const src = `onmessage = () => {
        try {
          const c = new OffscreenCanvas(1, 1);
          const g = c.getContext('webgl');
          const e = g && g.getExtension('WEBGL_debug_renderer_info');
          postMessage(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : 'no-ext');
        } catch (err) { postMessage('ERR ' + err); }
      };`;
      const w = new Worker(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })));
      w.onmessage = (e) => resolve(e.data);
      w.onerror = (e) => resolve('worker-error ' + (e.message || ''));
      w.postMessage(1);
      setTimeout(() => resolve('timeout'), 8000);
    } catch (e) { resolve('ERR ' + e); }
  });

  return JSON.stringify(out);
})()"#;

#[tokio::test]
#[ignore = "launches a browser"]
async fn webgl_spoof_is_not_detectable() {
    let mut d = Detector::open(1, "about:blank").await;
    let expected_renderer = d.identity().gpu.webgl_renderer.clone();
    let expected_vendor = d.identity().gpu.webgl_vendor.clone();

    let r = d.eval_async(SPOOF_AUDIT).await;
    dump("webgl spoof audit", &r);
    d.close().await;

    assert_eq!(r["hasContext"].as_bool(), Some(true), "no WebGL context at all");

    // Prove the spoof is on, or everything below passes for the wrong reason.
    assert_eq!(
        r["renderer"].as_str(),
        Some(expected_renderer.as_str()),
        "the page does not report the persona's renderer — the spoof did not apply"
    );
    assert_eq!(r["vendor"].as_str(), Some(expected_vendor.as_str()));

    // creepjs stealth.hasBadWebGL is `gpu !== workerGPU`, so page and worker must
    // agree. Not asserted yet: a worker started from a blob: URL never answers
    // here, it times out. Whether that is the worker being held at
    // waitForDebuggerOnStart and never resumed, or OffscreenCanvas having no GL
    // in this configuration, is unresolved — and a hung worker would be its own
    // problem, so this is worth chasing rather than deleting.
    let worker = r["workerRenderer"].as_str().unwrap_or_default();
    if worker == expected_renderer {
        println!("worker renderer matches the page");
    } else {
        println!("UNVERIFIED: worker renderer came back {worker:?}, expected the persona's");
    }

    // Native shape.
    assert!(
        r["toString"].as_str().is_some_and(|s| s.contains("[native code]")),
        "getParameter stringifies to injected source: {}",
        r["toString"]
    );
    assert_eq!(r["ownProps"].as_str(), Some("length,name"));
    assert_eq!(r["name"].as_str(), Some("getParameter"));
    assert_eq!(r["hasPrototype"].as_bool(), Some(false));
    assert_eq!(r["constructs"].as_bool(), Some(false), "getParameter is constructible");

    // Native null before the extension is enabled.
    assert!(
        r["beforeExtension"]["renderer"].is_null(),
        "37446 read back {} before WEBGL_debug_renderer_info was enabled; real \
         Chrome returns null, so this hands over the spoof directly",
        r["beforeExtension"]["renderer"]
    );
}

/// The headless heuristics are scored separately from the fingerprint panels and
/// are the ones that fire on a server without a window manager.
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn creepjs_headless_ratings_are_zero() {
    let mut d = Detector::open(1, URL).await;
    d.wait_until(READY, Duration::from_secs(120)).await;
    tokio::time::sleep(Duration::from_secs(10)).await;

    let report = d.eval(SCRAPE).await;
    dump("creepjs headless", &report);
    d.close().await;

    let headless = report["headless"].as_str().unwrap_or("0");
    let stealth = report["stealth"].as_str().unwrap_or("0");
    assert_eq!(headless, "0", "creepjs scored {headless}% headless");
    assert_eq!(stealth, "0", "creepjs scored {stealth}% stealth");
}