Plugin Directory

Changeset 3765708


Ignore:
Timestamp:
10/26/2026 07:17:30 AM (26 hours ago)
Author:
spooflens
Message:

SpoofLens Fraud Check 0.2.1: updated collector, free key wording

Location:
spooflens-fraud-check
Files:
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • spooflens-fraud-check/tags/0.2.1/assets/sl.js

    r3765684 r3765708  
    330330  }
    331331
     332  // speech voices: the count, and which system the on-device voices come with (Microsoft ones with Windows,
     333  // Apple ones with macOS and iOS); network voices (Google in desktop Chrome, "Online" in Edge) are left out.
     334  // Only the counts leave the browser, not the names.
     335  var APPLE_VOICES = /^(Samantha|Alex|Daniel|Karen|Moira|Tessa|Fiona|Fred|Victoria|Rishi|Veena|Thomas|Anna|Milena|Yuri|Amelie|Amélie|Kyoko|Ting-Ting)( |$)/;
     336  function voiceSummary(list) {
     337    var k = { win: 0, apple: 0, other: 0 }, names = [];
     338    for (var i = 0; i < list.length; i++) {
     339      var v = list[i];
     340      if (!v || v.localService !== true) continue;
     341      var n = String(v.name || ''), u = String(v.voiceURI || '');
     342      names.push(n);
     343      if (/^Microsoft /.test(n)) k.win++;
     344      else if (/^com\.apple\./.test(u) || APPLE_VOICES.test(n)) k.apple++;
     345      else k.other++;
     346    }
     347    // the names of the system's own voices, as one hash (0.18.0): the same in every browser of a device, and voices
     348    // the owner downloaded tell two phones of one model apart; the names themselves are not sent
     349    return { n: list.length, k: k, h: names.length ? hash(names.sort().join('|')) : '' };
     350  }
    332351  function voices() {
    333352    if (!window.speechSynthesis) return Promise.resolve(null);
    334353    var v = speechSynthesis.getVoices();
    335     if (v && v.length) return Promise.resolve(v.length);
     354    if (v && v.length) return Promise.resolve(voiceSummary(v));
    336355    return timeout(new Promise(function (r) {
    337       speechSynthesis.addEventListener('voiceschanged', function () { r(speechSynthesis.getVoices().length); }, { once: true });
    338     }), 800, 0);
     356      speechSynthesis.addEventListener('voiceschanged', function () { r(voiceSummary(speechSynthesis.getVoices())); }, { once: true });
     357    }), 800, { n: 0, k: null });
     358  }
     359
     360  // the JavaScript engine words its own TypeError: V8 (Chromium), SpiderMonkey (Firefox), JavaScriptCore (Safari, every iOS browser)
     361  function jsEngine() {
     362    try { var o = null; o.x = 1; } catch (e) {
     363      var m = String((e && e.message) || '');
     364      if (/Cannot set propert/.test(m)) return 'v8';
     365      if (/can't access property|has no properties|is null/.test(m)) return 'gecko';
     366      if (/is not an object/.test(m)) return 'jsc';
     367      return 'other';
     368    }
     369    return '';
     370  }
     371
     372  // globals and document properties that automation frameworks leave behind
     373  function autoTraces() {
     374    var out = [];
     375    ['__playwright__binding__', '__pwInitScripts', '_phantom', 'callPhantom', '__nightmare', 'domAutomation', 'domAutomationController',
     376     '_selenium', 'callSelenium', '_Selenium_IDE_Recorder', '__webdriver_evaluate', '__selenium_evaluate', '__webdriver_script_fn',
     377     '__driver_evaluate', '__fxdriver_evaluate', '__lastWatirAlert'].forEach(function (k) {
     378      try { if (k in window) out.push(k); } catch (e) {}
     379    });
     380    try {
     381      Object.keys(document).forEach(function (k) {
     382        if (/^\$?cdc_|^\$wdc_|^__(webdriver|selenium|fxdriver|driver)_/.test(k)) out.push('document.' + k);
     383      });
     384      ['webdriver', 'selenium', 'driver'].forEach(function (a) { if (document.documentElement.hasAttribute(a)) out.push('html[' + a + ']'); });
     385    } catch (e) {}
     386    return out.slice(0, 8);
    339387  }
    340388
    … …  
    346394      return o;
    347395    }), 1500, null);
     396  }
     397
     398  /* ------------------------------------------------ device traits (0.18.0) --- */
     399  // What comes from the machine and the system rather than from the browser engine, so that two browsers of one
     400  // device can be told apart from two devices (Chrome and Firefox on one computer share almost no other trait).
     401
     402  // WebGL limits and shader precision without the names: the graphics card and its driver. p: the limits and
     403  // precision; e: the extension list (it differs more between engines, kept apart).
     404  function glParams() {
     405    var o = { p: '', e: '' };
     406    try {
     407      var c = document.createElement('canvas');
     408      var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
     409      if (!gl) return o;
     410      var P = ['MAX_TEXTURE_SIZE', 'MAX_CUBE_MAP_TEXTURE_SIZE', 'MAX_RENDERBUFFER_SIZE', 'MAX_VERTEX_ATTRIBS', 'MAX_VERTEX_UNIFORM_VECTORS',
     411        'MAX_FRAGMENT_UNIFORM_VECTORS', 'MAX_VARYING_VECTORS', 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', 'MAX_TEXTURE_IMAGE_UNITS',
     412        'MAX_VERTEX_TEXTURE_IMAGE_UNITS', 'MAX_VIEWPORT_DIMS', 'ALIASED_LINE_WIDTH_RANGE', 'ALIASED_POINT_SIZE_RANGE', 'SUBPIXEL_BITS'];
     413      var out = P.map(function (k) { var v = gl.getParameter(gl[k]); return v && typeof v === 'object' && v.length !== undefined ? Array.prototype.join.call(v, 'x') : String(v); });
     414      ['VERTEX_SHADER', 'FRAGMENT_SHADER'].forEach(function (sh) {
     415        ['LOW_FLOAT', 'MEDIUM_FLOAT', 'HIGH_FLOAT', 'LOW_INT', 'MEDIUM_INT', 'HIGH_INT'].forEach(function (pr) {
     416          var x = gl.getShaderPrecisionFormat(gl[sh], gl[pr]);
     417          out.push(x ? x.rangeMin + '/' + x.rangeMax + '/' + x.precision : '-');
     418        });
     419      });
     420      o.p = hash(out.join('|'));
     421      o.e = hash((gl.getSupportedExtensions() || []).slice().sort().join(','));
     422      var lose = gl.getExtension('WEBGL_lose_context');
     423      if (lose) lose.loseContext();
     424    } catch (e) {}
     425    return o;
     426  }
     427
     428  // Scene 2.0: five small drawings, each leaning on another part of the graphics card (float maths, derivatives,
     429  // blending, texture filtering, trigonometry at its edges). Browsers of one computer go through one driver, so
     430  // our first scene already matched across engines; five give more to tell devices apart. Drawn twice: an
     431  // unmodified browser gives the same pixels (noise = something rewrote them).
     432  function scene2() {
     433    var o = { h: '', parts: [], noise: false };
     434    try {
     435      var c = document.createElement('canvas'); c.width = 32; c.height = 32;
     436      var opt = { antialias: false, preserveDrawingBuffer: true };
     437      var gl = c.getContext('webgl', opt) || c.getContext('experimental-webgl', opt);
     438      if (!gl) return o;
     439      var hp = gl.getShaderPrecisionFormat && gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
     440      var prec = 'precision ' + (hp && hp.precision > 0 ? 'highp' : 'mediump') + ' float;varying vec2 v;';
     441      var der = gl.getExtension('OES_standard_derivatives');
     442      var vs = 'attribute vec2 a;varying vec2 v;void main(){v=a*0.5+0.5;gl_Position=vec4(a,0.0,1.0);}';
     443      var frags = [
     444        prec + 'void main(){float x=v.x*1000.0+v.y*7.3;gl_FragColor=vec4(fract(sin(x)*1e4),fract(exp(v.y*9.0)),fract(pow(v.x+1.0,13.7)),1.0);}',
     445        der ? '#extension GL_OES_standard_derivatives : enable\n' + prec + 'void main(){float f=sin(v.x*50.0)*cos(v.y*31.0);gl_FragColor=vec4(fwidth(f)*8.0,abs(dFdx(f))*20.0,abs(dFdy(f))*20.0,1.0);}' : '',
     446        prec + 'uniform float k;void main(){gl_FragColor=vec4(v.x,v.y*k,1.0-v.x*k,0.37);}',
     447        prec + 'uniform sampler2D t;void main(){gl_FragColor=texture2D(t,v*0.73+0.11);}',
     448        prec + 'void main(){float a=atan(v.y*2.0-1.0,v.x*2.0-1.0);gl_FragColor=vec4(fract(a*7.0),fract(acos(v.x*1.999-0.9995)*5.0),fract(tan(v.y*1.5)),1.0);}'
     449      ];
     450      var sh = function (type, src) { var x = gl.createShader(type); gl.shaderSource(x, src); gl.compileShader(x); return x; };
     451      var buf = gl.createBuffer();
     452      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
     453      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
     454      var tex = gl.createTexture();
     455      gl.bindTexture(gl.TEXTURE_2D, tex);
     456      var tp = new Uint8Array(64);
     457      for (var i = 0; i < 64; i++) tp[i] = (i * 53 + 17) % 256;
     458      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, tp);
     459      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
     460      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
     461      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
     462      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
     463      gl.disable(gl.DITHER); gl.viewport(0, 0, 32, 32);
     464      var px = new Uint8Array(32 * 32 * 4);
     465      var progs = frags.map(function (src) {
     466        if (!src) return null;
     467        var p = gl.createProgram();
     468        gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, src)); gl.linkProgram(p);
     469        return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
     470      });
     471      var pass = function () {
     472        return progs.map(function (p, n) {
     473          if (!p) return '-';
     474          gl.useProgram(p);
     475          var loc = gl.getAttribLocation(p, 'a');
     476          gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
     477          gl.clearColor(0.1, 0.2, 0.3, 1.0); gl.clear(gl.COLOR_BUFFER_BIT);
     478          if (n === 2) {
     479            // three half-transparent layers on top of each other
     480            gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
     481            var k = gl.getUniformLocation(p, 'k');
     482            [0.3, 0.6, 0.9].forEach(function (x) { gl.uniform1f(k, x); gl.drawArrays(gl.TRIANGLES, 0, 6); });
     483            gl.disable(gl.BLEND);
     484          } else {
     485            if (n === 3) gl.uniform1i(gl.getUniformLocation(p, 't'), 0);
     486            gl.drawArrays(gl.TRIANGLES, 0, 6);
     487          }
     488          gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, px);
     489          var str = [];
     490          for (var i2 = 0; i2 < px.length; i2 += 1024) str.push(String.fromCharCode.apply(null, px.subarray(i2, i2 + 1024)));
     491          return hash(str.join('')).slice(0, 8);
     492        });
     493      };
     494      var a = pass(), b = pass();
     495      o.parts = a;
     496      o.noise = a.join() !== b.join();
     497      o.h = hash(a.join('|'));
     498      var lose = gl.getExtension('WEBGL_lose_context');
     499      if (lose) lose.loseContext();
     500    } catch (e) {}
     501    return o;
     502  }
     503
     504  // The screen as the system lays it out: the size in device pixels, the strip the taskbar or dock takes, the
     505  // menu bar at the top (macOS), the colour range of the display and HDR, and whether more than one display is on.
     506  function screen2() {
     507    var sc = window.screen || {}, dpr = window.devicePixelRatio || 1;
     508    var on = function (q) { return mq(q) === true; };
     509    return {
     510      pw: Math.round((sc.width || 0) * dpr), ph: Math.round((sc.height || 0) * dpr),
     511      tb: (sc.height || 0) - (sc.availHeight || 0), tw: (sc.width || 0) - (sc.availWidth || 0),
     512      at: typeof sc.availTop === 'number' ? sc.availTop : null, al: typeof sc.availLeft === 'number' ? sc.availLeft : null,
     513      gamut: on('(color-gamut: rec2020)') ? 'rec2020' : (on('(color-gamut: p3)') ? 'p3' : (on('(color-gamut: srgb)') ? 'srgb' : '')),
     514      hdr: on('(dynamic-range: high)'), ext: sc.isExtended === true
     515    };
     516  }
     517
     518  // The sound hardware: the default sample rate, the output channels and the latency of the system's audio.
     519  function audioHw() {
     520    var AC = window.AudioContext || window.webkitAudioContext;
     521    if (!AC) return Promise.resolve(null);
     522    try {
     523      var ac = new AC();
     524      var o = { sr: ac.sampleRate || 0, ch: ac.destination ? ac.destination.maxChannelCount : null,
     525        bl: typeof ac.baseLatency === 'number' ? Math.round(ac.baseLatency * 10000) / 10 : null };
     526      try { ac.close(); } catch (e) {}
     527      return Promise.resolve(o);
     528    } catch (e) { return Promise.resolve(null); }
     529  }
     530
     531  // Emoji clock: one emoji of each Unicode emoji version. The newest one the system draws in colour as a single
     532  // glyph tells the version of its emoji font, which follows the version of the system: browsers and iOS 26+
     533  // freeze the system version in the User-Agent, the emoji do not. m: the sizes of three emoji (the font itself).
     534  var EMOJI = [['11', '\uD83E\uDD70'], ['12', '\uD83E\uDD71'], ['13', '\uD83E\uDD72'], ['13.1', '\u2764\uFE0F\u200D\uD83D\uDD25'], ['14', '\uD83E\uDEE0'],
     535    ['15', '\uD83E\uDEE8'], ['15.1', '\uD83D\uDE42\u200D\u2194\uFE0F'], ['16', '\uD83E\uDEE9']];
     536  function emojiClock() {
     537    var o = { v: '', ok: '', m: '' };
     538    try {
     539      var c = document.createElement('canvas'); c.width = 64; c.height = 40;
     540      var x = c.getContext('2d');
     541      x.font = '28px sans-serif'; x.textBaseline = 'top';
     542      var one = x.measureText('\uD83D\uDE00').width;
     543      var ok = [];
     544      EMOJI.forEach(function (e) {
     545        x.clearRect(0, 0, 64, 40);
     546        x.fillText(e[1], 0, 2);
     547        var d = x.getImageData(0, 0, 64, 40).data, col = 0;
     548        for (var i = 0; i < d.length; i += 4) {
     549          if (d[i + 3] > 128 && (Math.abs(d[i] - d[i + 1]) > 40 || Math.abs(d[i + 1] - d[i + 2]) > 40)) col++;
     550        }
     551        // in colour and as one glyph: a sequence the font does not know falls apart into two or more
     552        if (col > 20 && x.measureText(e[1]).width < one * 1.5) ok.push(e[0]);
     553      });
     554      o.ok = ok.join(',');
     555      o.v = ok.length ? ok[ok.length - 1] : '';
     556      var m = x.measureText('\uD83D\uDE00\uD83E\uDEE0\uD83D\uDC4D\uD83C\uDFFD');
     557      o.m = hash([one, m.width, m.actualBoundingBoxAscent, m.actualBoundingBoxDescent].map(function (n) { return Math.round((n || 0) * 10); }).join(','));
     558    } catch (e) {}
     559    return o;
     560  }
     561
     562  // How much the browser lets a site keep, as a class only (0: under 1 GB ... 8: 1.6 TB and more). In Chromium
     563  // it follows the size of the disk, which tells two phones of one model with different storage apart.
     564  function storeTier() {
     565    if (!navigator.storage || !navigator.storage.estimate) return Promise.resolve(null);
     566    return timeout(navigator.storage.estimate().then(function (e) {
     567      var gb = ((e && e.quota) || 0) / 1e9, edges = [1, 10, 50, 100, 200, 400, 800, 1600], t = 0;
     568      while (t < edges.length && gb >= edges[t]) t++;
     569      return { t: t };
     570    }), 1000, null);
    348571  }
    349572
    … …  
    410633   *    (the request itself fails, there is no certificate for that name), then /dns/<id> says who asked;
    411634   *  - STUN: the public address WebRTC's UDP traffic comes from (srflx candidates).
     635   *  - QUIC (0.19.0, on its own host): /a offers HTTP/3 in an Alt-Svc header and closes its TCP connection, three
     636   *    /b requests show whether the browser moves to HTTP/3, /r returns what the server saw, signed.
    412637   * The tokens are verified on spooflens.com; the browser only carries them.
    413638   */
    414   function edgeProbe(host, zone) {
     639  function edgeProbe(host, zone, qhost) {
    415640    // on spooflens.com the page names the edge (data-edge); sl.js on client sites passes it in
    416641    host = host || document.documentElement.getAttribute('data-edge');
    417642    zone = zone || document.documentElement.getAttribute('data-edge-zone');
     643    qhost = qhost || document.documentElement.getAttribute('data-edge-quic');
    418644    if (!host || !window.fetch) return Promise.resolve(null);
    419645    var base = 'https://' + host, out = { tls: '', dns: '', stun: [] };
    420646    var get = function (u) { return fetch(base + u, { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.json(); }); };
    421     var tlsP = timeout(get('/fp').then(function (j) { out.tls = j.token || ''; }), 3000, null).catch(function () {});
     647    // round trip as the page sees it (0.16.0): three pings on the connection /fp has just used, the fastest kept.
     648    // The server compares it with the round trip the edge's kernel measured for that connection.
     649    var tag = Math.random().toString(36).slice(2, 8);   // this run's own timing entries
     650    var ping = function (n) {
     651      var u = base + '/ping?n=' + tag + n, t0 = performance.now();
     652      return fetch(u, { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.text(); }).then(function () {
     653        var ms = performance.now() - t0;
     654        // the network stack's own timing (the edge sends Timing-Allow-Origin), free of main-thread delays
     655        try {
     656          var e = performance.getEntriesByName(u).pop();
     657          if (e && e.requestStart > 0 && e.responseStart >= e.requestStart) ms = e.responseStart - e.requestStart;
     658        } catch (x) {}
     659        return ms;
     660      });
     661    };
     662    var pings = function () {
     663      var ms = [];
     664      var keep = function (v) { ms.push(v); };
     665      return ping(1).then(keep).then(function () { return ping(2); }).then(keep).then(function () { return ping(3); }).then(keep)
     666        .catch(function () {})
     667        .then(function () { if (ms.length) out.rtt = Math.round(Math.min.apply(null, ms) * 10) / 10; });
     668    };
     669    // QUIC after the pings, so its traffic does not sit in their timing. nextHopProtocol of each request is the
     670    // browser's own word (the QUIC host sends Timing-Allow-Origin); the server's account is in the token.
     671    var quic = function () {
     672      if (!qhost) return null;
     673      var qb = 'https://' + qhost, qid = '', urls = [];
     674      for (var k = 0; k < 16; k++) qid += '0123456789abcdef'[Math.floor(Math.random() * 16)];
     675      var wait = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
     676      var q = function (p) {
     677        var u = qb + p + (p.indexOf('?') >= 0 ? '&' : '?') + 'id=' + qid;
     678        if (p !== '/r') urls.push(u);
     679        return fetch(u, { cache: 'no-store', credentials: 'omit' });
     680      };
     681      // the timing entry of a request is written after fetch() resolves, so the protocols are read at the end
     682      var protocols = function () {
     683        return urls.map(function (u) { try { var e = performance.getEntriesByName(u).pop(); return e ? e.nextHopProtocol || '' : ''; } catch (x) { return ''; } });
     684      };
     685      return timeout(q('/a').then(function () { return wait(50); })
     686        .then(function () { return q('/b?n=1'); }).then(function () { return wait(300); })
     687        .then(function () { return q('/b?n=2'); }).then(function () { return wait(200); })
     688        .then(function () { return q('/b?n=3'); })
     689        .then(function () { return q('/r'); }).then(function (r) { return r.json(); })
     690        .then(function (j) { if (j && j.token) out.quic = { token: j.token, np: protocols() }; }), 4000, null).catch(function () {});
     691    };
     692    var tlsP = timeout(get('/fp').then(function (j) { out.tls = j.token || ''; }), 3000, null).catch(function () {})
     693      .then(function () { return out.tls ? timeout(pings(), 2000, null) : null; }).catch(function () {})
     694      .then(quic);
    422695    var id = '';
    423696    for (var i = 0; i < 16; i++) id += '0123456789abcdef'[Math.floor(Math.random() * 16)];
    … …  
    534807      media: {
    535808        dark: mq('(prefers-color-scheme: dark)'), reduced: mq('(prefers-reduced-motion: reduce)'),
    536         pointer: mq('(pointer: fine)') ? 'fine' : (mq('(pointer: coarse)') ? 'coarse' : 'none'), hover: mq('(hover: hover)')
     809        pointer: mq('(pointer: fine)') ? 'fine' : (mq('(pointer: coarse)') ? 'coarse' : 'none'), hover: mq('(hover: hover)'),
     810        // settings of the system (0.18.0): contrast, transparency, inverted and forced colours
     811        contrast: mq('(prefers-contrast: more)') ? 'more' : (mq('(prefers-contrast: less)') ? 'less' : (mq('(prefers-contrast: custom)') ? 'custom' : 'no')),
     812        transp: mq('(prefers-reduced-transparency: reduce)'), invert: mq('(inverted-colors: inverted)'), forced: mq('(forced-colors: active)')
    537813      },
     814      scr2: screen2(),
    538815      gfx: { vendor: gl.vendor || '', renderer: gl.renderer || '', version: gl.version || '', glsl: gl.glsl || '',
    539              maxTex: gl.maxTex || null, extCount: gl.extCount || 0, hash: gl.hash || '' },
     816             maxTex: gl.maxTex || null, extCount: gl.extCount || 0, hash: gl.hash || '', params: glParams() },
    540817      canvas: { h1: c1, h2: c2, noise: !!(c1 && c2 && c1 !== c2), flat: canvasFlat() },
    541818      scene: webglScene(roads),
     819      scene2: scene2(),
     820      emoji: emojiClock(),
    542821      fonts: fonts(),
    543       auto: { webdriver: nav.webdriver === true, chromeObj: !!window.chrome, tamper: tamperCheck() },
     822      auto: { webdriver: nav.webdriver === true, chromeObj: !!window.chrome, tamper: tamperCheck(), engine: jsEngine(), traces: autoTraces() },
     823      // WebTransport (HTTP/3 for pages, 0.19.0): exposed only in a secure context, so elsewhere nothing is said
     824      wt: window.isSecureContext ? typeof window.WebTransport === 'function' : null,
    544825      iframe: iframeProbe(),
    545826      priv: {
    … …  
    548829      }
    549830    };
    550     return Promise.all([audioHash(), audioHash(), webgpu(), voices(), devices(), rtc(), workerProbe(workerSrc), uaData(), edgeProbe(edgeConf && edgeConf.host, edgeConf && edgeConf.zone), motion(), sceneRoads(roads)])
     831    return Promise.all([audioHash(), audioHash(), webgpu(), voices(), devices(), rtc(), workerProbe(workerSrc), uaData(), edgeProbe(edgeConf && edgeConf.host, edgeConf && edgeConf.zone, edgeConf && edgeConf.quic), motion(), sceneRoads(roads), audioHw(), storeTier()])
    551832      .then(function (r) {
    552833        if (r[10]) s.scene.roads = r[10];
    553834        s.audio = { h1: r[0] ? hash(r[0]) : '', h2: r[1] ? hash(r[1]) : '', noise: !!(r[0] && r[1] && r[0] !== r[1]) };
    554835        if (r[2]) { s.gfx.webgpuVendor = r[2].vendor || ''; s.gfx.webgpuArch = r[2].arch || ''; }
    555         s.voices = r[3]; s.devices = r[4]; s.rtc = r[5]; s.worker = r[6]; s.nav.uad = r[7];
     836        s.voices = r[3] ? r[3].n : r[3]; s.vo = r[3] ? r[3].k : null; s.devices = r[4]; s.rtc = r[5]; s.worker = r[6]; s.nav.uad = r[7];
    556837        if (r[8]) s.edge = r[8];
    557838        s.motion = r[9];
     839        s.audio2 = r[11];
     840        s.store = r[12];
     841        s.vh = r[3] ? r[3].h || '' : '';
     842        // the voice list often arrives late: read it once more now that everything else is done
     843        try {
     844          var vl = window.speechSynthesis ? speechSynthesis.getVoices() : [];
     845          if (vl && vl.length > (s.voices || 0)) { var vs2 = voiceSummary(vl); s.voices = vs2.n; s.vo = vs2.k; s.vh = vs2.h; }
     846        } catch (e) {}
    558847        s.fp = hash([s.canvas.h1, s.gfx.hash, s.audio.h1, s.fonts.join(','), s.scr.w + 'x' + s.scr.h + '@' + s.scr.dpr,
    559848          s.nav.hc, s.nav.dm, s.tz.name, s.nav.languages.join(','), s.nav.platform].join('|'));
    … …  
    561850      })
    562851      .then(waitOuter);
     852  }
     853
     854  // Research links (0.16.1): two more quick passes over the drawing, sound, font and voice tests. Only how many
     855  // times each part came out different is kept (s.rep), which shows the natural noise of this device.
     856  function repeatPasses(s) {
     857    var rep = { n: 0, canvas: 0, webgl: 0, scene: 0, audio: 0, fonts: 0, voices: 0 };
     858    var fontsKey = (s.fonts || []).join(','), sceneH = (s.scene && s.scene.h1) || '';
     859    var one = function () {
     860      var c = canvasHash(), g = webgl(), sc = webglScene({}), fk = fonts().join(',');
     861      return Promise.all([audioHash(), voices()]).then(function (r) {
     862        rep.n++;
     863        if (c !== s.canvas.h1) rep.canvas++;
     864        if ((g.hash || '') !== (s.gfx.hash || '')) rep.webgl++;
     865        if (((sc && sc.h1) || '') !== sceneH) rep.scene++;
     866        if ((r[0] ? hash(r[0]) : '') !== ((s.audio && s.audio.h1) || '')) rep.audio++;
     867        if (fk !== fontsKey) rep.fonts++;
     868        if ((r[1] ? r[1].n : r[1]) !== s.voices) rep.voices++;
     869      });
     870    };
     871    return timeout(one().then(one), 6000, null).catch(function () {}).then(function () { if (rep.n) s.rep = rep; });
    563872  }
    564873
  • spooflens-fraud-check/tags/0.2.1/includes/admin.php

    r3765684 r3765708  
    9797                                                        printf(
    9898                                                                /* translators: %s: link to the SpoofLens account */
    99                                                                 esc_html__( 'Free key: %s (1,000 checks a month during the beta). Leave empty to keep the saved key.', 'spooflens-fraud-check' ),
     99                                                                esc_html__( 'Free key: %s (1,000 checks a month free). Leave empty to keep the saved key.', 'spooflens-fraud-check' ),
    100100                                                                '<a href="https://spooflens.com/en/app/api" target="_blank" rel="noopener">spooflens.com/en/app/api</a>'
    101101                                                        );
  • spooflens-fraud-check/tags/0.2.1/readme.txt

    r3765684 r3765708  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.2.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2626Works with WordPress registration, WooCommerce My Account registration, both the classic and the block WooCommerce checkout, Contact Form 7 and WPForms. Compatible with High-Performance Order Storage. A blocked form submission is treated as spam: Contact Form 7 marks it as spam, WPForms shows an error and does not save the entry.
    2727
    28 A free key gives 1,000 checks a month during the beta: https://spooflens.com/en/app/api
     28A free key gives 1,000 checks a month: https://spooflens.com/en/app/api
    2929
    3030== Installation ==
    … …  
    6868This plugin relies on the SpoofLens service (https://spooflens.com), operated by SpoofLens.
    6969
    70 * On the registration page, on the WooCommerce checkout and My Account pages and on pages with a Contact Form 7 or WPForms form, the visitor's browser runs the collector script that ships with the plugin (assets/sl.js, MIT; its signal part is also published at https://github.com/spooflens/spooflens-collector). The script collects technical characteristics the browser reports to websites (system, screen, languages, time zone, fonts, graphics card, canvas, WebGL and audio fingerprints) and connects to edge.spooflens.com and a random name under l.spooflens.com to read the TLS handshake, the DNS resolver and the public WebRTC address.
     70* On the registration page, on the WooCommerce checkout and My Account pages and on pages with a Contact Form 7 or WPForms form, the visitor's browser runs the collector script that ships with the plugin (assets/sl.js, MIT; its signal part is also published at https://github.com/spooflens/spooflens-collector). The script collects technical characteristics the browser reports to websites (system and its display settings, screen, languages, time zone, fonts, graphics card and its parameters, sound output, the number and a hash of the names of speech voices, which emoji the system can draw, a class of the storage quota, canvas, WebGL and audio fingerprints) and connects to edge.spooflens.com and a random name under l.spooflens.com to read the TLS handshake, the first TCP packet of the connection, the round trip of a few short requests, the DNS resolver and the public WebRTC address.
    7171* When a user registers, an order is placed or a form is sent, your server sends to https://spooflens.com/v1/assess: the collected data, the visitor's IP address and request headers (User-Agent, Accept-Language, Client Hints), and a SHA-256 hash of the email address. The answer (score, verdict, reasons) is stored with the user or order on your site.
    7272* SpoofLens does not store the browser data or the full IP address. Its request log, visible to you in your SpoofLens account, keeps for 30 days the time, score, decision, reasons, device identifier, the shortened IP address, country and network type; it also keeps a hash of the device identifier and of the account reference for 30 days to count accounts per device.
    … …  
    7676
    7777== Changelog ==
     78
     79= 0.2.1 =
     80* Updated collector: more traits of the device and its system (display settings, sound output, speech voices, emoji support, a class of the storage quota, graphics card parameters, a second WebGL test scene), the JavaScript engine, traces of automation tools and the round trip to the checking server. The new checks run on the SpoofLens side; your settings do not change.
     81* Settings: the free key gives 1,000 checks a month, also after the beta.
    7882
    7983= 0.2.0 =
  • spooflens-fraud-check/tags/0.2.1/spooflens.php

    r3765684 r3765708  
    44 * Plugin URI:        https://spooflens.com/developers
    55 * Description:       Scores sign-ups, WooCommerce orders and Contact Form 7 / WPForms submissions for spoofed browsers, bots, VPNs and several accounts on one device, using the SpoofLens API.
    6  * Version:           0.2.0
     6 * Version:           0.2.1
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    1717defined( 'ABSPATH' ) || exit;
    1818
    19 define( 'SPOOFLENS_VERSION', '0.2.0' );
     19define( 'SPOOFLENS_VERSION', '0.2.1' );
    2020define( 'SPOOFLENS_FILE', __FILE__ );
    2121// Can be set in wp-config.php to point the plugin at a test server.
  • spooflens-fraud-check/trunk/assets/sl.js

    r3765684 r3765708  
    330330  }
    331331
     332  // speech voices: the count, and which system the on-device voices come with (Microsoft ones with Windows,
     333  // Apple ones with macOS and iOS); network voices (Google in desktop Chrome, "Online" in Edge) are left out.
     334  // Only the counts leave the browser, not the names.
     335  var APPLE_VOICES = /^(Samantha|Alex|Daniel|Karen|Moira|Tessa|Fiona|Fred|Victoria|Rishi|Veena|Thomas|Anna|Milena|Yuri|Amelie|Amélie|Kyoko|Ting-Ting)( |$)/;
     336  function voiceSummary(list) {
     337    var k = { win: 0, apple: 0, other: 0 }, names = [];
     338    for (var i = 0; i < list.length; i++) {
     339      var v = list[i];
     340      if (!v || v.localService !== true) continue;
     341      var n = String(v.name || ''), u = String(v.voiceURI || '');
     342      names.push(n);
     343      if (/^Microsoft /.test(n)) k.win++;
     344      else if (/^com\.apple\./.test(u) || APPLE_VOICES.test(n)) k.apple++;
     345      else k.other++;
     346    }
     347    // the names of the system's own voices, as one hash (0.18.0): the same in every browser of a device, and voices
     348    // the owner downloaded tell two phones of one model apart; the names themselves are not sent
     349    return { n: list.length, k: k, h: names.length ? hash(names.sort().join('|')) : '' };
     350  }
    332351  function voices() {
    333352    if (!window.speechSynthesis) return Promise.resolve(null);
    334353    var v = speechSynthesis.getVoices();
    335     if (v && v.length) return Promise.resolve(v.length);
     354    if (v && v.length) return Promise.resolve(voiceSummary(v));
    336355    return timeout(new Promise(function (r) {
    337       speechSynthesis.addEventListener('voiceschanged', function () { r(speechSynthesis.getVoices().length); }, { once: true });
    338     }), 800, 0);
     356      speechSynthesis.addEventListener('voiceschanged', function () { r(voiceSummary(speechSynthesis.getVoices())); }, { once: true });
     357    }), 800, { n: 0, k: null });
     358  }
     359
     360  // the JavaScript engine words its own TypeError: V8 (Chromium), SpiderMonkey (Firefox), JavaScriptCore (Safari, every iOS browser)
     361  function jsEngine() {
     362    try { var o = null; o.x = 1; } catch (e) {
     363      var m = String((e && e.message) || '');
     364      if (/Cannot set propert/.test(m)) return 'v8';
     365      if (/can't access property|has no properties|is null/.test(m)) return 'gecko';
     366      if (/is not an object/.test(m)) return 'jsc';
     367      return 'other';
     368    }
     369    return '';
     370  }
     371
     372  // globals and document properties that automation frameworks leave behind
     373  function autoTraces() {
     374    var out = [];
     375    ['__playwright__binding__', '__pwInitScripts', '_phantom', 'callPhantom', '__nightmare', 'domAutomation', 'domAutomationController',
     376     '_selenium', 'callSelenium', '_Selenium_IDE_Recorder', '__webdriver_evaluate', '__selenium_evaluate', '__webdriver_script_fn',
     377     '__driver_evaluate', '__fxdriver_evaluate', '__lastWatirAlert'].forEach(function (k) {
     378      try { if (k in window) out.push(k); } catch (e) {}
     379    });
     380    try {
     381      Object.keys(document).forEach(function (k) {
     382        if (/^\$?cdc_|^\$wdc_|^__(webdriver|selenium|fxdriver|driver)_/.test(k)) out.push('document.' + k);
     383      });
     384      ['webdriver', 'selenium', 'driver'].forEach(function (a) { if (document.documentElement.hasAttribute(a)) out.push('html[' + a + ']'); });
     385    } catch (e) {}
     386    return out.slice(0, 8);
    339387  }
    340388
    … …  
    346394      return o;
    347395    }), 1500, null);
     396  }
     397
     398  /* ------------------------------------------------ device traits (0.18.0) --- */
     399  // What comes from the machine and the system rather than from the browser engine, so that two browsers of one
     400  // device can be told apart from two devices (Chrome and Firefox on one computer share almost no other trait).
     401
     402  // WebGL limits and shader precision without the names: the graphics card and its driver. p: the limits and
     403  // precision; e: the extension list (it differs more between engines, kept apart).
     404  function glParams() {
     405    var o = { p: '', e: '' };
     406    try {
     407      var c = document.createElement('canvas');
     408      var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
     409      if (!gl) return o;
     410      var P = ['MAX_TEXTURE_SIZE', 'MAX_CUBE_MAP_TEXTURE_SIZE', 'MAX_RENDERBUFFER_SIZE', 'MAX_VERTEX_ATTRIBS', 'MAX_VERTEX_UNIFORM_VECTORS',
     411        'MAX_FRAGMENT_UNIFORM_VECTORS', 'MAX_VARYING_VECTORS', 'MAX_COMBINED_TEXTURE_IMAGE_UNITS', 'MAX_TEXTURE_IMAGE_UNITS',
     412        'MAX_VERTEX_TEXTURE_IMAGE_UNITS', 'MAX_VIEWPORT_DIMS', 'ALIASED_LINE_WIDTH_RANGE', 'ALIASED_POINT_SIZE_RANGE', 'SUBPIXEL_BITS'];
     413      var out = P.map(function (k) { var v = gl.getParameter(gl[k]); return v && typeof v === 'object' && v.length !== undefined ? Array.prototype.join.call(v, 'x') : String(v); });
     414      ['VERTEX_SHADER', 'FRAGMENT_SHADER'].forEach(function (sh) {
     415        ['LOW_FLOAT', 'MEDIUM_FLOAT', 'HIGH_FLOAT', 'LOW_INT', 'MEDIUM_INT', 'HIGH_INT'].forEach(function (pr) {
     416          var x = gl.getShaderPrecisionFormat(gl[sh], gl[pr]);
     417          out.push(x ? x.rangeMin + '/' + x.rangeMax + '/' + x.precision : '-');
     418        });
     419      });
     420      o.p = hash(out.join('|'));
     421      o.e = hash((gl.getSupportedExtensions() || []).slice().sort().join(','));
     422      var lose = gl.getExtension('WEBGL_lose_context');
     423      if (lose) lose.loseContext();
     424    } catch (e) {}
     425    return o;
     426  }
     427
     428  // Scene 2.0: five small drawings, each leaning on another part of the graphics card (float maths, derivatives,
     429  // blending, texture filtering, trigonometry at its edges). Browsers of one computer go through one driver, so
     430  // our first scene already matched across engines; five give more to tell devices apart. Drawn twice: an
     431  // unmodified browser gives the same pixels (noise = something rewrote them).
     432  function scene2() {
     433    var o = { h: '', parts: [], noise: false };
     434    try {
     435      var c = document.createElement('canvas'); c.width = 32; c.height = 32;
     436      var opt = { antialias: false, preserveDrawingBuffer: true };
     437      var gl = c.getContext('webgl', opt) || c.getContext('experimental-webgl', opt);
     438      if (!gl) return o;
     439      var hp = gl.getShaderPrecisionFormat && gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
     440      var prec = 'precision ' + (hp && hp.precision > 0 ? 'highp' : 'mediump') + ' float;varying vec2 v;';
     441      var der = gl.getExtension('OES_standard_derivatives');
     442      var vs = 'attribute vec2 a;varying vec2 v;void main(){v=a*0.5+0.5;gl_Position=vec4(a,0.0,1.0);}';
     443      var frags = [
     444        prec + 'void main(){float x=v.x*1000.0+v.y*7.3;gl_FragColor=vec4(fract(sin(x)*1e4),fract(exp(v.y*9.0)),fract(pow(v.x+1.0,13.7)),1.0);}',
     445        der ? '#extension GL_OES_standard_derivatives : enable\n' + prec + 'void main(){float f=sin(v.x*50.0)*cos(v.y*31.0);gl_FragColor=vec4(fwidth(f)*8.0,abs(dFdx(f))*20.0,abs(dFdy(f))*20.0,1.0);}' : '',
     446        prec + 'uniform float k;void main(){gl_FragColor=vec4(v.x,v.y*k,1.0-v.x*k,0.37);}',
     447        prec + 'uniform sampler2D t;void main(){gl_FragColor=texture2D(t,v*0.73+0.11);}',
     448        prec + 'void main(){float a=atan(v.y*2.0-1.0,v.x*2.0-1.0);gl_FragColor=vec4(fract(a*7.0),fract(acos(v.x*1.999-0.9995)*5.0),fract(tan(v.y*1.5)),1.0);}'
     449      ];
     450      var sh = function (type, src) { var x = gl.createShader(type); gl.shaderSource(x, src); gl.compileShader(x); return x; };
     451      var buf = gl.createBuffer();
     452      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
     453      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
     454      var tex = gl.createTexture();
     455      gl.bindTexture(gl.TEXTURE_2D, tex);
     456      var tp = new Uint8Array(64);
     457      for (var i = 0; i < 64; i++) tp[i] = (i * 53 + 17) % 256;
     458      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, tp);
     459      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
     460      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
     461      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
     462      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
     463      gl.disable(gl.DITHER); gl.viewport(0, 0, 32, 32);
     464      var px = new Uint8Array(32 * 32 * 4);
     465      var progs = frags.map(function (src) {
     466        if (!src) return null;
     467        var p = gl.createProgram();
     468        gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, src)); gl.linkProgram(p);
     469        return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
     470      });
     471      var pass = function () {
     472        return progs.map(function (p, n) {
     473          if (!p) return '-';
     474          gl.useProgram(p);
     475          var loc = gl.getAttribLocation(p, 'a');
     476          gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
     477          gl.clearColor(0.1, 0.2, 0.3, 1.0); gl.clear(gl.COLOR_BUFFER_BIT);
     478          if (n === 2) {
     479            // three half-transparent layers on top of each other
     480            gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
     481            var k = gl.getUniformLocation(p, 'k');
     482            [0.3, 0.6, 0.9].forEach(function (x) { gl.uniform1f(k, x); gl.drawArrays(gl.TRIANGLES, 0, 6); });
     483            gl.disable(gl.BLEND);
     484          } else {
     485            if (n === 3) gl.uniform1i(gl.getUniformLocation(p, 't'), 0);
     486            gl.drawArrays(gl.TRIANGLES, 0, 6);
     487          }
     488          gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, px);
     489          var str = [];
     490          for (var i2 = 0; i2 < px.length; i2 += 1024) str.push(String.fromCharCode.apply(null, px.subarray(i2, i2 + 1024)));
     491          return hash(str.join('')).slice(0, 8);
     492        });
     493      };
     494      var a = pass(), b = pass();
     495      o.parts = a;
     496      o.noise = a.join() !== b.join();
     497      o.h = hash(a.join('|'));
     498      var lose = gl.getExtension('WEBGL_lose_context');
     499      if (lose) lose.loseContext();
     500    } catch (e) {}
     501    return o;
     502  }
     503
     504  // The screen as the system lays it out: the size in device pixels, the strip the taskbar or dock takes, the
     505  // menu bar at the top (macOS), the colour range of the display and HDR, and whether more than one display is on.
     506  function screen2() {
     507    var sc = window.screen || {}, dpr = window.devicePixelRatio || 1;
     508    var on = function (q) { return mq(q) === true; };
     509    return {
     510      pw: Math.round((sc.width || 0) * dpr), ph: Math.round((sc.height || 0) * dpr),
     511      tb: (sc.height || 0) - (sc.availHeight || 0), tw: (sc.width || 0) - (sc.availWidth || 0),
     512      at: typeof sc.availTop === 'number' ? sc.availTop : null, al: typeof sc.availLeft === 'number' ? sc.availLeft : null,
     513      gamut: on('(color-gamut: rec2020)') ? 'rec2020' : (on('(color-gamut: p3)') ? 'p3' : (on('(color-gamut: srgb)') ? 'srgb' : '')),
     514      hdr: on('(dynamic-range: high)'), ext: sc.isExtended === true
     515    };
     516  }
     517
     518  // The sound hardware: the default sample rate, the output channels and the latency of the system's audio.
     519  function audioHw() {
     520    var AC = window.AudioContext || window.webkitAudioContext;
     521    if (!AC) return Promise.resolve(null);
     522    try {
     523      var ac = new AC();
     524      var o = { sr: ac.sampleRate || 0, ch: ac.destination ? ac.destination.maxChannelCount : null,
     525        bl: typeof ac.baseLatency === 'number' ? Math.round(ac.baseLatency * 10000) / 10 : null };
     526      try { ac.close(); } catch (e) {}
     527      return Promise.resolve(o);
     528    } catch (e) { return Promise.resolve(null); }
     529  }
     530
     531  // Emoji clock: one emoji of each Unicode emoji version. The newest one the system draws in colour as a single
     532  // glyph tells the version of its emoji font, which follows the version of the system: browsers and iOS 26+
     533  // freeze the system version in the User-Agent, the emoji do not. m: the sizes of three emoji (the font itself).
     534  var EMOJI = [['11', '\uD83E\uDD70'], ['12', '\uD83E\uDD71'], ['13', '\uD83E\uDD72'], ['13.1', '\u2764\uFE0F\u200D\uD83D\uDD25'], ['14', '\uD83E\uDEE0'],
     535    ['15', '\uD83E\uDEE8'], ['15.1', '\uD83D\uDE42\u200D\u2194\uFE0F'], ['16', '\uD83E\uDEE9']];
     536  function emojiClock() {
     537    var o = { v: '', ok: '', m: '' };
     538    try {
     539      var c = document.createElement('canvas'); c.width = 64; c.height = 40;
     540      var x = c.getContext('2d');
     541      x.font = '28px sans-serif'; x.textBaseline = 'top';
     542      var one = x.measureText('\uD83D\uDE00').width;
     543      var ok = [];
     544      EMOJI.forEach(function (e) {
     545        x.clearRect(0, 0, 64, 40);
     546        x.fillText(e[1], 0, 2);
     547        var d = x.getImageData(0, 0, 64, 40).data, col = 0;
     548        for (var i = 0; i < d.length; i += 4) {
     549          if (d[i + 3] > 128 && (Math.abs(d[i] - d[i + 1]) > 40 || Math.abs(d[i + 1] - d[i + 2]) > 40)) col++;
     550        }
     551        // in colour and as one glyph: a sequence the font does not know falls apart into two or more
     552        if (col > 20 && x.measureText(e[1]).width < one * 1.5) ok.push(e[0]);
     553      });
     554      o.ok = ok.join(',');
     555      o.v = ok.length ? ok[ok.length - 1] : '';
     556      var m = x.measureText('\uD83D\uDE00\uD83E\uDEE0\uD83D\uDC4D\uD83C\uDFFD');
     557      o.m = hash([one, m.width, m.actualBoundingBoxAscent, m.actualBoundingBoxDescent].map(function (n) { return Math.round((n || 0) * 10); }).join(','));
     558    } catch (e) {}
     559    return o;
     560  }
     561
     562  // How much the browser lets a site keep, as a class only (0: under 1 GB ... 8: 1.6 TB and more). In Chromium
     563  // it follows the size of the disk, which tells two phones of one model with different storage apart.
     564  function storeTier() {
     565    if (!navigator.storage || !navigator.storage.estimate) return Promise.resolve(null);
     566    return timeout(navigator.storage.estimate().then(function (e) {
     567      var gb = ((e && e.quota) || 0) / 1e9, edges = [1, 10, 50, 100, 200, 400, 800, 1600], t = 0;
     568      while (t < edges.length && gb >= edges[t]) t++;
     569      return { t: t };
     570    }), 1000, null);
    348571  }
    349572
    … …  
    410633   *    (the request itself fails, there is no certificate for that name), then /dns/<id> says who asked;
    411634   *  - STUN: the public address WebRTC's UDP traffic comes from (srflx candidates).
     635   *  - QUIC (0.19.0, on its own host): /a offers HTTP/3 in an Alt-Svc header and closes its TCP connection, three
     636   *    /b requests show whether the browser moves to HTTP/3, /r returns what the server saw, signed.
    412637   * The tokens are verified on spooflens.com; the browser only carries them.
    413638   */
    414   function edgeProbe(host, zone) {
     639  function edgeProbe(host, zone, qhost) {
    415640    // on spooflens.com the page names the edge (data-edge); sl.js on client sites passes it in
    416641    host = host || document.documentElement.getAttribute('data-edge');
    417642    zone = zone || document.documentElement.getAttribute('data-edge-zone');
     643    qhost = qhost || document.documentElement.getAttribute('data-edge-quic');
    418644    if (!host || !window.fetch) return Promise.resolve(null);
    419645    var base = 'https://' + host, out = { tls: '', dns: '', stun: [] };
    420646    var get = function (u) { return fetch(base + u, { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.json(); }); };
    421     var tlsP = timeout(get('/fp').then(function (j) { out.tls = j.token || ''; }), 3000, null).catch(function () {});
     647    // round trip as the page sees it (0.16.0): three pings on the connection /fp has just used, the fastest kept.
     648    // The server compares it with the round trip the edge's kernel measured for that connection.
     649    var tag = Math.random().toString(36).slice(2, 8);   // this run's own timing entries
     650    var ping = function (n) {
     651      var u = base + '/ping?n=' + tag + n, t0 = performance.now();
     652      return fetch(u, { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.text(); }).then(function () {
     653        var ms = performance.now() - t0;
     654        // the network stack's own timing (the edge sends Timing-Allow-Origin), free of main-thread delays
     655        try {
     656          var e = performance.getEntriesByName(u).pop();
     657          if (e && e.requestStart > 0 && e.responseStart >= e.requestStart) ms = e.responseStart - e.requestStart;
     658        } catch (x) {}
     659        return ms;
     660      });
     661    };
     662    var pings = function () {
     663      var ms = [];
     664      var keep = function (v) { ms.push(v); };
     665      return ping(1).then(keep).then(function () { return ping(2); }).then(keep).then(function () { return ping(3); }).then(keep)
     666        .catch(function () {})
     667        .then(function () { if (ms.length) out.rtt = Math.round(Math.min.apply(null, ms) * 10) / 10; });
     668    };
     669    // QUIC after the pings, so its traffic does not sit in their timing. nextHopProtocol of each request is the
     670    // browser's own word (the QUIC host sends Timing-Allow-Origin); the server's account is in the token.
     671    var quic = function () {
     672      if (!qhost) return null;
     673      var qb = 'https://' + qhost, qid = '', urls = [];
     674      for (var k = 0; k < 16; k++) qid += '0123456789abcdef'[Math.floor(Math.random() * 16)];
     675      var wait = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
     676      var q = function (p) {
     677        var u = qb + p + (p.indexOf('?') >= 0 ? '&' : '?') + 'id=' + qid;
     678        if (p !== '/r') urls.push(u);
     679        return fetch(u, { cache: 'no-store', credentials: 'omit' });
     680      };
     681      // the timing entry of a request is written after fetch() resolves, so the protocols are read at the end
     682      var protocols = function () {
     683        return urls.map(function (u) { try { var e = performance.getEntriesByName(u).pop(); return e ? e.nextHopProtocol || '' : ''; } catch (x) { return ''; } });
     684      };
     685      return timeout(q('/a').then(function () { return wait(50); })
     686        .then(function () { return q('/b?n=1'); }).then(function () { return wait(300); })
     687        .then(function () { return q('/b?n=2'); }).then(function () { return wait(200); })
     688        .then(function () { return q('/b?n=3'); })
     689        .then(function () { return q('/r'); }).then(function (r) { return r.json(); })
     690        .then(function (j) { if (j && j.token) out.quic = { token: j.token, np: protocols() }; }), 4000, null).catch(function () {});
     691    };
     692    var tlsP = timeout(get('/fp').then(function (j) { out.tls = j.token || ''; }), 3000, null).catch(function () {})
     693      .then(function () { return out.tls ? timeout(pings(), 2000, null) : null; }).catch(function () {})
     694      .then(quic);
    422695    var id = '';
    423696    for (var i = 0; i < 16; i++) id += '0123456789abcdef'[Math.floor(Math.random() * 16)];
    … …  
    534807      media: {
    535808        dark: mq('(prefers-color-scheme: dark)'), reduced: mq('(prefers-reduced-motion: reduce)'),
    536         pointer: mq('(pointer: fine)') ? 'fine' : (mq('(pointer: coarse)') ? 'coarse' : 'none'), hover: mq('(hover: hover)')
     809        pointer: mq('(pointer: fine)') ? 'fine' : (mq('(pointer: coarse)') ? 'coarse' : 'none'), hover: mq('(hover: hover)'),
     810        // settings of the system (0.18.0): contrast, transparency, inverted and forced colours
     811        contrast: mq('(prefers-contrast: more)') ? 'more' : (mq('(prefers-contrast: less)') ? 'less' : (mq('(prefers-contrast: custom)') ? 'custom' : 'no')),
     812        transp: mq('(prefers-reduced-transparency: reduce)'), invert: mq('(inverted-colors: inverted)'), forced: mq('(forced-colors: active)')
    537813      },
     814      scr2: screen2(),
    538815      gfx: { vendor: gl.vendor || '', renderer: gl.renderer || '', version: gl.version || '', glsl: gl.glsl || '',
    539              maxTex: gl.maxTex || null, extCount: gl.extCount || 0, hash: gl.hash || '' },
     816             maxTex: gl.maxTex || null, extCount: gl.extCount || 0, hash: gl.hash || '', params: glParams() },
    540817      canvas: { h1: c1, h2: c2, noise: !!(c1 && c2 && c1 !== c2), flat: canvasFlat() },
    541818      scene: webglScene(roads),
     819      scene2: scene2(),
     820      emoji: emojiClock(),
    542821      fonts: fonts(),
    543       auto: { webdriver: nav.webdriver === true, chromeObj: !!window.chrome, tamper: tamperCheck() },
     822      auto: { webdriver: nav.webdriver === true, chromeObj: !!window.chrome, tamper: tamperCheck(), engine: jsEngine(), traces: autoTraces() },
     823      // WebTransport (HTTP/3 for pages, 0.19.0): exposed only in a secure context, so elsewhere nothing is said
     824      wt: window.isSecureContext ? typeof window.WebTransport === 'function' : null,
    544825      iframe: iframeProbe(),
    545826      priv: {
    … …  
    548829      }
    549830    };
    550     return Promise.all([audioHash(), audioHash(), webgpu(), voices(), devices(), rtc(), workerProbe(workerSrc), uaData(), edgeProbe(edgeConf && edgeConf.host, edgeConf && edgeConf.zone), motion(), sceneRoads(roads)])
     831    return Promise.all([audioHash(), audioHash(), webgpu(), voices(), devices(), rtc(), workerProbe(workerSrc), uaData(), edgeProbe(edgeConf && edgeConf.host, edgeConf && edgeConf.zone, edgeConf && edgeConf.quic), motion(), sceneRoads(roads), audioHw(), storeTier()])
    551832      .then(function (r) {
    552833        if (r[10]) s.scene.roads = r[10];
    553834        s.audio = { h1: r[0] ? hash(r[0]) : '', h2: r[1] ? hash(r[1]) : '', noise: !!(r[0] && r[1] && r[0] !== r[1]) };
    554835        if (r[2]) { s.gfx.webgpuVendor = r[2].vendor || ''; s.gfx.webgpuArch = r[2].arch || ''; }
    555         s.voices = r[3]; s.devices = r[4]; s.rtc = r[5]; s.worker = r[6]; s.nav.uad = r[7];
     836        s.voices = r[3] ? r[3].n : r[3]; s.vo = r[3] ? r[3].k : null; s.devices = r[4]; s.rtc = r[5]; s.worker = r[6]; s.nav.uad = r[7];
    556837        if (r[8]) s.edge = r[8];
    557838        s.motion = r[9];
     839        s.audio2 = r[11];
     840        s.store = r[12];
     841        s.vh = r[3] ? r[3].h || '' : '';
     842        // the voice list often arrives late: read it once more now that everything else is done
     843        try {
     844          var vl = window.speechSynthesis ? speechSynthesis.getVoices() : [];
     845          if (vl && vl.length > (s.voices || 0)) { var vs2 = voiceSummary(vl); s.voices = vs2.n; s.vo = vs2.k; s.vh = vs2.h; }
     846        } catch (e) {}
    558847        s.fp = hash([s.canvas.h1, s.gfx.hash, s.audio.h1, s.fonts.join(','), s.scr.w + 'x' + s.scr.h + '@' + s.scr.dpr,
    559848          s.nav.hc, s.nav.dm, s.tz.name, s.nav.languages.join(','), s.nav.platform].join('|'));
    … …  
    561850      })
    562851      .then(waitOuter);
     852  }
     853
     854  // Research links (0.16.1): two more quick passes over the drawing, sound, font and voice tests. Only how many
     855  // times each part came out different is kept (s.rep), which shows the natural noise of this device.
     856  function repeatPasses(s) {
     857    var rep = { n: 0, canvas: 0, webgl: 0, scene: 0, audio: 0, fonts: 0, voices: 0 };
     858    var fontsKey = (s.fonts || []).join(','), sceneH = (s.scene && s.scene.h1) || '';
     859    var one = function () {
     860      var c = canvasHash(), g = webgl(), sc = webglScene({}), fk = fonts().join(',');
     861      return Promise.all([audioHash(), voices()]).then(function (r) {
     862        rep.n++;
     863        if (c !== s.canvas.h1) rep.canvas++;
     864        if ((g.hash || '') !== (s.gfx.hash || '')) rep.webgl++;
     865        if (((sc && sc.h1) || '') !== sceneH) rep.scene++;
     866        if ((r[0] ? hash(r[0]) : '') !== ((s.audio && s.audio.h1) || '')) rep.audio++;
     867        if (fk !== fontsKey) rep.fonts++;
     868        if ((r[1] ? r[1].n : r[1]) !== s.voices) rep.voices++;
     869      });
     870    };
     871    return timeout(one().then(one), 6000, null).catch(function () {}).then(function () { if (rep.n) s.rep = rep; });
    563872  }
    564873
  • spooflens-fraud-check/trunk/includes/admin.php

    r3765684 r3765708  
    9797                                                        printf(
    9898                                                                /* translators: %s: link to the SpoofLens account */
    99                                                                 esc_html__( 'Free key: %s (1,000 checks a month during the beta). Leave empty to keep the saved key.', 'spooflens-fraud-check' ),
     99                                                                esc_html__( 'Free key: %s (1,000 checks a month free). Leave empty to keep the saved key.', 'spooflens-fraud-check' ),
    100100                                                                '<a href="https://spooflens.com/en/app/api" target="_blank" rel="noopener">spooflens.com/en/app/api</a>'
    101101                                                        );
  • spooflens-fraud-check/trunk/readme.txt

    r3765684 r3765708  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.2.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2626Works with WordPress registration, WooCommerce My Account registration, both the classic and the block WooCommerce checkout, Contact Form 7 and WPForms. Compatible with High-Performance Order Storage. A blocked form submission is treated as spam: Contact Form 7 marks it as spam, WPForms shows an error and does not save the entry.
    2727
    28 A free key gives 1,000 checks a month during the beta: https://spooflens.com/en/app/api
     28A free key gives 1,000 checks a month: https://spooflens.com/en/app/api
    2929
    3030== Installation ==
    … …  
    6868This plugin relies on the SpoofLens service (https://spooflens.com), operated by SpoofLens.
    6969
    70 * On the registration page, on the WooCommerce checkout and My Account pages and on pages with a Contact Form 7 or WPForms form, the visitor's browser runs the collector script that ships with the plugin (assets/sl.js, MIT; its signal part is also published at https://github.com/spooflens/spooflens-collector). The script collects technical characteristics the browser reports to websites (system, screen, languages, time zone, fonts, graphics card, canvas, WebGL and audio fingerprints) and connects to edge.spooflens.com and a random name under l.spooflens.com to read the TLS handshake, the DNS resolver and the public WebRTC address.
     70* On the registration page, on the WooCommerce checkout and My Account pages and on pages with a Contact Form 7 or WPForms form, the visitor's browser runs the collector script that ships with the plugin (assets/sl.js, MIT; its signal part is also published at https://github.com/spooflens/spooflens-collector). The script collects technical characteristics the browser reports to websites (system and its display settings, screen, languages, time zone, fonts, graphics card and its parameters, sound output, the number and a hash of the names of speech voices, which emoji the system can draw, a class of the storage quota, canvas, WebGL and audio fingerprints) and connects to edge.spooflens.com and a random name under l.spooflens.com to read the TLS handshake, the first TCP packet of the connection, the round trip of a few short requests, the DNS resolver and the public WebRTC address.
    7171* When a user registers, an order is placed or a form is sent, your server sends to https://spooflens.com/v1/assess: the collected data, the visitor's IP address and request headers (User-Agent, Accept-Language, Client Hints), and a SHA-256 hash of the email address. The answer (score, verdict, reasons) is stored with the user or order on your site.
    7272* SpoofLens does not store the browser data or the full IP address. Its request log, visible to you in your SpoofLens account, keeps for 30 days the time, score, decision, reasons, device identifier, the shortened IP address, country and network type; it also keeps a hash of the device identifier and of the account reference for 30 days to count accounts per device.
    … …  
    7676
    7777== Changelog ==
     78
     79= 0.2.1 =
     80* Updated collector: more traits of the device and its system (display settings, sound output, speech voices, emoji support, a class of the storage quota, graphics card parameters, a second WebGL test scene), the JavaScript engine, traces of automation tools and the round trip to the checking server. The new checks run on the SpoofLens side; your settings do not change.
     81* Settings: the free key gives 1,000 checks a month, also after the beta.
    7882
    7983= 0.2.0 =
  • spooflens-fraud-check/trunk/spooflens.php

    r3765684 r3765708  
    44 * Plugin URI:        https://spooflens.com/developers
    55 * Description:       Scores sign-ups, WooCommerce orders and Contact Form 7 / WPForms submissions for spoofed browsers, bots, VPNs and several accounts on one device, using the SpoofLens API.
    6  * Version:           0.2.0
     6 * Version:           0.2.1
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    1717defined( 'ABSPATH' ) || exit;
    1818
    19 define( 'SPOOFLENS_VERSION', '0.2.0' );
     19define( 'SPOOFLENS_VERSION', '0.2.1' );
    2020define( 'SPOOFLENS_FILE', __FILE__ );
    2121// Can be set in wp-config.php to point the plugin at a test server.
Note: See TracChangeset for help on using the changeset viewer.