@webdecoy/fcaptcha 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/experimental.js CHANGED
@@ -1,11 +1,32 @@
1
1
  'use strict';
2
2
 
3
+ const { identityObservation, IDENTITY_POLICY } = require('./identity');
3
4
  const { calculateCategoryScores, BEHAVIOURAL_CATEGORIES, CORROBORATION_AGREE_AT,
4
5
  CORROBORATION_FLOOR } = require('./engine');
5
6
 
6
7
  // Change this ID whenever the policy's evidence, thresholds, or scoring semantics
7
8
  // change. An old selector must never silently opt an operator into a new policy.
8
9
  const EXPERIMENTAL_POLICY = 'stealth-corroboration-v1';
10
+ const ANIMATION_POLICY = 'animation-consistency-v1';
11
+
12
+ function animationObservation(signals) {
13
+ const probe = signals.environmental?.animationConsistency;
14
+ const realm = (value) => {
15
+ if (value?.status !== 'ok' || !Array.isArray(value.specified) || value.specified.length !== 3 ||
16
+ !value.specified.every((n) => n === 1000) || !Array.isArray(value.durations) || value.durations.length !== 3 ||
17
+ !value.durations.every((row) => Array.isArray(row) && row.length === 4 &&
18
+ row.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1e9))) return null;
19
+ return value.durations.every((row, i) => row.every((n) => n === (i === 2 ? 1000 : 0)));
20
+ };
21
+ const main = probe?.version === 1 ? realm(probe.main) : null;
22
+ const frame = probe?.version === 1 ? realm(probe.iframe) : null;
23
+ const status = main === null || frame === null ? 'unknown' : main && frame ? 'detected' : 'clear';
24
+ // Always observe: existing policy selectors must never enable new evidence.
25
+ return { mode: 'observe', status, detections: status === 'detected' ? [{
26
+ id: 'animation-timing-inconsistency',
27
+ reason: 'Browser API timing inconsistency; experimental observation, not proof of automation',
28
+ }] : [] };
29
+ }
9
30
 
10
31
  // Observe by default. This intentionally measures a known-ambiguous hypothesis:
11
32
  // #87's stealth session and a DevTools hardware override can look identical.
@@ -31,6 +52,10 @@ function evaluateExperimental(signals, productionScore, detections, blocking = f
31
52
  reason: 'Page and Worker disagree on hardwareConcurrency; also possible with DevTools or privacy tools',
32
53
  }] : [],
33
54
  corroboratingCategories,
55
+ observations: {
56
+ [ANIMATION_POLICY]: animationObservation(signals),
57
+ [IDENTITY_POLICY]: identityObservation(signals),
58
+ },
34
59
  };
35
60
  }
36
61
 
