rustenium-identity 0.1.14

A versatile stealth overlay for rustenium
Documentation
//! The invariants `js/hardware.js` has to hold while it noises a canvas.
//!
//! The noise exists to move the canvas hash off the host's, and every rule in it
//! exists because a one-line probe catches its absence. These tests pin those
//! rules directly, rather than through whichever detector happened to notice.
//!
//! `cargo test --test canvas -- --ignored --nocapture`

mod common;

use common::{Detector, dump};

/// Solid fills must survive a PNG round-trip byte-exact, including where two
/// solid regions meet.
///
/// The boundary is the part that regressed. "Perturb pixels that differ from
/// their left neighbour" selects antialiased edges, but it also selects the first
/// column of a solid region abutting another one. Pixelscan paints fourteen
/// adjacent 5px bands and counts each band's pixels; we were returning 20-23 of
/// 25. The rule is now "differs from both neighbours", which a hard edge does not.
const SOLID_BANDS: &str = r#"(async () => {
  const colours = [[255,0,0],[0,255,0],[0,0,255],[255,255,0],[255,0,255],[0,255,255],
                   [1,1,1],[254,254,254],[0,0,0],[51,51,51],[102,102,102],[153,153,153],
                   [204,204,204],[255,255,255]];
  const w = 5, h = 5;
  const c = document.createElement('canvas');
  c.width = w * colours.length; c.height = h;
  const ctx = c.getContext('2d');
  colours.forEach((m, i) => {
    ctx.fillStyle = '#' + m.map((x) => x.toString(16).padStart(2, '0')).join('');
    ctx.fillRect(w * i, 0, w * (1 + i), h);
  });

  const url = c.toDataURL();
  const img = document.createElement('img');
  await new Promise((r) => { img.onload = () => r(); img.src = url; });
  const v = document.createElement('canvas');
  v.width = c.width; v.height = c.height;
  const vc = v.getContext('2d');
  vc.drawImage(img, 0, 0);

  const counts = colours.map((m, i) => {
    const d = vc.getImageData(w * i, 0, w, h).data;
    const px = new Uint32Array(d.buffer);
    const want = new Uint32Array(new Uint8Array([...m, 255]).buffer)[0];
    let n = 0;
    px.forEach((p) => { if (p === want) n++; });
    return n;
  });
  return JSON.stringify({ counts, expected: w * h, allExact: counts.every((n) => n === w * h) });
})()"#;

/// A canvas nobody drew on must read back entirely zero, and a cleared one too.
const BLANK: &str = r#"(() => {
  const c = document.createElement('canvas');
  c.width = 300; c.height = 150;
  const ctx = c.getContext('2d');
  // Not Math.max(...data): 180k arguments overflows the call stack.
  const maxOf = (d) => { let m = 0; for (let i = 0; i < d.length; i++) if (d[i] > m) m = d[i]; return m; };
  const untouched = maxOf(ctx.getImageData(0, 0, 300, 150).data);
  ctx.fillStyle = '#123456';
  ctx.fillRect(0, 0, 300, 150);
  ctx.clearRect(0, 0, 300, 150);
  const cleared = maxOf(ctx.getImageData(0, 0, 300, 150).data);
  return JSON.stringify({ untouched, cleared });
})()"#;

/// Antialiased text, read back two ways. Real hardware round-trips PNG
/// losslessly, so a direct read and an encode/decode/draw/read must agree — which
/// is why the perturbation forces a bit rather than adding one.
const TEXT_RENDER: &str = r#"(async () => {
  const draw = () => {
    const c = document.createElement('canvas');
    c.width = 280; c.height = 60;
    const ctx = c.getContext('2d');
    ctx.fillStyle = '#f0f0f0'; ctx.fillRect(0, 0, 280, 60);
    ctx.fillStyle = '#102030';
    ctx.font = '28px serif';
    ctx.fillText('Cwm fjord bank glyphs vext quiz', 4, 40);
    return c;
  };
  const a = draw(), b = draw();
  const directA = a.toDataURL(), directB = b.toDataURL();

  const img = document.createElement('img');
  await new Promise((r) => { img.onload = () => r(); img.src = directA; });
  const v = document.createElement('canvas');
  v.width = 280; v.height = 60;
  v.getContext('2d').drawImage(img, 0, 0);
  const roundTripped = v.toDataURL();

  return JSON.stringify({
    stableAcrossDraws: directA === directB,
    roundTripStable: directA === roundTripped,
    hash: directA.slice(-48),
  });
})()"#;

#[tokio::test]
#[ignore = "launches a browser"]
async fn solid_fills_survive_a_round_trip() {
    let mut d = Detector::open(1, "about:blank").await;
    let r = d.eval_async(SOLID_BANDS).await;
    dump("adjacent solid fills", &r);
    d.close().await;
    assert_eq!(
        r["allExact"].as_bool(),
        Some(true),
        "adjacent solid fills lost pixels to the noise: {} (each should be {})",
        r["counts"],
        r["expected"],
    );
}

#[tokio::test]
#[ignore = "launches a browser"]
async fn blank_and_cleared_canvases_read_zero() {
    let mut d = Detector::open(1, "about:blank").await;
    let r = d.eval(BLANK).await;
    dump("blank canvas", &r);
    d.close().await;
    assert_eq!(r["untouched"].as_i64(), Some(0), "an untouched canvas was not all zero");
    assert_eq!(r["cleared"].as_i64(), Some(0), "a cleared canvas was not all zero");
}

#[tokio::test]
#[ignore = "launches a browser"]
async fn text_renders_are_stable() {
    let mut d = Detector::open(1, "about:blank").await;
    let r = d.eval_async(TEXT_RENDER).await;
    dump("antialiased text", &r);
    d.close().await;
    assert_eq!(
        r["stableAcrossDraws"].as_bool(),
        Some(true),
        "two identical draws produced different bytes — instability is its own tell"
    );
    assert_eq!(
        r["roundTripStable"].as_bool(),
        Some(true),
        "a PNG round-trip changed the bytes; real hardware round-trips losslessly"
    );
}

/// The noise must still be doing something, and be keyed to the persona.
///
/// The guard against "fixed the detector by turning the protection off": two
/// different identities render the same text and must produce different bytes,
/// because the perturbation is seeded from the identity.
#[tokio::test]
#[ignore = "launches two browsers"]
async fn noise_is_active_and_seeded_per_identity() {
    let mut a = Detector::open(1, "about:blank").await;
    let ra = a.eval_async(TEXT_RENDER).await;
    a.close().await;

    let mut b = Detector::open(4, "about:blank").await;
    let rb = b.eval_async(TEXT_RENDER).await;
    b.close().await;

    println!("preset 1 tail: {}", ra["hash"]);
    println!("preset 4 tail: {}", rb["hash"]);
    assert_ne!(
        ra["hash"], rb["hash"],
        "two identities produced an identical canvas — the noise is not being applied, \
         or is not seeded from the identity"
    );
}