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,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive Signal Collection — detect bots through background behavioral analysis.
|
|
3
|
+
* These signals are collected BEFORE the user interacts with any challenge.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sdkLogger } from './logger';
|
|
7
|
+
|
|
8
|
+
export interface DeviceMotionResult {
|
|
9
|
+
sampleCount: number;
|
|
10
|
+
avgAcceleration: number;
|
|
11
|
+
stdAcceleration: number;
|
|
12
|
+
avgRotationRate: number;
|
|
13
|
+
stdRotationRate: number;
|
|
14
|
+
hasGravity: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeviceOrientationResult {
|
|
18
|
+
sampleCount: number;
|
|
19
|
+
avgGammaDelta: number;
|
|
20
|
+
avgBetaDelta: number;
|
|
21
|
+
hasData: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PassiveSignalResult {
|
|
25
|
+
rafTiming: RafTimingResult;
|
|
26
|
+
hoverTrap: HoverTrapResult;
|
|
27
|
+
eventTiming: EventTimingResult;
|
|
28
|
+
scrollBehavior: ScrollBehaviorResult;
|
|
29
|
+
idlePattern: IdlePatternResult;
|
|
30
|
+
inputBehavior: InputBehaviorResult;
|
|
31
|
+
deviceMotion?: DeviceMotionResult;
|
|
32
|
+
deviceOrientation?: DeviceOrientationResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RafTimingResult {
|
|
36
|
+
avgInterval: number;
|
|
37
|
+
stdInterval: number;
|
|
38
|
+
minInterval: number;
|
|
39
|
+
maxInterval: number;
|
|
40
|
+
frameCount: number;
|
|
41
|
+
anomaly: boolean;
|
|
42
|
+
anomalyType: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface HoverTrapResult {
|
|
46
|
+
triggered: boolean;
|
|
47
|
+
hoverCount: number;
|
|
48
|
+
mouseOverHidden: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface EventTimingResult {
|
|
52
|
+
mousedownToMouseup: number[];
|
|
53
|
+
mousemoveToClick: number;
|
|
54
|
+
avgClickDuration: number;
|
|
55
|
+
hasNaturalVariance: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ScrollBehaviorResult {
|
|
59
|
+
scrollEvents: number;
|
|
60
|
+
hasInertia: boolean;
|
|
61
|
+
avgScrollDelta: number;
|
|
62
|
+
scrollPatternRegular: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface IdlePatternResult {
|
|
66
|
+
idlePeriods: number;
|
|
67
|
+
avgIdleDuration: number;
|
|
68
|
+
hasNaturalPauses: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface InputBehaviorResult {
|
|
72
|
+
keyPressIntervals: number[];
|
|
73
|
+
hasTypingRhythm: boolean;
|
|
74
|
+
avgKeyInterval: number;
|
|
75
|
+
keystrokeDynamics: KeystrokeDynamics;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface KeystrokeDynamics {
|
|
79
|
+
dwellTimes: number[]; // How long each key is held (keydown→keyup)
|
|
80
|
+
flightTimes: number[]; // Time between consecutive keyup→keydown
|
|
81
|
+
avgDwell: number;
|
|
82
|
+
avgFlight: number;
|
|
83
|
+
dwellVariance: number;
|
|
84
|
+
flightVariance: number;
|
|
85
|
+
digraphLatencies: Record<string, number[]>; // Timing for common key pairs
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class PassiveSignalCollector {
|
|
89
|
+
private rafTimings: number[] = [];
|
|
90
|
+
private lastRafTime = 0;
|
|
91
|
+
private rafHandle = 0;
|
|
92
|
+
private mouseEvents: Array<{ type: string; time: number; x: number; y: number }> = [];
|
|
93
|
+
private scrollEvents: Array<{ time: number; deltaY: number }> = [];
|
|
94
|
+
private keyEvents: Array<{ time: number; key: string; type: 'down' | 'up' }> = [];
|
|
95
|
+
private hoverTrapTriggered = false;
|
|
96
|
+
private hoverTrapElement: HTMLElement | null = null;
|
|
97
|
+
private idlePeriods: number[] = [];
|
|
98
|
+
private lastActivityTime = 0;
|
|
99
|
+
private collecting = false;
|
|
100
|
+
|
|
101
|
+
// Device sensor data
|
|
102
|
+
private motionSamples: Array<{ accel: number; rotation: number }> = [];
|
|
103
|
+
private motionHasGravity = false;
|
|
104
|
+
private orientationDeltas: Array<{ gammaDelta: number; betaDelta: number }> = [];
|
|
105
|
+
private lastGamma: number | null = null;
|
|
106
|
+
private lastBeta: number | null = null;
|
|
107
|
+
private isMobileDevice = false;
|
|
108
|
+
|
|
109
|
+
start(): void {
|
|
110
|
+
if (this.collecting) return;
|
|
111
|
+
this.collecting = true;
|
|
112
|
+
this.lastActivityTime = performance.now();
|
|
113
|
+
sdkLogger.info('PassiveSignals', 'Collector start');
|
|
114
|
+
|
|
115
|
+
// 1. Start rAF timing measurement
|
|
116
|
+
this.measureRaf();
|
|
117
|
+
|
|
118
|
+
// 2. Set up hover trap
|
|
119
|
+
this.setupHoverTrap();
|
|
120
|
+
|
|
121
|
+
// 3. Attach event listeners
|
|
122
|
+
this.attachListeners();
|
|
123
|
+
|
|
124
|
+
// 4. Start device sensor collection (mobile only)
|
|
125
|
+
this.startDeviceSensors();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stop(): void {
|
|
129
|
+
this.collecting = false;
|
|
130
|
+
cancelAnimationFrame(this.rafHandle);
|
|
131
|
+
this.removeHoverTrap();
|
|
132
|
+
this.detachListeners();
|
|
133
|
+
this.stopDeviceSensors();
|
|
134
|
+
sdkLogger.info('PassiveSignals', 'Collector stop');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getResults(): PassiveSignalResult {
|
|
138
|
+
const rafTiming = this.analyzeRafTiming();
|
|
139
|
+
const hoverTrap = this.analyzeHoverTrap();
|
|
140
|
+
const eventTiming = this.analyzeEventTiming();
|
|
141
|
+
const scrollBehavior = this.analyzeScrollBehavior();
|
|
142
|
+
const idlePattern = this.analyzeIdlePattern();
|
|
143
|
+
const inputBehavior = this.analyzeInputBehavior();
|
|
144
|
+
const deviceMotion = this.analyzeDeviceMotion();
|
|
145
|
+
const deviceOrientation = this.analyzeDeviceOrientation();
|
|
146
|
+
sdkLogger.debug('PassiveSignals', 'Results summary', {
|
|
147
|
+
rafFrames: rafTiming.frameCount,
|
|
148
|
+
rafAnomaly: rafTiming.anomaly,
|
|
149
|
+
hoverTriggered: hoverTrap.triggered,
|
|
150
|
+
mouseEvents: this.mouseEvents.length,
|
|
151
|
+
scrollEvents: scrollBehavior.scrollEvents,
|
|
152
|
+
idlePeriods: idlePattern.idlePeriods,
|
|
153
|
+
motionSamples: deviceMotion?.sampleCount ?? 0,
|
|
154
|
+
orientationSamples: deviceOrientation?.sampleCount ?? 0,
|
|
155
|
+
});
|
|
156
|
+
const result: PassiveSignalResult = {
|
|
157
|
+
rafTiming,
|
|
158
|
+
hoverTrap,
|
|
159
|
+
eventTiming,
|
|
160
|
+
scrollBehavior,
|
|
161
|
+
idlePattern,
|
|
162
|
+
inputBehavior,
|
|
163
|
+
};
|
|
164
|
+
if (deviceMotion) result.deviceMotion = deviceMotion;
|
|
165
|
+
if (deviceOrientation) result.deviceOrientation = deviceOrientation;
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- rAF Timing ---
|
|
170
|
+
private measureRaf(): void {
|
|
171
|
+
const measure = (now: number) => {
|
|
172
|
+
if (!this.collecting) return;
|
|
173
|
+
if (this.lastRafTime > 0) {
|
|
174
|
+
this.rafTimings.push(now - this.lastRafTime);
|
|
175
|
+
}
|
|
176
|
+
this.lastRafTime = now;
|
|
177
|
+
// Collect for up to 120 frames (~2 seconds at 60fps)
|
|
178
|
+
if (this.rafTimings.length < 120) {
|
|
179
|
+
this.rafHandle = requestAnimationFrame(measure);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
this.rafHandle = requestAnimationFrame(measure);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private analyzeRafTiming(): RafTimingResult {
|
|
186
|
+
const t = this.rafTimings;
|
|
187
|
+
if (t.length < 10) {
|
|
188
|
+
return { avgInterval: 0, stdInterval: 0, minInterval: 0, maxInterval: 0, frameCount: 0, anomaly: false, anomalyType: 'insufficient_data' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const avg = t.reduce((a, b) => a + b, 0) / t.length;
|
|
192
|
+
const std = Math.sqrt(t.reduce((s, v) => s + (v - avg) ** 2, 0) / t.length);
|
|
193
|
+
const min = Math.min(...t);
|
|
194
|
+
const max = Math.max(...t);
|
|
195
|
+
|
|
196
|
+
let anomaly = false;
|
|
197
|
+
let anomalyType = 'none';
|
|
198
|
+
|
|
199
|
+
// Real browser at 60fps: avg ~16.67ms, std ~1-3ms
|
|
200
|
+
// Headless/virtual: avg might be exact 16.67 with std < 0.1
|
|
201
|
+
// Or completely irregular (batch rendering)
|
|
202
|
+
if (std < 0.1 && t.length > 30) {
|
|
203
|
+
anomaly = true;
|
|
204
|
+
anomalyType = 'perfect_timing'; // Too perfect = virtual display
|
|
205
|
+
} else if (avg < 5) {
|
|
206
|
+
anomaly = true;
|
|
207
|
+
anomalyType = 'too_fast'; // Faster than any real display
|
|
208
|
+
} else if (avg > 50 && t.length > 20) {
|
|
209
|
+
anomaly = true;
|
|
210
|
+
anomalyType = 'too_slow'; // Below 20fps consistently
|
|
211
|
+
} else if (std > avg * 0.5 && t.length > 30) {
|
|
212
|
+
anomaly = true;
|
|
213
|
+
anomalyType = 'highly_irregular'; // No real vsync
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { avgInterval: avg, stdInterval: std, minInterval: min, maxInterval: max, frameCount: t.length, anomaly, anomalyType };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- CSS Hover Trap ---
|
|
220
|
+
private setupHoverTrap(): void {
|
|
221
|
+
// Create invisible elements that only real mouse movement would trigger
|
|
222
|
+
const trap = document.createElement('div');
|
|
223
|
+
trap.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:-1;opacity:0;';
|
|
224
|
+
|
|
225
|
+
// Inner trap element with pointer-events auto
|
|
226
|
+
const inner = document.createElement('div');
|
|
227
|
+
inner.style.cssText = 'position:absolute;top:10%;left:10%;width:80%;height:80%;pointer-events:auto;';
|
|
228
|
+
|
|
229
|
+
inner.addEventListener('mouseover', () => {
|
|
230
|
+
this.hoverTrapTriggered = true;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
trap.appendChild(inner);
|
|
234
|
+
document.body.appendChild(trap);
|
|
235
|
+
this.hoverTrapElement = trap;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private removeHoverTrap(): void {
|
|
239
|
+
if (this.hoverTrapElement) {
|
|
240
|
+
document.body.removeChild(this.hoverTrapElement);
|
|
241
|
+
this.hoverTrapElement = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private analyzeHoverTrap(): HoverTrapResult {
|
|
246
|
+
const hoverEvents = this.mouseEvents.filter(e => e.type === 'mouseover');
|
|
247
|
+
return {
|
|
248
|
+
triggered: this.hoverTrapTriggered,
|
|
249
|
+
hoverCount: hoverEvents.length,
|
|
250
|
+
mouseOverHidden: this.hoverTrapTriggered,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- Event Listeners ---
|
|
255
|
+
private onMouseEvent = (e: MouseEvent) => {
|
|
256
|
+
if (!this.collecting) return;
|
|
257
|
+
const now = performance.now();
|
|
258
|
+
this.mouseEvents.push({ type: e.type, time: now, x: e.clientX, y: e.clientY });
|
|
259
|
+
this.recordActivity(now);
|
|
260
|
+
// Cap storage
|
|
261
|
+
if (this.mouseEvents.length > 500) this.mouseEvents.splice(0, 100);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
private onScrollEvent = (e: Event) => {
|
|
265
|
+
if (!this.collecting) return;
|
|
266
|
+
const we = e as WheelEvent;
|
|
267
|
+
const now = performance.now();
|
|
268
|
+
this.scrollEvents.push({ time: now, deltaY: we.deltaY || 0 });
|
|
269
|
+
this.recordActivity(now);
|
|
270
|
+
if (this.scrollEvents.length > 200) this.scrollEvents.splice(0, 50);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
private onKeyDown = (e: KeyboardEvent) => {
|
|
274
|
+
if (!this.collecting) return;
|
|
275
|
+
const now = performance.now();
|
|
276
|
+
const key = e.key.length === 1 ? 'char' : e.key;
|
|
277
|
+
this.keyEvents.push({ time: now, key, type: 'down' });
|
|
278
|
+
this.recordActivity(now);
|
|
279
|
+
if (this.keyEvents.length > 400) this.keyEvents.splice(0, 100);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
private onKeyUp = (e: KeyboardEvent) => {
|
|
283
|
+
if (!this.collecting) return;
|
|
284
|
+
const now = performance.now();
|
|
285
|
+
const key = e.key.length === 1 ? 'char' : e.key;
|
|
286
|
+
this.keyEvents.push({ time: now, key, type: 'up' });
|
|
287
|
+
this.recordActivity(now);
|
|
288
|
+
if (this.keyEvents.length > 400) this.keyEvents.splice(0, 100);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
private recordActivity(now: number): void {
|
|
292
|
+
const gap = now - this.lastActivityTime;
|
|
293
|
+
if (gap > 2000) {
|
|
294
|
+
this.idlePeriods.push(gap);
|
|
295
|
+
}
|
|
296
|
+
this.lastActivityTime = now;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private attachListeners(): void {
|
|
300
|
+
document.addEventListener('mousedown', this.onMouseEvent, { passive: true });
|
|
301
|
+
document.addEventListener('mouseup', this.onMouseEvent, { passive: true });
|
|
302
|
+
document.addEventListener('mousemove', this.onMouseEvent, { passive: true });
|
|
303
|
+
document.addEventListener('mouseover', this.onMouseEvent, { passive: true });
|
|
304
|
+
document.addEventListener('wheel', this.onScrollEvent, { passive: true });
|
|
305
|
+
document.addEventListener('keydown', this.onKeyDown, { passive: true });
|
|
306
|
+
document.addEventListener('keyup', this.onKeyUp, { passive: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private detachListeners(): void {
|
|
310
|
+
document.removeEventListener('mousedown', this.onMouseEvent);
|
|
311
|
+
document.removeEventListener('mouseup', this.onMouseEvent);
|
|
312
|
+
document.removeEventListener('mousemove', this.onMouseEvent);
|
|
313
|
+
document.removeEventListener('mouseover', this.onMouseEvent);
|
|
314
|
+
document.removeEventListener('wheel', this.onScrollEvent);
|
|
315
|
+
document.removeEventListener('keydown', this.onKeyDown);
|
|
316
|
+
document.removeEventListener('keyup', this.onKeyUp);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- Analysis Methods ---
|
|
320
|
+
private analyzeEventTiming(): EventTimingResult {
|
|
321
|
+
const downs = this.mouseEvents.filter(e => e.type === 'mousedown');
|
|
322
|
+
const ups = this.mouseEvents.filter(e => e.type === 'mouseup');
|
|
323
|
+
|
|
324
|
+
const clickDurations: number[] = [];
|
|
325
|
+
for (const down of downs) {
|
|
326
|
+
const up = ups.find(u => u.time > down.time && u.time - down.time < 1000);
|
|
327
|
+
if (up) clickDurations.push(up.time - down.time);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const moves = this.mouseEvents.filter(e => e.type === 'mousemove');
|
|
331
|
+
let moveThenClick = 0;
|
|
332
|
+
if (moves.length > 0 && downs.length > 0) {
|
|
333
|
+
const lastMove = moves[moves.length - 1];
|
|
334
|
+
const firstClick = downs[0];
|
|
335
|
+
moveThenClick = firstClick.time - lastMove.time;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const avgClick = clickDurations.length > 0
|
|
339
|
+
? clickDurations.reduce((a, b) => a + b, 0) / clickDurations.length
|
|
340
|
+
: 0;
|
|
341
|
+
|
|
342
|
+
const hasVariance = clickDurations.length >= 3 &&
|
|
343
|
+
(Math.max(...clickDurations) - Math.min(...clickDurations)) > 20;
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
mousedownToMouseup: clickDurations.slice(0, 10),
|
|
347
|
+
mousemoveToClick: moveThenClick,
|
|
348
|
+
avgClickDuration: avgClick,
|
|
349
|
+
hasNaturalVariance: hasVariance,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private analyzeScrollBehavior(): ScrollBehaviorResult {
|
|
354
|
+
const se = this.scrollEvents;
|
|
355
|
+
if (se.length < 3) {
|
|
356
|
+
return { scrollEvents: se.length, hasInertia: false, avgScrollDelta: 0, scrollPatternRegular: false };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const deltas = se.map(s => Math.abs(s.deltaY));
|
|
360
|
+
const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
|
361
|
+
|
|
362
|
+
// Check for inertia (decreasing deltas = natural scroll)
|
|
363
|
+
let hasInertia = false;
|
|
364
|
+
for (let i = 0; i < se.length - 3; i++) {
|
|
365
|
+
const d1 = Math.abs(se[i].deltaY);
|
|
366
|
+
const d2 = Math.abs(se[i + 1].deltaY);
|
|
367
|
+
const d3 = Math.abs(se[i + 2].deltaY);
|
|
368
|
+
if (d1 > d2 && d2 > d3 && d1 > 10) {
|
|
369
|
+
hasInertia = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Check regularity
|
|
375
|
+
const intervals = [];
|
|
376
|
+
for (let i = 1; i < se.length; i++) {
|
|
377
|
+
intervals.push(se[i].time - se[i - 1].time);
|
|
378
|
+
}
|
|
379
|
+
const ivAvg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
380
|
+
const ivStd = Math.sqrt(intervals.reduce((s, v) => s + (v - ivAvg) ** 2, 0) / intervals.length);
|
|
381
|
+
const regular = ivAvg > 0 && ivStd / ivAvg < 0.1;
|
|
382
|
+
|
|
383
|
+
return { scrollEvents: se.length, hasInertia, avgScrollDelta: avgDelta, scrollPatternRegular: regular };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private analyzeIdlePattern(): IdlePatternResult {
|
|
387
|
+
const ip = this.idlePeriods;
|
|
388
|
+
const avgIdle = ip.length > 0 ? ip.reduce((a, b) => a + b, 0) / ip.length : 0;
|
|
389
|
+
return {
|
|
390
|
+
idlePeriods: ip.length,
|
|
391
|
+
avgIdleDuration: avgIdle,
|
|
392
|
+
hasNaturalPauses: ip.length >= 1 && avgIdle > 500,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private analyzeInputBehavior(): InputBehaviorResult {
|
|
397
|
+
const ke = this.keyEvents;
|
|
398
|
+
const intervals: number[] = [];
|
|
399
|
+
const downEvents = ke.filter(e => e.type === 'down');
|
|
400
|
+
for (let i = 1; i < downEvents.length; i++) {
|
|
401
|
+
intervals.push(downEvents[i].time - downEvents[i - 1].time);
|
|
402
|
+
}
|
|
403
|
+
const avg = intervals.length > 0 ? intervals.reduce((a, b) => a + b, 0) / intervals.length : 0;
|
|
404
|
+
const hasRhythm = intervals.length >= 5 && avg > 50 && avg < 500;
|
|
405
|
+
|
|
406
|
+
// Keystroke dynamics: dwell times (key hold duration) and flight times
|
|
407
|
+
const dwellTimes: number[] = [];
|
|
408
|
+
const flightTimes: number[] = [];
|
|
409
|
+
const digraphLatencies: Record<string, number[]> = {};
|
|
410
|
+
|
|
411
|
+
// Match keydown→keyup pairs for dwell time
|
|
412
|
+
const pendingDown: Map<string, number> = new Map();
|
|
413
|
+
const upHistory: Array<{ time: number; key: string }> = [];
|
|
414
|
+
|
|
415
|
+
for (const event of ke) {
|
|
416
|
+
if (event.type === 'down') {
|
|
417
|
+
pendingDown.set(event.key, event.time);
|
|
418
|
+
// Flight time: previous keyup → current keydown
|
|
419
|
+
if (upHistory.length > 0) {
|
|
420
|
+
const lastUp = upHistory[upHistory.length - 1];
|
|
421
|
+
const flight = event.time - lastUp.time;
|
|
422
|
+
if (flight > 0 && flight < 2000) {
|
|
423
|
+
flightTimes.push(flight);
|
|
424
|
+
// Digraph: pair of consecutive keys
|
|
425
|
+
const digraph = `${lastUp.key}_${event.key}`;
|
|
426
|
+
if (!digraphLatencies[digraph]) digraphLatencies[digraph] = [];
|
|
427
|
+
digraphLatencies[digraph].push(flight);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else if (event.type === 'up') {
|
|
431
|
+
const downTime = pendingDown.get(event.key);
|
|
432
|
+
if (downTime !== undefined) {
|
|
433
|
+
const dwell = event.time - downTime;
|
|
434
|
+
if (dwell > 0 && dwell < 2000) {
|
|
435
|
+
dwellTimes.push(dwell);
|
|
436
|
+
}
|
|
437
|
+
pendingDown.delete(event.key);
|
|
438
|
+
}
|
|
439
|
+
upHistory.push({ time: event.time, key: event.key });
|
|
440
|
+
if (upHistory.length > 50) upHistory.splice(0, 25);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const mean = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
445
|
+
const variance = (arr: number[]) => {
|
|
446
|
+
if (arr.length < 2) return 0;
|
|
447
|
+
const m = mean(arr);
|
|
448
|
+
return arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
keyPressIntervals: intervals.slice(0, 20),
|
|
453
|
+
hasTypingRhythm: hasRhythm,
|
|
454
|
+
avgKeyInterval: avg,
|
|
455
|
+
keystrokeDynamics: {
|
|
456
|
+
dwellTimes: dwellTimes.slice(0, 30),
|
|
457
|
+
flightTimes: flightTimes.slice(0, 30),
|
|
458
|
+
avgDwell: mean(dwellTimes),
|
|
459
|
+
avgFlight: mean(flightTimes),
|
|
460
|
+
dwellVariance: variance(dwellTimes),
|
|
461
|
+
flightVariance: variance(flightTimes),
|
|
462
|
+
digraphLatencies,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- Device Sensor Collection ---
|
|
468
|
+
private onDeviceMotion = (e: DeviceMotionEvent) => {
|
|
469
|
+
if (!this.collecting) return;
|
|
470
|
+
const a = e.accelerationIncludingGravity;
|
|
471
|
+
const r = e.rotationRate;
|
|
472
|
+
if (a) {
|
|
473
|
+
const accelMag = Math.sqrt((a.x ?? 0) ** 2 + (a.y ?? 0) ** 2 + (a.z ?? 0) ** 2);
|
|
474
|
+
const rotMag = r ? Math.sqrt((r.alpha ?? 0) ** 2 + (r.beta ?? 0) ** 2 + (r.gamma ?? 0) ** 2) : 0;
|
|
475
|
+
this.motionSamples.push({ accel: accelMag, rotation: rotMag });
|
|
476
|
+
if (e.acceleration && (e.acceleration.x !== null || e.acceleration.y !== null)) {
|
|
477
|
+
this.motionHasGravity = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (this.motionSamples.length > 200) this.motionSamples.splice(0, 50);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
private onDeviceOrientation = (e: DeviceOrientationEvent) => {
|
|
484
|
+
if (!this.collecting) return;
|
|
485
|
+
const gamma = e.gamma ?? 0;
|
|
486
|
+
const beta = e.beta ?? 0;
|
|
487
|
+
if (this.lastGamma !== null && this.lastBeta !== null) {
|
|
488
|
+
this.orientationDeltas.push({
|
|
489
|
+
gammaDelta: Math.abs(gamma - this.lastGamma),
|
|
490
|
+
betaDelta: Math.abs(beta - this.lastBeta),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
this.lastGamma = gamma;
|
|
494
|
+
this.lastBeta = beta;
|
|
495
|
+
if (this.orientationDeltas.length > 200) this.orientationDeltas.splice(0, 50);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
private startDeviceSensors(): void {
|
|
499
|
+
this.isMobileDevice = navigator.maxTouchPoints > 0;
|
|
500
|
+
if (!this.isMobileDevice) return;
|
|
501
|
+
window.addEventListener('devicemotion', this.onDeviceMotion, { passive: true });
|
|
502
|
+
window.addEventListener('deviceorientation', this.onDeviceOrientation, { passive: true });
|
|
503
|
+
sdkLogger.debug('PassiveSignals', 'Device sensors started (mobile detected)');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private stopDeviceSensors(): void {
|
|
507
|
+
if (!this.isMobileDevice) return;
|
|
508
|
+
window.removeEventListener('devicemotion', this.onDeviceMotion);
|
|
509
|
+
window.removeEventListener('deviceorientation', this.onDeviceOrientation);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private analyzeDeviceMotion(): DeviceMotionResult | undefined {
|
|
513
|
+
if (!this.isMobileDevice) return undefined;
|
|
514
|
+
const samples = this.motionSamples;
|
|
515
|
+
const accels = samples.map(s => s.accel);
|
|
516
|
+
const rots = samples.map(s => s.rotation);
|
|
517
|
+
const mean = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
518
|
+
const std = (arr: number[]) => {
|
|
519
|
+
if (arr.length < 2) return 0;
|
|
520
|
+
const m = mean(arr);
|
|
521
|
+
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
sampleCount: samples.length,
|
|
525
|
+
avgAcceleration: mean(accels),
|
|
526
|
+
stdAcceleration: std(accels),
|
|
527
|
+
avgRotationRate: mean(rots),
|
|
528
|
+
stdRotationRate: std(rots),
|
|
529
|
+
hasGravity: this.motionHasGravity,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private analyzeDeviceOrientation(): DeviceOrientationResult | undefined {
|
|
534
|
+
if (!this.isMobileDevice) return undefined;
|
|
535
|
+
const deltas = this.orientationDeltas;
|
|
536
|
+
const mean = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
537
|
+
return {
|
|
538
|
+
sampleCount: deltas.length,
|
|
539
|
+
avgGammaDelta: mean(deltas.map(d => d.gammaDelta)),
|
|
540
|
+
avgBetaDelta: mean(deltas.map(d => d.betaDelta)),
|
|
541
|
+
hasData: deltas.length > 0,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
package/src/pow.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { PowResult } from './types';
|
|
2
|
+
import { sdkLogger } from './logger';
|
|
3
|
+
|
|
4
|
+
export async function computePoW(challenge: string, difficulty: number): Promise<PowResult> {
|
|
5
|
+
sdkLogger.info('PoW', 'Computation start', {
|
|
6
|
+
difficulty,
|
|
7
|
+
challengeLen: challenge?.length ?? 0,
|
|
8
|
+
useWorker: typeof Worker !== 'undefined',
|
|
9
|
+
});
|
|
10
|
+
const result =
|
|
11
|
+
typeof Worker !== 'undefined'
|
|
12
|
+
? await computeInWorker(challenge, difficulty)
|
|
13
|
+
: await computeInline(challenge, difficulty);
|
|
14
|
+
sdkLogger.info('PoW', 'Computation done', {
|
|
15
|
+
iterations: result.iterations,
|
|
16
|
+
timeMs: `${result.computationTimeMs.toFixed(1)}ms`,
|
|
17
|
+
difficulty: result.difficulty,
|
|
18
|
+
});
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function computeInWorker(challenge: string, difficulty: number): Promise<PowResult> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const workerCode = `
|
|
25
|
+
self.onmessage = async function(e) {
|
|
26
|
+
const { challenge, difficulty } = e.data;
|
|
27
|
+
const prefix = '0'.repeat(difficulty);
|
|
28
|
+
const start = performance.now();
|
|
29
|
+
let nonce = 0;
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
const data = encoder.encode(challenge + nonce.toString());
|
|
34
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
35
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
36
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
37
|
+
|
|
38
|
+
if (hashHex.startsWith(prefix)) {
|
|
39
|
+
self.postMessage({
|
|
40
|
+
nonce: nonce.toString(),
|
|
41
|
+
computationTimeMs: performance.now() - start,
|
|
42
|
+
iterations: nonce + 1,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
nonce++;
|
|
47
|
+
if (nonce > 10000000) {
|
|
48
|
+
self.postMessage({ error: 'timeout' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
|
56
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
57
|
+
const worker = new Worker(blobUrl);
|
|
58
|
+
|
|
59
|
+
const cleanup = () => { worker.terminate(); URL.revokeObjectURL(blobUrl); };
|
|
60
|
+
|
|
61
|
+
const timeout = setTimeout(() => {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(computeInline(challenge, difficulty));
|
|
64
|
+
}, 30000);
|
|
65
|
+
|
|
66
|
+
worker.onmessage = (e) => {
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
cleanup();
|
|
69
|
+
if (e.data.error) {
|
|
70
|
+
resolve(computeInline(challenge, difficulty));
|
|
71
|
+
} else {
|
|
72
|
+
resolve({
|
|
73
|
+
challenge,
|
|
74
|
+
nonce: e.data.nonce,
|
|
75
|
+
difficulty,
|
|
76
|
+
computationTimeMs: e.data.computationTimeMs,
|
|
77
|
+
iterations: e.data.iterations,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
worker.onerror = () => {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
cleanup();
|
|
85
|
+
resolve(computeInline(challenge, difficulty));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
worker.postMessage({ challenge, difficulty });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function computeInline(challenge: string, difficulty: number): Promise<PowResult> {
|
|
93
|
+
const prefix = '0'.repeat(difficulty);
|
|
94
|
+
const start = performance.now();
|
|
95
|
+
let nonce = 0;
|
|
96
|
+
const encoder = new TextEncoder();
|
|
97
|
+
|
|
98
|
+
while (nonce < 10000000) {
|
|
99
|
+
const data = encoder.encode(challenge + nonce.toString());
|
|
100
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
101
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
102
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
103
|
+
|
|
104
|
+
if (hashHex.startsWith(prefix)) {
|
|
105
|
+
return {
|
|
106
|
+
challenge,
|
|
107
|
+
nonce: nonce.toString(),
|
|
108
|
+
difficulty,
|
|
109
|
+
computationTimeMs: performance.now() - start,
|
|
110
|
+
iterations: nonce + 1,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
nonce++;
|
|
114
|
+
if (nonce % 10000 === 0) {
|
|
115
|
+
await new Promise(r => setTimeout(r, 0));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { challenge, nonce: '', difficulty, computationTimeMs: performance.now() - start, iterations: nonce };
|
|
120
|
+
}
|