package/identity.js ADDED
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // identity-coherence-v1 compares what a browser claims to be with properties
4
+ // measured independently of that claim. Each measurement is an axis that
5
+ // agrees, contradicts or knows nothing; unknown never counts. Observe-only:
6
+ // it has no enforcement selector, so FCAPTCHA_EXPERIMENTAL_BLOCKING cannot
7
+ // opt anyone into it.
8
+ //
9
+ // The value is in how axes relate, not in a weighted sum. One axis against the
10
+ // claim has ordinary explanations (a VM, a font pack). Measurements that agree
11
+ // with each other against the claim mean the claim was replaced. Measurements
12
+ // that disagree with each other cannot come from changing the claim alone.
13
+ const IDENTITY_POLICY = 'identity-coherence-v1';
14
+
15
+ // Canonical order keeps reasons and fixtures identical across the servers.
16
+ const OS_LABELS = { windows: 'Windows', macos: 'macOS', ios: 'iOS', android: 'Android', chromeos: 'ChromeOS', linux: 'Linux' };
17
+
18
+ // ANGLE names its backend in the renderer string. Direct3D exists only on
19
+ // Windows and Metal only on Apple platforms. Deliberately absent: GPU model
20
+ // names. An Apple GPU runs Linux under Asahi, and Adreno/Mali ship in Windows
21
+ // laptops and Chromebooks, so a model name says nothing certain about the OS.
22
+ const DIRECT3D = /direct3d|\bd3d(9|11|12)\b/i;
23
+ const METAL = /\bmetal\b/i;
24
+
25
+ // The OS families the User-Agent is consistent with, or null. iPadOS requests
26
+ // desktop sites with a macOS UA, which touch points betray.
27
+ function claimedOS(ua, maxTouchPoints) {
28
+ if (typeof ua !== 'string' || ua === '') return null;
29
+ if (/iPhone|iPad|iPod/.test(ua)) return ['ios'];
30
+ if (ua.includes('Android')) return ['android'];
31
+ if (ua.includes('CrOS')) return ['chromeos'];
32
+ if (ua.includes('Windows')) return ['windows'];
33
+ if (/Macintosh|Mac OS X/.test(ua)) return maxTouchPoints > 1 ? ['macos', 'ios'] : ['macos'];
34
+ if (/Linux|X11/.test(ua)) return ['linux'];
35
+ return null;
36
+ }
37
+
38
+ function gpuBackendAxis(env) {
39
+ const axis = { id: 'gpu-backend-os-mismatch', dimension: 'os', observed: null };
40
+ const webgl = env?.webglInfo;
41
+ const renderer = webgl?.supported !== false && typeof webgl?.renderer === 'string' ? webgl.renderer : '';
42
+ const d3d = DIRECT3D.test(renderer);
43
+ const metal = METAL.test(renderer);
44
+ if (d3d && !metal) return { ...axis, observed: ['windows'], subject: 'WebGL Direct3D backend' };
45
+ if (metal && !d3d) return { ...axis, observed: ['macos', 'ios'], subject: 'WebGL Metal backend' };
46
+ return axis;
47
+ }
48
+
49
+ // Mirrors checkFontPlatformCoherence: a short list is a blocked enumeration,
50
+ // and a mixed list (Office on a Mac) proves nothing.
51
+ function fontSetAxis(env) {
52
+ const axis = { id: 'font-set-os-mismatch', dimension: 'os', observed: null };
53
+ const fonts = env?.fontsInfo;
54
+ if (!fonts || typeof fonts !== 'object' || fonts.supported === false ||
55
+ !(typeof fonts.count === 'number' && fonts.count >= 3)) return axis;
56
+ const mac = fonts.hasSFPro === true || fonts.hasMenlo === true;
57
+ const win = fonts.hasSegoeUI === true || fonts.hasCalibri === true;
58
+ if (win && !mac) return { ...axis, observed: ['windows'], subject: 'Font set (Windows faces)' };
59
+ if (mac && !win) return { ...axis, observed: ['macos', 'ios'], subject: 'Font set (macOS faces)' };
60
+ return axis;
61
+ }
62
+
63
+ const overlap = (a, b) => a.some((x) => b.includes(x));
64
+ const label = (set) => Object.keys(OS_LABELS).filter((os) => set.includes(os)).map((os) => OS_LABELS[os]).join('/');
65
+
66
+ function identityObservation(signals) {
67
+ const result = { mode: 'observe', status: 'unknown', detections: [] };
68
+ const env = signals?.environmental;
69
+ const nav = env?.navigator;
70
+ const touch = typeof nav?.maxTouchPoints === 'number' ? nav.maxTouchPoints : 0;
71
+ const claim = claimedOS(nav?.userAgent, touch);
72
+ if (!claim) return result;
73
+
74
+ const known = [gpuBackendAxis(env), fontSetAxis(env)].filter((axis) => axis.observed);
75
+ if (known.length === 0) return result;
76
+
77
+ const contradicting = known.filter((axis) => !overlap(axis.observed, claim));
78
+ for (const axis of contradicting) {
79
+ result.detections.push({ id: axis.id, reason: `${axis.subject} contradicts claimed OS (${label(claim)})` });
80
+ }
81
+ const disagree = known.some((a, i) => known.slice(i + 1).some(
82
+ (b) => a.dimension === b.dimension && !overlap(a.observed, b.observed)));
83
+ if (disagree) {
84
+ result.detections.push({ id: 'identity-axes-disagree', reason: 'Measured properties disagree with each other' });
85
+ } else if (contradicting.length >= 2) {
86
+ result.detections.push({
87
+ id: 'identity-claim-contradicted',
88
+ reason: 'Several measured properties agree with each other and contradict the claimed OS',
89
+ });
90
+ }
91
+ result.status = result.detections.length > 0 ? 'detected' : 'clear';
92
+ return result;
93
+ }
94
+
95
+ module.exports = { identityObservation, claimedOS, IDENTITY_POLICY };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.40.0",
3
+ "version": "1.42.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@
15
15
  "test:clientip": "node clientip.test.js",
16
16
  "test:limits": "node limits.test.js",
17
17
  "test:detection": "node detection.test.js",
18
- "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.test.js experimental.test.js",
18
+ "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.test.js experimental.test.js identity.test.js",
19
19
  "test:siteverify": "node siteverify.test.js",
20
20
  "test:forensics": "node inputforensics.test.js",
21
21
  "test:suspicion": "node --test suspicion.test.js"