rustenium-identity 0.1.10

A versatile stealth overlay for rustenium
Documentation
// ---- Hardware fingerprint protection (deterministic, identity-seeded) ----
// Every value below is a pure function of (FP_SEED, sample index), so a persona's
// canvas and text metrics are stable across sessions AND identical when a page
// renders the same thing twice (FP libs compare two passes) — while differing
// from the host machine's real output and across identities.
//
// Every hook goes through PropertyModifier.spoofMethod so the replacement keeps a
// native shape; these exact methods (getImageData, toDataURL, toBlob, measureText)
// are the ones prototype auditors hash to attribute a fingerprint to a specific
// tool.
//
// Three surfaces are deliberately left alone, each documented at its former site:
// audio, WebGL render output, and element geometry. All three are checked against
// exact known-good values or exact inter-value relationships, so perturbing them
// is visible without buying meaningful unlinkability.
(function () {
  var FP_SEED = ({{FP_SEED}}) >>> 0;

  // Integer avalanche hash -> u32, deterministic for (seed, n).
  function fpHash(n) {
    var h = (FP_SEED ^ (n >>> 0)) >>> 0;
    h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
    h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
    return (h ^ (h >>> 16)) >>> 0;
  }
  function fpRand(n) { return fpHash(n) / 4294967296; }   // [0,1)
  function strHash(s) {
    var h = 0x811c9dc5 >>> 0;
    for (var i = 0; i < s.length; i++) { h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0; }
    return h >>> 0;
  }

  var hook = PropertyModifier.spoofMethod;

  // Perturb by ±1 on RGB, but ONLY on pixels that already differ from their left
  // neighbour and are not fully transparent.
  //
  // This restriction is the whole ballgame. Noising unconditionally breaks two
  // invariants that hold on every real GPU, and both are one-line probes:
  //   * a solid fillRect must read back perfectly uniform
  //   * an untouched canvas must be entirely zero
  // Speckling either one is a far louder signal than the true canvas hash ever was.
  // Antialiased glyph edges — where essentially all the fingerprint entropy lives —
  // do vary from their neighbours, so the hash still moves off the host's value.
  //
  // The comparison uses the ORIGINAL neighbour value (tracked in prev*), not the
  // possibly-already-noised buffer, so the result is independent of scan order.
  // Canvases too small to carry hardware entropy are left untouched.
  //
  // Detectors use a tiny fixed render (a 2x2 with an antialiased arc) precisely
  // BECAUSE it is low entropy — the result is identical on every machine of a
  // given engine, which is what makes a known-good lookup table possible. Noising
  // it therefore cannot help unlinkability, and can only move the value off the
  // table, which is itself the signal. The real fingerprint lives in large text
  // and emoji renders, which are well above this threshold.
  var MIN_ENTROPY_PIXELS = 16 * 16;

  function perturbImage(data, width, height) {
    if (!width || !height) return;
    if (width * height < MIN_ENTROPY_PIXELS) return;
    for (var y = 0; y < height; y++) {
      var pr = -1, pg = -1, pb = -1, pa = -1;
      for (var x = 0; x < width; x++) {
        var i = (y * width + x) * 4;
        var r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
        // Fully-opaque only. Canvas stores pixels premultiplied; getImageData
        // un-premultiplies and putImageData re-premultiplies, and for alpha<255
        // that round-trip is lossy — a forced bit would not survive it, so an
        // encode/decode cycle would disagree with a direct read. At alpha 255 the
        // conversion is exact. Fingerprint canvases draw text over a filled
        // background, so the antialiased glyph pixels that carry the entropy are
        // opaque blends and remain eligible.
        // Compare with bit 0 masked out — the only bit this function writes.
        // Eligibility must not depend on anything the transform itself changes,
        // or re-application is not a no-op: with a raw compare, forcing pixel P
        // can make its right-hand neighbour (previously equal to P, hence
        // skipped) suddenly differ, so that neighbour becomes eligible on the
        // SECOND pass only. A canvas is read back through two paths — direct
        // getImageData, and encode/decode/draw/getImageData — and the second
        // applies this twice, so the two disagreed by 1 on every such pixel.
        // Real hardware round-trips PNG losslessly, which is a one-line probe.
        var varies = x > 0 && (
          (r & 0xFE) !== (pr & 0xFE) ||
          (g & 0xFE) !== (pg & 0xFE) ||
          (b & 0xFE) !== (pb & 0xFE) ||
          a !== pa
        );
        if (varies && a === 255) {
          // Force the low bit to a deterministic function of position instead of
          // adding ±1. Both change the pixel by at most one level, but forcing is
          // IDEMPOTENT: re-applying it is a no-op. That matters because a canvas
          // can be read back twice through different paths — e.g. encode with
          // toDataURL, decode, draw, then getImageData — and additive noise would
          // accumulate, making the round-trip differ from the direct read. On real
          // hardware a PNG round-trip is lossless and those two agree exactly.
          //
          // Idempotence needs BOTH halves: the write must be a no-op the second
          // time (forcing, not adding) and the eligibility test must be blind to
          // what the write changed (the mask above). Forcing alone is not enough.
          data[i]     = (r & 0xFE) | (fpHash(i) & 1);
          data[i + 1] = (g & 0xFE) | (fpHash(i + 1) & 1);
          data[i + 2] = (b & 0xFE) | (fpHash(i + 2) & 1);
        }
        pr = r; pg = g; pb = b; pa = a;
      }
    }
  }

  // ---- Canvas 2D ----
  var Ctx2D = typeof CanvasRenderingContext2D !== 'undefined' ? CanvasRenderingContext2D : null;
  var OffCtx2D = typeof OffscreenCanvasRenderingContext2D !== 'undefined' ? OffscreenCanvasRenderingContext2D : null;
  // Capture the un-hooked readers so noisyClone() noises exactly once. Both are
  // needed: getImageData brand-checks its receiver, so the HTMLCanvas one throws
  // "Illegal invocation" on an OffscreenCanvasRenderingContext2D. That threw
  // inside noisyClone, the catch below swallowed it, and convertToBlob then
  // returned UNNOISED bytes while toDataURL returned noised ones — leaving the
  // two encode paths disagreeing, which is exactly what a detector compares.
  var origGetImageData = Ctx2D ? Ctx2D.prototype.getImageData : null;
  var origOffGetImageData = OffCtx2D ? OffCtx2D.prototype.getImageData : null;
  function readRaw(ctx, w, h) {
    var reader = (OffCtx2D && ctx instanceof OffCtx2D) ? origOffGetImageData : origGetImageData;
    return reader.call(ctx, 0, 0, w, h);
  }

  function hookGetImageData(proto) {
    if (!proto) return;
    hook(proto, 'getImageData', function (orig) {
      return function getImageData() {
        var img = orig.apply(this, arguments);
        try { perturbImage(img.data, img.width, img.height); } catch (e) {}
        return img;
      };
    });
  }
  hookGetImageData(Ctx2D && Ctx2D.prototype);
  hookGetImageData(OffCtx2D && OffCtx2D.prototype);

  // Encode a noised copy so the source canvas is never mutated. drawImage works for
  // both 2D and WebGL source canvases, so this also covers WebGL toDataURL.
  function noisyClone(canvas) {
    var w = canvas.width, h = canvas.height;
    var tmp = (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas)
      ? new OffscreenCanvas(w, h)
      : document.createElement('canvas');
    tmp.width = w; tmp.height = h;
    var tctx = tmp.getContext('2d');
    tctx.drawImage(canvas, 0, 0);
    var img = readRaw(tctx, w, h);
    perturbImage(img.data, w, h);
    tctx.putImageData(img, 0, 0);
    return tmp;
  }

  if (typeof HTMLCanvasElement !== 'undefined' && origGetImageData) {
    hook(HTMLCanvasElement.prototype, 'toDataURL', function (orig) {
      return function toDataURL() {
        try { return orig.apply(noisyClone(this), arguments); }
        catch (e) { return orig.apply(this, arguments); }
      };
    });
    hook(HTMLCanvasElement.prototype, 'toBlob', function (orig) {
      return function toBlob() {
        try { return orig.apply(noisyClone(this), arguments); }
        catch (e) { return orig.apply(this, arguments); }
      };
    });
  }
  // OffscreenCanvas has its own encode path; without this it reads back unnoised
  // while the 2D context reads back noised, which is its own inconsistency.
  if (typeof OffscreenCanvas !== 'undefined' && origOffGetImageData) {
    hook(OffscreenCanvas.prototype, 'convertToBlob', function (orig) {
      return function convertToBlob() {
        try { return orig.apply(noisyClone(this), arguments); }
        catch (e) { return orig.apply(this, arguments); }
      };
    });
  }

  // ---- WebGL render output (readPixels): deliberately NOT spoofed ----
  // Removed after measurement. A shader writes whole colours, so the channels of
  // a rendered pixel are locked to each other, and antialiasing scales them
  // together. The standard probe renders `gl_FragColor = vec4(1,0,0,1)` into a
  // 41x41 buffer and asserts `red === alpha` for every pixel — which holds on all
  // real hardware, coverage included. Per-channel noise cannot survive that:
  // moving red's low bit breaks the equality, and moving alpha to match means an
  // opaque pixel reads back at 254, which is its own signal. Measured 25
  // mismatches on the eligible pixels — the ones just inboard of an antialiased
  // edge, where the pixel is fully opaque but differs from its left neighbour.
  //
  // Nor is much lost. That render is flat-shaded, so it carries almost no
  // hardware entropy — the same reason the 2D path skips low-entropy canvases —
  // and against a detector holding ground truth for the claimed GPU the render
  // is already wrong, since the persona names one card while the pixels come off
  // another. Noising makes the output match neither, which is worse than matching
  // the real one: it is unique rather than merely inconsistent.
  //
  // The renderer/vendor STRINGS are still spoofed, in main_world.js.

  // ---- AudioContext: deliberately NOT spoofed ----
  // Removed after measurement. Detectors hold a table of known-good channel sums
  // per compressor gain, and this machine's natural sum is already in it
  // (124.04347527516074, one of 14 for its gain). Any perturbation lands between
  // known values, and the value is then rendered as a character-level diff against
  // the nearest one — the divergence is displayed digit by digit.
  //
  // The table is small precisely because audio carries little entropy: a handful of
  // legitimate sums covers every machine on an engine. So noising it buys almost no
  // unlinkability while making the value visibly non-standard, which is the same
  // trade the low-entropy canvas skip refuses.
  //
  // Snapping to a *different* known sum would in principle give both — differing
  // from the host while staying in the table — but `includes()` compares floats
  // exactly, and scaling a buffer cannot reliably land a reduce() on a specific
  // float. Not worth the fragility.
  //
  // Removing the hooks also removes what they dragged in: the rendered-buffer
  // WeakSet, the startRendering hook, and the copyFromChannel hook that existed
  // only to stay consistent with the noise.

  // ---- Text metrics (font fingerprint) ----
  // Perturbs absolute text metrics so metric-hash fingerprints differ from the host.
  // The noise is applied on TextMetrics.prototype.width rather than as an own property
  // on the returned object: natively `width` lives on the prototype, so a shadowing
  // own property would make Object.getOwnPropertyNames(metrics) non-empty — a one-line
  // tell. NOTE: this does not hide the installed-font *set* — equal-width fallbacks
  // stay equal — which is not solvable from JS without breaking real rendering.
  if (Ctx2D && typeof TextMetrics !== 'undefined') {
    var metricNoise = new WeakMap();
    hook(Ctx2D.prototype, 'measureText', function (orig) {
      return function measureText(text) {
        var m = orig.apply(this, arguments);
        try { metricNoise.set(m, strHash('' + text + '|' + this.font)); } catch (e) {}
        return m;
      };
    });
    PropertyModifier.spoofAccessor(TextMetrics.prototype, 'width', function (real, self) {
      var key = metricNoise.get(self);
      if (key === undefined) return real;
      return real + (fpRand(key) - 0.5) * 0.0004 * (real || 1);
    });
  }

  // ---- Element geometry: deliberately NOT spoofed ----
  // Removed after testing: DOMRect noise is reliably detected, and the checks use
  // exact equality so there is no safe margin.
  //
  //   * `right - left == width` (and bottom/top, right/x, bottom/y) is asserted on
  //     every rect. Noising width and right but not left means the two sides are
  //     computed by different float paths and disagree in the last ULP — flagged as
  //     "failed math calculation". Deriving all eight fields from one noised value
  //     does not fix it either, because float subtraction is not associative.
  //   * Two identically-sized elements must yield identical left/right. Any noise
  //     keyed on position breaks that ("equal elements mismatch"); noise not keyed
  //     on position is trivially averaged out.
  //
  // Real rect entropy comes from font metrics, zoom and devicePixelRatio, which are
  // browser-level settings (Emulation.setDeviceMetricsOverride, font config). Set
  // there, every rect stays internally consistent because the browser computed it.
})();