engagelab-captcha-sdk 1.0.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/dist/captcha-sdk.esm.js +1 -0
- package/dist/captcha-sdk.umd.js +1 -0
- package/dist/types/api.d.ts +12 -0
- package/dist/types/challenges/dragsort.d.ts +24 -0
- package/dist/types/challenges/grid.d.ts +20 -0
- package/dist/types/challenges/icons.d.ts +18 -0
- package/dist/types/challenges/invisible.d.ts +8 -0
- package/dist/types/challenges/physics.d.ts +40 -0
- package/dist/types/challenges/rotate.d.ts +14 -0
- package/dist/types/challenges/slider.d.ts +17 -0
- package/dist/types/challenges/spatial.d.ts +12 -0
- package/dist/types/compat/hcaptcha.d.ts +1 -0
- package/dist/types/compat/recaptcha.d.ts +1 -0
- package/dist/types/crypto.d.ts +11 -0
- package/dist/types/deep_probes.d.ts +13 -0
- package/dist/types/degradation.d.ts +27 -0
- package/dist/types/device_attestation.d.ts +19 -0
- package/dist/types/fingerprint.d.ts +2 -0
- package/dist/types/headless.d.ts +2 -0
- package/dist/types/i18n.d.ts +7 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/integrity.d.ts +3 -0
- package/dist/types/logger.d.ts +49 -0
- package/dist/types/native_monitor.d.ts +21 -0
- package/dist/types/passive_signals.d.ts +119 -0
- package/dist/types/pow.d.ts +2 -0
- package/dist/types/reporter.d.ts +8 -0
- package/dist/types/session_persist.d.ts +18 -0
- package/dist/types/theme.d.ts +11 -0
- package/dist/types/timing.d.ts +2 -0
- package/dist/types/trajectory.d.ts +18 -0
- package/dist/types/types.d.ts +182 -0
- package/dist/types/ui/container.d.ts +20 -0
- package/dist/types/ui/styles.d.ts +1 -0
- package/dist/types/wasm_core.d.ts +44 -0
- package/dist/types/web-component.d.ts +11 -0
- package/package.json +22 -0
- package/rollup.config.mjs +62 -0
- package/src/api.ts +180 -0
- package/src/challenges/dragsort.ts +168 -0
- package/src/challenges/grid.ts +147 -0
- package/src/challenges/icons.ts +106 -0
- package/src/challenges/invisible.ts +57 -0
- package/src/challenges/physics.ts +437 -0
- package/src/challenges/rotate.ts +81 -0
- package/src/challenges/slider.ts +168 -0
- package/src/challenges/spatial.ts +91 -0
- package/src/compat/hcaptcha.ts +112 -0
- package/src/compat/recaptcha.ts +108 -0
- package/src/crypto.ts +69 -0
- package/src/deep_probes.ts +690 -0
- package/src/degradation.ts +113 -0
- package/src/device_attestation.ts +109 -0
- package/src/fingerprint.ts +247 -0
- package/src/headless.ts +233 -0
- package/src/i18n.ts +455 -0
- package/src/index.ts +527 -0
- package/src/integrity.ts +100 -0
- package/src/logger.ts +170 -0
- package/src/native_monitor.ts +100 -0
- package/src/passive_signals.ts +544 -0
- package/src/pow.ts +120 -0
- package/src/reporter.ts +75 -0
- package/src/session_persist.ts +90 -0
- package/src/theme.ts +41 -0
- package/src/timing.ts +79 -0
- package/src/trajectory.ts +110 -0
- package/src/types.ts +199 -0
- package/src/ui/container.ts +161 -0
- package/src/ui/styles.ts +153 -0
- package/src/wasm_core.ts +189 -0
- package/src/web-component.ts +103 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export interface DegradationConfig {
|
|
2
|
+
maxRetries: number;
|
|
3
|
+
retryDelayMs: number;
|
|
4
|
+
fallbackMode: 'invisible' | 'local-pow' | 'always-pass';
|
|
5
|
+
healthCheckIntervalMs: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG: DegradationConfig = {
|
|
9
|
+
maxRetries: 3,
|
|
10
|
+
retryDelayMs: 1000,
|
|
11
|
+
fallbackMode: 'local-pow',
|
|
12
|
+
healthCheckIntervalMs: 30000,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class DegradationManager {
|
|
16
|
+
private config: DegradationConfig;
|
|
17
|
+
private isServerHealthy: boolean = true;
|
|
18
|
+
private consecutiveFailures: number = 0;
|
|
19
|
+
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(config?: Partial<DegradationConfig>) {
|
|
22
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async executeWithRetry<T>(fn: () => Promise<T>): Promise<T | null> {
|
|
26
|
+
for (let i = 0; i <= this.config.maxRetries; i++) {
|
|
27
|
+
try {
|
|
28
|
+
const result = await fn();
|
|
29
|
+
this.onSuccess();
|
|
30
|
+
return result;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
this.onFailure();
|
|
33
|
+
if (i < this.config.maxRetries) {
|
|
34
|
+
await this.delay(this.config.retryDelayMs * Math.pow(2, i));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
shouldDegrade(): boolean {
|
|
42
|
+
return !this.isServerHealthy || this.consecutiveFailures >= this.config.maxRetries;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getDegradedResult(): { success: boolean; token: string; degraded: boolean } {
|
|
46
|
+
switch (this.config.fallbackMode) {
|
|
47
|
+
case 'always-pass':
|
|
48
|
+
return { success: true, token: 'degraded_pass_' + Date.now(), degraded: true };
|
|
49
|
+
case 'local-pow':
|
|
50
|
+
return { success: true, token: 'degraded_pow_' + this.localPoW(), degraded: true };
|
|
51
|
+
case 'invisible':
|
|
52
|
+
default:
|
|
53
|
+
return { success: false, token: '', degraded: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private localPoW(): string {
|
|
58
|
+
const challenge = Date.now().toString(36);
|
|
59
|
+
let nonce = 0;
|
|
60
|
+
while (true) {
|
|
61
|
+
const hash = this.simpleHash(challenge + nonce);
|
|
62
|
+
if (hash.startsWith('00')) break;
|
|
63
|
+
nonce++;
|
|
64
|
+
if (nonce > 100000) break;
|
|
65
|
+
}
|
|
66
|
+
return challenge + ':' + nonce;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private simpleHash(input: string): string {
|
|
70
|
+
let hash = 0;
|
|
71
|
+
for (let i = 0; i < input.length; i++) {
|
|
72
|
+
const char = input.charCodeAt(i);
|
|
73
|
+
hash = ((hash << 5) - hash) + char;
|
|
74
|
+
hash = hash & hash;
|
|
75
|
+
}
|
|
76
|
+
return Math.abs(hash).toString(16).padStart(8, '0');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startHealthCheck(healthFn: () => Promise<boolean>): void {
|
|
80
|
+
this.stopHealthCheck();
|
|
81
|
+
this.healthCheckTimer = setInterval(async () => {
|
|
82
|
+
try {
|
|
83
|
+
this.isServerHealthy = await healthFn();
|
|
84
|
+
if (this.isServerHealthy) this.consecutiveFailures = 0;
|
|
85
|
+
} catch {
|
|
86
|
+
this.isServerHealthy = false;
|
|
87
|
+
}
|
|
88
|
+
}, this.config.healthCheckIntervalMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
stopHealthCheck(): void {
|
|
92
|
+
if (this.healthCheckTimer) {
|
|
93
|
+
clearInterval(this.healthCheckTimer);
|
|
94
|
+
this.healthCheckTimer = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private onSuccess(): void {
|
|
99
|
+
this.consecutiveFailures = 0;
|
|
100
|
+
this.isServerHealthy = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private onFailure(): void {
|
|
104
|
+
this.consecutiveFailures++;
|
|
105
|
+
if (this.consecutiveFailures >= this.config.maxRetries) {
|
|
106
|
+
this.isServerHealthy = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private delay(ms: number): Promise<void> {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Attestation — hardware-backed trust verification.
|
|
3
|
+
*
|
|
4
|
+
* Web: WebAuthn/FIDO2 for device binding
|
|
5
|
+
* The server can optionally require device attestation for high-risk scenarios.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface AttestationResult {
|
|
9
|
+
supported: boolean;
|
|
10
|
+
type: 'webauthn' | 'none';
|
|
11
|
+
credential?: string;
|
|
12
|
+
attestationObject?: string;
|
|
13
|
+
clientDataJSON?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function checkDeviceAttestationSupport(): Promise<{
|
|
17
|
+
webauthn: boolean;
|
|
18
|
+
platformAuthenticator: boolean;
|
|
19
|
+
conditionalMediation: boolean;
|
|
20
|
+
}> {
|
|
21
|
+
const result = {
|
|
22
|
+
webauthn: false,
|
|
23
|
+
platformAuthenticator: false,
|
|
24
|
+
conditionalMediation: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
result.webauthn = typeof window.PublicKeyCredential !== 'undefined';
|
|
29
|
+
|
|
30
|
+
if (result.webauthn && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
|
|
31
|
+
result.platformAuthenticator = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (result.webauthn && (PublicKeyCredential as unknown as { isConditionalMediationAvailable?: () => Promise<boolean> }).isConditionalMediationAvailable) {
|
|
35
|
+
result.conditionalMediation = await (PublicKeyCredential as unknown as { isConditionalMediationAvailable: () => Promise<boolean> }).isConditionalMediationAvailable();
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function requestDeviceAttestation(
|
|
45
|
+
challenge: Uint8Array,
|
|
46
|
+
rpId: string,
|
|
47
|
+
userId: string,
|
|
48
|
+
): Promise<AttestationResult> {
|
|
49
|
+
if (typeof window.PublicKeyCredential === 'undefined') {
|
|
50
|
+
return { supported: false, type: 'none' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const challengeBuffer = new Uint8Array(challenge.byteLength);
|
|
55
|
+
challengeBuffer.set(challenge);
|
|
56
|
+
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
|
57
|
+
challenge: challengeBuffer as BufferSource,
|
|
58
|
+
rp: {
|
|
59
|
+
name: 'EngageLab CAPTCHA',
|
|
60
|
+
id: rpId,
|
|
61
|
+
},
|
|
62
|
+
user: {
|
|
63
|
+
id: new TextEncoder().encode(userId),
|
|
64
|
+
name: `captcha-${userId.substring(0, 8)}`,
|
|
65
|
+
displayName: 'CAPTCHA Verification',
|
|
66
|
+
},
|
|
67
|
+
pubKeyCredParams: [
|
|
68
|
+
{ alg: -7, type: 'public-key' }, // ES256
|
|
69
|
+
{ alg: -257, type: 'public-key' }, // RS256
|
|
70
|
+
],
|
|
71
|
+
authenticatorSelection: {
|
|
72
|
+
authenticatorAttachment: 'platform',
|
|
73
|
+
userVerification: 'discouraged',
|
|
74
|
+
residentKey: 'discouraged',
|
|
75
|
+
},
|
|
76
|
+
timeout: 10000,
|
|
77
|
+
attestation: 'direct',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const credential = await navigator.credentials.create({
|
|
81
|
+
publicKey: publicKeyOptions,
|
|
82
|
+
}) as PublicKeyCredential;
|
|
83
|
+
|
|
84
|
+
if (!credential) {
|
|
85
|
+
return { supported: true, type: 'webauthn' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const response = credential.response as AuthenticatorAttestationResponse;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
supported: true,
|
|
92
|
+
type: 'webauthn',
|
|
93
|
+
credential: bufferToBase64(credential.rawId),
|
|
94
|
+
attestationObject: bufferToBase64(response.attestationObject),
|
|
95
|
+
clientDataJSON: bufferToBase64(response.clientDataJSON),
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return { supported: true, type: 'webauthn' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bufferToBase64(buffer: ArrayBuffer): string {
|
|
103
|
+
const bytes = new Uint8Array(buffer);
|
|
104
|
+
let binary = '';
|
|
105
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
106
|
+
binary += String.fromCharCode(bytes[i]);
|
|
107
|
+
}
|
|
108
|
+
return btoa(binary);
|
|
109
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Fingerprint, FingerprintOptions, MathFingerprint, StorageInfo } from './types';
|
|
2
|
+
import { sdkLogger } from './logger';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_OPTIONS: Required<FingerprintOptions> = {
|
|
5
|
+
audio: true,
|
|
6
|
+
canvas: true,
|
|
7
|
+
webgl: true,
|
|
8
|
+
fonts: true,
|
|
9
|
+
webrtc: true,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function collectFingerprint(options?: FingerprintOptions): Promise<Fingerprint> {
|
|
13
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
14
|
+
sdkLogger.info('Fingerprint', 'Collection start', {
|
|
15
|
+
canvas: opts.canvas,
|
|
16
|
+
webgl: opts.webgl,
|
|
17
|
+
audio: opts.audio,
|
|
18
|
+
fonts: opts.fonts,
|
|
19
|
+
webrtc: opts.webrtc,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let canvasHash = '';
|
|
23
|
+
let webgl = { renderer: '', vendor: '', hash: '' };
|
|
24
|
+
let audioHash = '';
|
|
25
|
+
let fonts: string[] = [];
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
[canvasHash, webgl, audioHash, fonts] = await Promise.all([
|
|
29
|
+
opts.canvas ? getCanvasFingerprint() : Promise.resolve(''),
|
|
30
|
+
opts.webgl ? getWebGLInfo() : Promise.resolve({ renderer: '', vendor: '', hash: '' }),
|
|
31
|
+
opts.audio ? getAudioFingerprint() : Promise.resolve(''),
|
|
32
|
+
opts.fonts ? detectFonts() : Promise.resolve([]),
|
|
33
|
+
]);
|
|
34
|
+
} catch (e: any) {
|
|
35
|
+
sdkLogger.error('Fingerprint', 'Parallel collection error', e?.message || e);
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const nav = navigator as any;
|
|
40
|
+
|
|
41
|
+
const fp: Fingerprint = {
|
|
42
|
+
canvasHash,
|
|
43
|
+
webglRenderer: webgl.renderer,
|
|
44
|
+
webglVendor: webgl.vendor,
|
|
45
|
+
webglHash: webgl.hash,
|
|
46
|
+
audioHash,
|
|
47
|
+
screenWidth: screen.width,
|
|
48
|
+
screenHeight: screen.height,
|
|
49
|
+
colorDepth: screen.colorDepth,
|
|
50
|
+
devicePixelRatio: window.devicePixelRatio || 1,
|
|
51
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
|
52
|
+
timezoneOffset: new Date().getTimezoneOffset(),
|
|
53
|
+
language: navigator.language || '',
|
|
54
|
+
languages: Array.from(navigator.languages || []),
|
|
55
|
+
platform: nav.platform || '',
|
|
56
|
+
hardwareConcurrency: nav.hardwareConcurrency || 0,
|
|
57
|
+
deviceMemory: nav.deviceMemory || 0,
|
|
58
|
+
maxTouchPoints: nav.maxTouchPoints || 0,
|
|
59
|
+
fonts,
|
|
60
|
+
pluginCount: navigator.plugins?.length || 0,
|
|
61
|
+
webdriver: !!nav.webdriver,
|
|
62
|
+
hasChromeRuntime: !!(window as any).chrome?.runtime,
|
|
63
|
+
connectionType: nav.connection?.effectiveType || '',
|
|
64
|
+
screenAvailWidth: screen.availWidth,
|
|
65
|
+
screenAvailHeight: screen.availHeight,
|
|
66
|
+
outerWidth: window.outerWidth,
|
|
67
|
+
outerHeight: window.outerHeight,
|
|
68
|
+
innerWidth: window.innerWidth,
|
|
69
|
+
innerHeight: window.innerHeight,
|
|
70
|
+
headlessSignals: [], // filled by headless.ts
|
|
71
|
+
mathFp: getMathFingerprint(),
|
|
72
|
+
storageInfo: getStorageInfo(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
sdkLogger.debug('Fingerprint', 'Collection done', {
|
|
76
|
+
canvasHashLen: fp.canvasHash.length,
|
|
77
|
+
webglRenderer: fp.webglRenderer?.substring(0, 40),
|
|
78
|
+
webglVendor: fp.webglVendor?.substring(0, 40),
|
|
79
|
+
audioHashLen: fp.audioHash.length,
|
|
80
|
+
fontCount: fp.fonts.length,
|
|
81
|
+
screen: `${fp.screenWidth}x${fp.screenHeight}`,
|
|
82
|
+
webdriver: fp.webdriver,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return fp;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getCanvasFingerprint(): string {
|
|
89
|
+
try {
|
|
90
|
+
const canvas = document.createElement('canvas');
|
|
91
|
+
canvas.width = 280;
|
|
92
|
+
canvas.height = 60;
|
|
93
|
+
const ctx = canvas.getContext('2d');
|
|
94
|
+
if (!ctx) return '';
|
|
95
|
+
|
|
96
|
+
ctx.textBaseline = 'alphabetic';
|
|
97
|
+
ctx.fillStyle = '#f60';
|
|
98
|
+
ctx.fillRect(125, 1, 62, 20);
|
|
99
|
+
ctx.fillStyle = '#069';
|
|
100
|
+
ctx.font = '11pt Arial';
|
|
101
|
+
ctx.fillText('EngageLab,\ud83d\ude03', 2, 15);
|
|
102
|
+
ctx.fillStyle = 'rgba(102,204,0,0.7)';
|
|
103
|
+
ctx.font = '18pt Arial';
|
|
104
|
+
ctx.fillText('CAPTCHA Test', 4, 45);
|
|
105
|
+
|
|
106
|
+
ctx.strokeStyle = 'rgb(150,50,50)';
|
|
107
|
+
ctx.beginPath();
|
|
108
|
+
ctx.arc(50, 50, 20, 0, Math.PI * 2);
|
|
109
|
+
ctx.stroke();
|
|
110
|
+
|
|
111
|
+
const gradient = ctx.createLinearGradient(0, 0, 280, 0);
|
|
112
|
+
gradient.addColorStop(0, '#ff0000');
|
|
113
|
+
gradient.addColorStop(1, '#0000ff');
|
|
114
|
+
ctx.fillStyle = gradient;
|
|
115
|
+
ctx.fillRect(0, 50, 280, 10);
|
|
116
|
+
|
|
117
|
+
return hashString(canvas.toDataURL());
|
|
118
|
+
} catch (e: any) {
|
|
119
|
+
sdkLogger.warn('Fingerprint', 'Canvas fingerprint failed', e?.message || e);
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getWebGLInfo(): { renderer: string; vendor: string; hash: string } {
|
|
125
|
+
try {
|
|
126
|
+
const canvas = document.createElement('canvas');
|
|
127
|
+
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') as WebGLRenderingContext | null;
|
|
128
|
+
if (!gl) return { renderer: '', vendor: '', hash: '' };
|
|
129
|
+
|
|
130
|
+
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
131
|
+
const renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
|
|
132
|
+
const vendor = ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : '';
|
|
133
|
+
|
|
134
|
+
const params = [
|
|
135
|
+
gl.getParameter(gl.MAX_TEXTURE_SIZE),
|
|
136
|
+
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE),
|
|
137
|
+
gl.getParameter(gl.MAX_VIEWPORT_DIMS),
|
|
138
|
+
gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE),
|
|
139
|
+
gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE),
|
|
140
|
+
gl.getSupportedExtensions()?.join(','),
|
|
141
|
+
].join('|');
|
|
142
|
+
|
|
143
|
+
return { renderer, vendor, hash: hashString(params) };
|
|
144
|
+
} catch (e: any) {
|
|
145
|
+
sdkLogger.warn('Fingerprint', 'WebGL fingerprint failed', e?.message || e);
|
|
146
|
+
return { renderer: '', vendor: '', hash: '' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function getAudioFingerprint(): Promise<string> {
|
|
151
|
+
try {
|
|
152
|
+
const ctx = new OfflineAudioContext(1, 44100, 44100);
|
|
153
|
+
const oscillator = ctx.createOscillator();
|
|
154
|
+
const compressor = ctx.createDynamicsCompressor();
|
|
155
|
+
|
|
156
|
+
oscillator.type = 'triangle';
|
|
157
|
+
oscillator.frequency.setValueAtTime(10000, ctx.currentTime);
|
|
158
|
+
compressor.threshold.setValueAtTime(-50, ctx.currentTime);
|
|
159
|
+
compressor.knee.setValueAtTime(40, ctx.currentTime);
|
|
160
|
+
compressor.ratio.setValueAtTime(12, ctx.currentTime);
|
|
161
|
+
compressor.attack.setValueAtTime(0, ctx.currentTime);
|
|
162
|
+
compressor.release.setValueAtTime(0.25, ctx.currentTime);
|
|
163
|
+
|
|
164
|
+
oscillator.connect(compressor);
|
|
165
|
+
compressor.connect(ctx.destination);
|
|
166
|
+
|
|
167
|
+
oscillator.start(0);
|
|
168
|
+
oscillator.stop(0.5);
|
|
169
|
+
|
|
170
|
+
const rendered = await ctx.startRendering();
|
|
171
|
+
const data = rendered.getChannelData(0);
|
|
172
|
+
|
|
173
|
+
const sample = Array.from(data.slice(4500, 4530)).map(v => v.toFixed(6)).join(',');
|
|
174
|
+
return hashString(sample);
|
|
175
|
+
} catch (e: any) {
|
|
176
|
+
sdkLogger.warn('Fingerprint', 'Audio fingerprint failed', e?.message || e);
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function detectFonts(): string[] {
|
|
182
|
+
const baseFonts = ['monospace', 'sans-serif', 'serif'];
|
|
183
|
+
const testFonts = [
|
|
184
|
+
'Arial', 'Verdana', 'Times New Roman', 'Courier New', 'Georgia',
|
|
185
|
+
'Palatino', 'Garamond', 'Comic Sans MS', 'Trebuchet MS', 'Arial Black',
|
|
186
|
+
'Impact', 'Lucida Console', 'Tahoma', 'Lucida Sans', 'Century Gothic',
|
|
187
|
+
'Bookman Old Style', 'Brush Script MT', 'Rockwell', 'Segoe UI',
|
|
188
|
+
'Microsoft YaHei', 'SimHei', 'SimSun', 'PingFang SC', 'Hiragino Sans',
|
|
189
|
+
'Noto Sans', 'Roboto', 'Open Sans', 'Helvetica', 'Futura',
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const span = document.createElement('span');
|
|
193
|
+
span.style.position = 'absolute';
|
|
194
|
+
span.style.left = '-9999px';
|
|
195
|
+
span.style.fontSize = '72px';
|
|
196
|
+
span.textContent = 'mmmmmmmmmmlli';
|
|
197
|
+
document.body.appendChild(span);
|
|
198
|
+
|
|
199
|
+
const baseWidths: Record<string, number> = {};
|
|
200
|
+
for (const base of baseFonts) {
|
|
201
|
+
span.style.fontFamily = base;
|
|
202
|
+
baseWidths[base] = span.offsetWidth;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const detected: string[] = [];
|
|
206
|
+
for (const font of testFonts) {
|
|
207
|
+
let found = false;
|
|
208
|
+
for (const base of baseFonts) {
|
|
209
|
+
span.style.fontFamily = `'${font}', ${base}`;
|
|
210
|
+
if (span.offsetWidth !== baseWidths[base]) {
|
|
211
|
+
found = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (found) detected.push(font);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
document.body.removeChild(span);
|
|
219
|
+
return detected;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getMathFingerprint(): MathFingerprint {
|
|
223
|
+
return {
|
|
224
|
+
tanValue: Math.tan(-1e300),
|
|
225
|
+
atan2Value: Math.atan2(1, 1),
|
|
226
|
+
logValue: Math.log(1000),
|
|
227
|
+
sinValue: Math.sin(1),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getStorageInfo(): StorageInfo {
|
|
232
|
+
let ls = false, ss = false, idb = false;
|
|
233
|
+
try { ls = !!window.localStorage; } catch {}
|
|
234
|
+
try { ss = !!window.sessionStorage; } catch {}
|
|
235
|
+
try { idb = !!window.indexedDB; } catch {}
|
|
236
|
+
return { localStorage: ls, sessionStorage: ss, indexedDb: idb };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hashString(str: string): string {
|
|
240
|
+
let hash = 0;
|
|
241
|
+
for (let i = 0; i < str.length; i++) {
|
|
242
|
+
const char = str.charCodeAt(i);
|
|
243
|
+
hash = ((hash << 5) - hash) + char;
|
|
244
|
+
hash = hash & hash;
|
|
245
|
+
}
|
|
246
|
+
return Math.abs(hash).toString(36);
|
|
247
|
+
}
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { HeadlessSignal } from './types';
|
|
2
|
+
import { sdkLogger } from './logger';
|
|
3
|
+
|
|
4
|
+
export async function detectHeadless(): Promise<HeadlessSignal[]> {
|
|
5
|
+
sdkLogger.debug('Headless', 'Starting headless signal checks');
|
|
6
|
+
const signals: HeadlessSignal[] = [];
|
|
7
|
+
|
|
8
|
+
signals.push(checkWebdriver());
|
|
9
|
+
signals.push(checkChromeRuntime());
|
|
10
|
+
signals.push(checkPlugins());
|
|
11
|
+
signals.push(checkLanguages());
|
|
12
|
+
signals.push(checkWindowSize());
|
|
13
|
+
signals.push(checkScreenAvail());
|
|
14
|
+
signals.push(await checkNotificationPermission());
|
|
15
|
+
signals.push(await checkPermissionsAPI());
|
|
16
|
+
signals.push(checkErrorStack());
|
|
17
|
+
signals.push(checkMediaQueries());
|
|
18
|
+
signals.push(checkImageLoadTiming());
|
|
19
|
+
signals.push(checkNativeFunctions());
|
|
20
|
+
signals.push(checkPrototypeChain());
|
|
21
|
+
signals.push(checkConnectionAPI());
|
|
22
|
+
signals.push(await checkWebRTC());
|
|
23
|
+
signals.push(checkPerformanceAPI());
|
|
24
|
+
signals.push(checkIframeAccess());
|
|
25
|
+
signals.push(checkConsoleLog());
|
|
26
|
+
signals.push(checkPhantomJS());
|
|
27
|
+
signals.push(checkSeleniumArtifacts());
|
|
28
|
+
|
|
29
|
+
const detected = signals.filter(s => s.detected);
|
|
30
|
+
const headlessScore = detected.reduce((sum, s) => sum + s.weight, 0);
|
|
31
|
+
sdkLogger.info('Headless', `Checked ${signals.length} signals, ${detected.length} positive`, {
|
|
32
|
+
names: detected.map(s => s.name),
|
|
33
|
+
headlessScore,
|
|
34
|
+
});
|
|
35
|
+
sdkLogger.debug(
|
|
36
|
+
'Headless',
|
|
37
|
+
'Per-signal',
|
|
38
|
+
signals.map(s => ({ name: s.name, detected: s.detected, weight: s.weight })),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return signals;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkWebdriver(): HeadlessSignal {
|
|
45
|
+
const detected = !!(navigator as any).webdriver;
|
|
46
|
+
return { name: 'webdriver', detected, value: String(detected), weight: 30 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checkChromeRuntime(): HeadlessSignal {
|
|
50
|
+
const w = window as any;
|
|
51
|
+
const hasChrome = !!w.chrome;
|
|
52
|
+
const hasRuntime = hasChrome && !!w.chrome.runtime;
|
|
53
|
+
const isChromeBrowser = /Chrome/.test(navigator.userAgent) && !/Edge|OPR/.test(navigator.userAgent);
|
|
54
|
+
const detected = isChromeBrowser && hasChrome && !hasRuntime;
|
|
55
|
+
return { name: 'chrome_runtime_missing', detected, value: `chrome=${hasChrome},runtime=${hasRuntime}`, weight: 10 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkPlugins(): HeadlessSignal {
|
|
59
|
+
const count = navigator.plugins?.length || 0;
|
|
60
|
+
const isMobile = /Mobile|Android|iPhone/.test(navigator.userAgent);
|
|
61
|
+
const detected = count === 0 && !isMobile;
|
|
62
|
+
return { name: 'no_plugins', detected, value: String(count), weight: 8 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkLanguages(): HeadlessSignal {
|
|
66
|
+
const langs = navigator.languages;
|
|
67
|
+
const detected = !langs || langs.length === 0;
|
|
68
|
+
return { name: 'no_languages', detected, value: String(langs?.length || 0), weight: 5 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkWindowSize(): HeadlessSignal {
|
|
72
|
+
const ow = window.outerWidth, oh = window.outerHeight;
|
|
73
|
+
const iw = window.innerWidth, ih = window.innerHeight;
|
|
74
|
+
const detected = ow > 0 && iw > 0 && ow === iw && oh === ih;
|
|
75
|
+
return { name: 'outer_equals_inner', detected, value: `${ow}x${oh}==${iw}x${ih}`, weight: 8 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkScreenAvail(): HeadlessSignal {
|
|
79
|
+
const detected = screen.availWidth === screen.width && screen.availHeight === screen.height;
|
|
80
|
+
return { name: 'no_taskbar', detected, value: `avail=${screen.availWidth}x${screen.availHeight}`, weight: 3 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function checkNotificationPermission(): Promise<HeadlessSignal> {
|
|
84
|
+
try {
|
|
85
|
+
const perm = Notification.permission;
|
|
86
|
+
const detected = perm === 'denied';
|
|
87
|
+
return { name: 'notification_denied', detected, value: perm, weight: 5 };
|
|
88
|
+
} catch {
|
|
89
|
+
return { name: 'notification_denied', detected: true, value: 'error', weight: 5 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function checkPermissionsAPI(): Promise<HeadlessSignal> {
|
|
94
|
+
try {
|
|
95
|
+
const result = await navigator.permissions.query({ name: 'notifications' as PermissionName });
|
|
96
|
+
const detected = result.state === 'denied';
|
|
97
|
+
return { name: 'permissions_api_denied', detected, value: result.state, weight: 5 };
|
|
98
|
+
} catch {
|
|
99
|
+
return { name: 'permissions_api_denied', detected: false, value: 'unsupported', weight: 0 };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkErrorStack(): HeadlessSignal {
|
|
104
|
+
try {
|
|
105
|
+
throw new Error('test');
|
|
106
|
+
} catch (e: any) {
|
|
107
|
+
const stack = e.stack || '';
|
|
108
|
+
const isChrome = stack.includes('at ');
|
|
109
|
+
const isFirefox = stack.includes('@');
|
|
110
|
+
const detected = !isChrome && !isFirefox && stack.length < 10;
|
|
111
|
+
return { name: 'unusual_error_stack', detected, value: stack.substring(0, 50), weight: 5 };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkMediaQueries(): HeadlessSignal {
|
|
116
|
+
let score = 0;
|
|
117
|
+
try {
|
|
118
|
+
const hover = window.matchMedia('(hover: hover)').matches;
|
|
119
|
+
const pointer = window.matchMedia('(pointer: fine)').matches;
|
|
120
|
+
const prefersColor = window.matchMedia('(prefers-color-scheme)').matches;
|
|
121
|
+
if (!hover && !pointer && !/Mobile/.test(navigator.userAgent)) score++;
|
|
122
|
+
if (!prefersColor) score++;
|
|
123
|
+
} catch {
|
|
124
|
+
score = 1;
|
|
125
|
+
}
|
|
126
|
+
return { name: 'media_query_anomaly', detected: score > 0, value: String(score), weight: 5 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkImageLoadTiming(): HeadlessSignal {
|
|
130
|
+
// Synchronous check: measure data URI image decode speed
|
|
131
|
+
const start = performance.now();
|
|
132
|
+
const img = new Image();
|
|
133
|
+
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
134
|
+
const elapsed = performance.now() - start;
|
|
135
|
+
const detected = elapsed < 0.001;
|
|
136
|
+
return { name: 'instant_image_decode', detected, value: `${elapsed.toFixed(4)}ms`, weight: 3 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkNativeFunctions(): HeadlessSignal {
|
|
140
|
+
const functionsToCheck = [
|
|
141
|
+
{ name: 'fetch', fn: window.fetch },
|
|
142
|
+
{ name: 'XMLHttpRequest.send', fn: XMLHttpRequest.prototype.send },
|
|
143
|
+
];
|
|
144
|
+
const patched: string[] = [];
|
|
145
|
+
for (const { name, fn } of functionsToCheck) {
|
|
146
|
+
if (fn) {
|
|
147
|
+
const str = Function.prototype.toString.call(fn);
|
|
148
|
+
if (!str.includes('[native code]') && !str.includes('{ [native code] }')) {
|
|
149
|
+
patched.push(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { name: 'native_fn_override', detected: patched.length > 0, value: patched.join(','), weight: 10 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function checkPrototypeChain(): HeadlessSignal {
|
|
157
|
+
try {
|
|
158
|
+
const navProto = Object.getPrototypeOf(navigator);
|
|
159
|
+
const expected = Navigator.prototype;
|
|
160
|
+
const detected = navProto !== expected;
|
|
161
|
+
return { name: 'prototype_tampered', detected, value: String(detected), weight: 8 };
|
|
162
|
+
} catch {
|
|
163
|
+
return { name: 'prototype_tampered', detected: true, value: 'error', weight: 8 };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkConnectionAPI(): HeadlessSignal {
|
|
168
|
+
const conn = (navigator as any).connection;
|
|
169
|
+
const detected = !conn && !/Safari|Firefox/.test(navigator.userAgent);
|
|
170
|
+
return { name: 'no_connection_api', detected, value: conn ? conn.effectiveType : 'missing', weight: 3 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function checkWebRTC(): Promise<HeadlessSignal> {
|
|
174
|
+
try {
|
|
175
|
+
const pc = new RTCPeerConnection({ iceServers: [] });
|
|
176
|
+
pc.createDataChannel('');
|
|
177
|
+
const offer = await pc.createOffer();
|
|
178
|
+
await pc.setLocalDescription(offer);
|
|
179
|
+
const sdp = pc.localDescription?.sdp || '';
|
|
180
|
+
pc.close();
|
|
181
|
+
const hasCandidate = sdp.includes('a=candidate');
|
|
182
|
+
return { name: 'webrtc_anomaly', detected: !hasCandidate, value: hasCandidate ? 'ok' : 'no_candidates', weight: 5 };
|
|
183
|
+
} catch {
|
|
184
|
+
return { name: 'webrtc_anomaly', detected: false, value: 'unsupported', weight: 0 };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkPerformanceAPI(): HeadlessSignal {
|
|
189
|
+
const t1 = performance.now();
|
|
190
|
+
const t2 = performance.now();
|
|
191
|
+
const precision = t2 - t1;
|
|
192
|
+
const detected = precision === 0 || precision > 5;
|
|
193
|
+
return { name: 'performance_precision', detected, value: `${precision.toFixed(4)}ms`, weight: 3 };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkIframeAccess(): HeadlessSignal {
|
|
197
|
+
try {
|
|
198
|
+
const iframe = document.createElement('iframe');
|
|
199
|
+
iframe.style.display = 'none';
|
|
200
|
+
document.body.appendChild(iframe);
|
|
201
|
+
const hasContent = !!iframe.contentWindow;
|
|
202
|
+
document.body.removeChild(iframe);
|
|
203
|
+
return { name: 'iframe_anomaly', detected: !hasContent, value: String(hasContent), weight: 3 };
|
|
204
|
+
} catch {
|
|
205
|
+
return { name: 'iframe_anomaly', detected: true, value: 'error', weight: 3 };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function checkConsoleLog(): HeadlessSignal {
|
|
210
|
+
const original = console.log.toString();
|
|
211
|
+
const isNative = original.includes('[native code]') || original.includes('{ [native code] }');
|
|
212
|
+
return { name: 'console_override', detected: !isNative, value: isNative ? 'native' : 'overridden', weight: 3 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function checkPhantomJS(): HeadlessSignal {
|
|
216
|
+
const w = window as any;
|
|
217
|
+
const detected = !!(w._phantom || w.callPhantom || w.__phantomas);
|
|
218
|
+
return { name: 'phantomjs', detected, value: String(detected), weight: 20 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkSeleniumArtifacts(): HeadlessSignal {
|
|
222
|
+
const w = window as any;
|
|
223
|
+
const d = document as any;
|
|
224
|
+
const artifacts = [
|
|
225
|
+
w._selenium, w._Selenium_IDE_Recorder, w.calledSelenium,
|
|
226
|
+
d.__webdriver_evaluate, d.__selenium_evaluate, d.__webdriver_script_function,
|
|
227
|
+
d.__webdriver_script_func, d.__webdriver_script_fn, d.__fxdriver_evaluate,
|
|
228
|
+
d.__driver_unwrapped, d.__webdriver_unwrapped, d.__driver_evaluate,
|
|
229
|
+
d.__selenium_unwrapped, d.__fxdriver_unwrapped,
|
|
230
|
+
];
|
|
231
|
+
const found = artifacts.filter(a => a !== undefined).length;
|
|
232
|
+
return { name: 'selenium_artifacts', detected: found > 0, value: String(found), weight: 25 };
|
|
233
|
+
}
|