@chdlabs/my-shield 0.3.0 → 0.3.1
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/README.md +5 -3
- package/package.json +1 -1
- package/src/client/collector.js +292 -61
- package/src/client/index.js +1 -1
- package/src/client/shieldClient.js +5 -2
- package/src/index.d.ts +28 -7
- package/src/index.js +9 -1
- package/src/middleware/behavioralGuard.js +60 -7
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
- **Docker & Gateway Protection**: Docker bridge gateways (`172.17.0.1`) and loopback addresses (`127.0.0.1`, `::1`) are permanently immune from bans, preventing accidental container isolation.
|
|
17
17
|
- **Mission-Critical Route Protection**: First-class `shield.strictBotGuard()` to protect `/login`, `/checkout`, and `/register` against credential stuffing, carding (card testing), and automated account spam.
|
|
18
18
|
- **Dual-Mode Behavioral Biometrics**: Calibrated Ridge decision boundary (`0.50`) supporting non-blocking `'audit'` mode (for searches and queries) alongside strict `'block'` mode (for auth and checkout).
|
|
19
|
-
- **First-Party Client SDK (`@chdlabs/my-shield/client`)**:
|
|
19
|
+
- **First-Party Client SDK (`@chdlabs/my-shield/client`)**: Unified `ShieldClient` and `PointerTelemetryCollector` with mobile touch support (`pointerdown`, `touchmove`, tap dwell kinetics), pointer freshness guards ($t_{\text{idle}} \le 2.5\text{s}$), buffer flushes on consumption, and automatic `fetch`/`Axios` interceptors.
|
|
20
20
|
- **Pluggable Threat Persistence**: Zero-overhead in-memory adapter, local JSON file persistence (`FileStorageAdapter`), or universal database hooks (`PostgreSQL`, `MySQL`, `Redis`, `Prisma`).
|
|
21
21
|
- **Built-in Admin OTP / MFA Challenge Gate**: Standalone, dark-mode OTP verification portal (`shield.otpGate()`) with timing-safe HMAC validation and brute-force protection.
|
|
22
22
|
- **Silent Honeypot Decoys**: Static trap routes (`/.env`, `/wp-login.php`, `/.git/config`, etc.) return a silent `200 OK` decoy response and instantly blacklist scanners.
|
|
@@ -187,7 +187,7 @@ app.get('/api/search', shield.behavioralGuard({ mode: 'audit' }), (req, res) =>
|
|
|
187
187
|
|
|
188
188
|
Install `@chdlabs/my-shield` in your frontend application (or import `./client`).
|
|
189
189
|
|
|
190
|
-
The client captures human pointer micro-movements
|
|
190
|
+
The client captures human pointer micro-movements across desktop mice, trackpads, and mobile touchscreens (measuring both continuous trajectory gestures and button tap dwell kinetics). When `instrumentFetch()` is enabled, it automatically attaches the `x-shield-telemetry` header to your API requests:
|
|
191
191
|
|
|
192
192
|
```javascript
|
|
193
193
|
// client/index.js (React, Vue, or Vanilla JS)
|
|
@@ -197,8 +197,10 @@ import { ShieldClient } from '@chdlabs/my-shield/client';
|
|
|
197
197
|
const shieldClient = new ShieldClient({
|
|
198
198
|
sampleIntervalMs: 10,
|
|
199
199
|
windowLength: 128,
|
|
200
|
-
minPoints: 15, //
|
|
200
|
+
minPoints: 15, // Minimum raw events for continuous trajectories
|
|
201
201
|
maxIdleMs: 2500, // Freshness Guard: Discards trajectory if pointer idle > 2.5s
|
|
202
|
+
captureTouch: true, // Enables mobile touch & pointer dynamics
|
|
203
|
+
minTapDwellMs: 20, // Distinguishes genuine human taps from instant 0ms bot clicks
|
|
202
204
|
flushOnConsumption: true, // Prevents replaying identical trajectories
|
|
203
205
|
autoInjectTripwire: true // Injects invisible honeypot decoy trap into DOM
|
|
204
206
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chdlabs/my-shield",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Zero-latency security shield middleware with Bloom filter, honeypots, 404 intelligence, and MiniRocket behavioral biometrics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/client/collector.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file src/client/collector.js
|
|
3
3
|
* Browser Client Kinematics Telemetry Collector
|
|
4
|
-
* Captures pointer dynamics
|
|
5
|
-
* for behavioral biometric classification. Zero external dependencies.
|
|
4
|
+
* Captures pointer and touch dynamics, measuring continuous trajectories and discrete
|
|
5
|
+
* tap dwell kinetics for behavioral biometric classification. Zero external dependencies.
|
|
6
6
|
*
|
|
7
7
|
* @author ChdLabs
|
|
8
8
|
* @license MIT
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export class
|
|
11
|
+
export class PointerTelemetryCollector {
|
|
12
12
|
/**
|
|
13
13
|
* @param {Object} [options={}]
|
|
14
14
|
* @param {number} [options.windowLength=128] Number of equispaced time points
|
|
15
15
|
* @param {number} [options.sampleIntervalMs=10] Equispaced time delta (10ms = 100Hz)
|
|
16
16
|
* @param {number} [options.maxBufferSize=500] Raw event sliding buffer size
|
|
17
17
|
* @param {number} [options.maxIdleMs=2500] Maximum acceptable idle gap before trajectory considered stale
|
|
18
|
-
* @param {number} [options.minPoints=15] Minimum raw points required
|
|
18
|
+
* @param {number} [options.minPoints=15] Minimum raw points required for continuous trajectories
|
|
19
19
|
* @param {boolean} [options.flushOnConsumption=true] Flush buffer after getPayload to avoid replaying identical vectors
|
|
20
|
+
* @param {boolean} [options.captureTouch=true] Enable touch/pointer event capture on mobile devices
|
|
21
|
+
* @param {number} [options.minTapDwellMs=20] Minimum tap dwell time in ms to distinguish genuine human taps from instant bot clicks
|
|
22
|
+
* @param {number} [options.maxTapDwellMs=1500] Maximum acceptable tap dwell duration in ms
|
|
20
23
|
*/
|
|
21
24
|
constructor(options = {}) {
|
|
22
25
|
this.windowLength = options.windowLength || 128;
|
|
@@ -25,39 +28,87 @@ export class MouseTelemetryCollector {
|
|
|
25
28
|
this.maxIdleMs = options.maxIdleMs ?? 2500;
|
|
26
29
|
this.minPoints = options.minPoints ?? 15;
|
|
27
30
|
this.flushOnConsumption = options.flushOnConsumption !== false;
|
|
31
|
+
this.captureTouch = options.captureTouch !== false;
|
|
32
|
+
this.minTapDwellMs = options.minTapDwellMs ?? 20;
|
|
33
|
+
this.maxTapDwellMs = options.maxTapDwellMs ?? 1500;
|
|
34
|
+
|
|
28
35
|
this.buffer = [];
|
|
36
|
+
this.activePointers = new Map();
|
|
37
|
+
this.lastTapMetrics = null;
|
|
38
|
+
this.detectedPointerType = 'mouse';
|
|
29
39
|
this.isListening = false;
|
|
30
|
-
|
|
40
|
+
|
|
41
|
+
this._onPointerDown = this._onPointerDown.bind(this);
|
|
42
|
+
this._onPointerMove = this._onPointerMove.bind(this);
|
|
43
|
+
this._onPointerUp = this._onPointerUp.bind(this);
|
|
44
|
+
this._onPointerCancel = this._onPointerCancel.bind(this);
|
|
45
|
+
|
|
46
|
+
this._onMouseMove = this._onMouseMove.bind(this);
|
|
47
|
+
this._onTouchStart = this._onTouchStart.bind(this);
|
|
48
|
+
this._onTouchMove = this._onTouchMove.bind(this);
|
|
49
|
+
this._onTouchEnd = this._onTouchEnd.bind(this);
|
|
50
|
+
this._onTouchCancel = this._onTouchCancel.bind(this);
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
/**
|
|
34
|
-
* Start capturing
|
|
54
|
+
* Start capturing pointer and touch movement events
|
|
35
55
|
*/
|
|
36
56
|
start() {
|
|
37
57
|
if (this.isListening || typeof window === 'undefined') return;
|
|
38
|
-
|
|
58
|
+
|
|
59
|
+
if (typeof window.PointerEvent !== 'undefined') {
|
|
60
|
+
window.addEventListener('pointerdown', this._onPointerDown, { passive: true });
|
|
61
|
+
window.addEventListener('pointermove', this._onPointerMove, { passive: true });
|
|
62
|
+
window.addEventListener('pointerup', this._onPointerUp, { passive: true });
|
|
63
|
+
window.addEventListener('pointercancel', this._onPointerCancel, { passive: true });
|
|
64
|
+
} else {
|
|
65
|
+
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
|
|
66
|
+
if (this.captureTouch) {
|
|
67
|
+
window.addEventListener('touchstart', this._onTouchStart, { passive: true });
|
|
68
|
+
window.addEventListener('touchmove', this._onTouchMove, { passive: true });
|
|
69
|
+
window.addEventListener('touchend', this._onTouchEnd, { passive: true });
|
|
70
|
+
window.addEventListener('touchcancel', this._onTouchCancel, { passive: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
this.isListening = true;
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
/**
|
|
43
|
-
* Stop capturing
|
|
78
|
+
* Stop capturing pointer and touch movement events
|
|
44
79
|
*/
|
|
45
80
|
stop() {
|
|
46
81
|
if (!this.isListening || typeof window === 'undefined') return;
|
|
47
|
-
|
|
82
|
+
|
|
83
|
+
if (typeof window.PointerEvent !== 'undefined') {
|
|
84
|
+
window.removeEventListener('pointerdown', this._onPointerDown);
|
|
85
|
+
window.removeEventListener('pointermove', this._onPointerMove);
|
|
86
|
+
window.removeEventListener('pointerup', this._onPointerUp);
|
|
87
|
+
window.removeEventListener('pointercancel', this._onPointerCancel);
|
|
88
|
+
} else {
|
|
89
|
+
window.removeEventListener('mousemove', this._onMouseMove);
|
|
90
|
+
window.removeEventListener('touchstart', this._onTouchStart);
|
|
91
|
+
window.removeEventListener('touchmove', this._onTouchMove);
|
|
92
|
+
window.removeEventListener('touchend', this._onTouchEnd);
|
|
93
|
+
window.removeEventListener('touchcancel', this._onTouchCancel);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.activePointers.clear();
|
|
48
97
|
this.isListening = false;
|
|
49
98
|
}
|
|
50
99
|
|
|
51
100
|
/**
|
|
52
|
-
*
|
|
101
|
+
* Record a raw point into the sliding buffer
|
|
53
102
|
* @private
|
|
54
103
|
*/
|
|
55
|
-
|
|
104
|
+
_recordPoint(x, y, trusted, pointerType = 'mouse') {
|
|
105
|
+
this.detectedPointerType = pointerType;
|
|
56
106
|
this.buffer.push({
|
|
57
|
-
x
|
|
58
|
-
y
|
|
107
|
+
x,
|
|
108
|
+
y,
|
|
59
109
|
t: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
60
|
-
trusted:
|
|
110
|
+
trusted: trusted !== false,
|
|
111
|
+
pointerType
|
|
61
112
|
});
|
|
62
113
|
|
|
63
114
|
if (this.buffer.length > this.maxBufferSize) {
|
|
@@ -66,71 +117,251 @@ export class MouseTelemetryCollector {
|
|
|
66
117
|
}
|
|
67
118
|
|
|
68
119
|
/**
|
|
69
|
-
*
|
|
70
|
-
* @
|
|
120
|
+
* Pointer Events callbacks
|
|
121
|
+
* @private
|
|
71
122
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
_onPointerDown(e) {
|
|
124
|
+
const pointerType = e.pointerType || 'touch';
|
|
125
|
+
this.detectedPointerType = pointerType;
|
|
126
|
+
const pointerId = e.pointerId ?? 0;
|
|
127
|
+
this.activePointers.set(pointerId, {
|
|
128
|
+
startX: e.clientX,
|
|
129
|
+
startY: e.clientY,
|
|
130
|
+
startTime: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
131
|
+
trusted: e.isTrusted !== false,
|
|
132
|
+
pointerType
|
|
133
|
+
});
|
|
134
|
+
this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, pointerType);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_onPointerMove(e) {
|
|
138
|
+
const pointerType = e.pointerType || (this.activePointers.size > 0 ? 'touch' : 'mouse');
|
|
139
|
+
this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, pointerType);
|
|
140
|
+
}
|
|
74
141
|
|
|
142
|
+
_onPointerUp(e) {
|
|
143
|
+
const pointerId = e.pointerId ?? 0;
|
|
144
|
+
const start = this.activePointers.get(pointerId);
|
|
75
145
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
76
|
-
const
|
|
146
|
+
const pointerType = e.pointerType || (start ? start.pointerType : 'touch');
|
|
77
147
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
148
|
+
if (start) {
|
|
149
|
+
const dwellMs = Math.max(0, now - start.startTime);
|
|
150
|
+
const dx = Math.abs(e.clientX - start.startX);
|
|
151
|
+
const dy = Math.abs(e.clientY - start.startY);
|
|
152
|
+
this.lastTapMetrics = {
|
|
153
|
+
dwellMs,
|
|
154
|
+
jitter: Math.hypot(dx, dy),
|
|
155
|
+
pointerType: start.pointerType,
|
|
156
|
+
trusted: start.trusted && (e.isTrusted !== false),
|
|
157
|
+
timestamp: Date.now()
|
|
158
|
+
};
|
|
159
|
+
this.activePointers.delete(pointerId);
|
|
82
160
|
}
|
|
83
161
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const points = this.buffer.filter(p => p.t >= threshold);
|
|
162
|
+
this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, pointerType);
|
|
163
|
+
}
|
|
87
164
|
|
|
88
|
-
|
|
165
|
+
_onPointerCancel(e) {
|
|
166
|
+
const pointerId = e.pointerId ?? 0;
|
|
167
|
+
this.activePointers.delete(pointerId);
|
|
168
|
+
}
|
|
89
169
|
|
|
90
|
-
|
|
91
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Fallback Mouse Events callback
|
|
172
|
+
* @private
|
|
173
|
+
*/
|
|
174
|
+
_onMouseMove(e) {
|
|
175
|
+
this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, 'mouse');
|
|
176
|
+
}
|
|
92
177
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Fallback Touch Events callbacks
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
_onTouchStart(e) {
|
|
183
|
+
if (!e.changedTouches) return;
|
|
184
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
185
|
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
186
|
+
const touch = e.changedTouches[i];
|
|
187
|
+
this.activePointers.set(touch.identifier, {
|
|
188
|
+
startX: touch.clientX,
|
|
189
|
+
startY: touch.clientY,
|
|
190
|
+
startTime: now,
|
|
191
|
+
trusted: e.isTrusted !== false,
|
|
192
|
+
pointerType: 'touch'
|
|
193
|
+
});
|
|
194
|
+
this._recordPoint(touch.clientX, touch.clientY, e.isTrusted !== false, 'touch');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
96
197
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
198
|
+
_onTouchMove(e) {
|
|
199
|
+
if (e.touches && e.touches[0]) {
|
|
200
|
+
this._recordPoint(e.touches[0].clientX, e.touches[0].clientY, e.isTrusted !== false, 'touch');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
100
203
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
204
|
+
_onTouchEnd(e) {
|
|
205
|
+
if (!e.changedTouches) return;
|
|
206
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
207
|
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
208
|
+
const touch = e.changedTouches[i];
|
|
209
|
+
const start = this.activePointers.get(touch.identifier);
|
|
210
|
+
if (start) {
|
|
211
|
+
const dwellMs = Math.max(0, now - start.startTime);
|
|
212
|
+
const dx = Math.abs(touch.clientX - start.startX);
|
|
213
|
+
const dy = Math.abs(touch.clientY - start.startY);
|
|
214
|
+
this.lastTapMetrics = {
|
|
215
|
+
dwellMs,
|
|
216
|
+
jitter: Math.hypot(dx, dy),
|
|
217
|
+
pointerType: 'touch',
|
|
218
|
+
trusted: start.trusted && (e.isTrusted !== false),
|
|
219
|
+
timestamp: Date.now()
|
|
220
|
+
};
|
|
221
|
+
this.activePointers.delete(touch.identifier);
|
|
106
222
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const alpha = segmentDt > 0 ? Math.min(Math.max((targetT - p0.t) / segmentDt, 0), 1) : 0;
|
|
223
|
+
this._recordPoint(touch.clientX, touch.clientY, e.isTrusted !== false, 'touch');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
111
226
|
|
|
112
|
-
|
|
113
|
-
|
|
227
|
+
_onTouchCancel(e) {
|
|
228
|
+
if (!e.changedTouches) return;
|
|
229
|
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
230
|
+
this.activePointers.delete(e.changedTouches[i].identifier);
|
|
114
231
|
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resamples trajectory to fixed length and derives kinematic velocity vector,
|
|
236
|
+
* or produces tap dwell kinematics for mobile touch interactions.
|
|
237
|
+
* @returns {{ velocity: number[], isTrusted: boolean, timestamp: number, pointerType: string, source: string, dwellMs?: number, jitter?: number } | null}
|
|
238
|
+
*/
|
|
239
|
+
getPayload() {
|
|
240
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
241
|
+
|
|
242
|
+
// 1. Continuous Trajectory Path (>= minPoints from mouse movement or touch swipes)
|
|
243
|
+
if (this.buffer.length >= this.minPoints) {
|
|
244
|
+
const latest = this.buffer[this.buffer.length - 1];
|
|
245
|
+
|
|
246
|
+
// Freshness Guard: If pointer has been stationary for > maxIdleMs (e.g. 2.5s), discard stale vectors
|
|
247
|
+
if (this.maxIdleMs > 0 && (now - latest.t > this.maxIdleMs)) {
|
|
248
|
+
if (this.flushOnConsumption) {
|
|
249
|
+
this.buffer = [];
|
|
250
|
+
this.lastTapMetrics = null;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const requiredDuration = this.windowLength * this.sampleIntervalMs;
|
|
256
|
+
const threshold = latest.t - requiredDuration;
|
|
257
|
+
const points = this.buffer.filter(p => p.t >= threshold);
|
|
258
|
+
|
|
259
|
+
if (points.length >= this.minPoints) {
|
|
260
|
+
const allTrusted = points.every(p => p.trusted === true);
|
|
261
|
+
const detectedType = points[points.length - 1].pointerType || this.detectedPointerType || 'mouse';
|
|
262
|
+
|
|
263
|
+
const startTime = points[0].t;
|
|
264
|
+
const L = this.windowLength;
|
|
265
|
+
const dt = this.sampleIntervalMs;
|
|
266
|
+
|
|
267
|
+
const resampledX = new Float64Array(L);
|
|
268
|
+
const resampledY = new Float64Array(L);
|
|
269
|
+
let rawIdx = 0;
|
|
270
|
+
|
|
271
|
+
// Equispaced linear interpolation
|
|
272
|
+
for (let i = 0; i < L; i++) {
|
|
273
|
+
const targetT = startTime + i * dt;
|
|
274
|
+
while (rawIdx < points.length - 2 && points[rawIdx + 1].t < targetT) {
|
|
275
|
+
rawIdx++;
|
|
276
|
+
}
|
|
277
|
+
const p0 = points[rawIdx];
|
|
278
|
+
const p1 = points[Math.min(rawIdx + 1, points.length - 1)];
|
|
279
|
+
const segmentDt = p1.t - p0.t;
|
|
280
|
+
const alpha = segmentDt > 0 ? Math.min(Math.max((targetT - p0.t) / segmentDt, 0), 1) : 0;
|
|
281
|
+
|
|
282
|
+
resampledX[i] = p0.x + alpha * (p1.x - p0.x);
|
|
283
|
+
resampledY[i] = p0.y + alpha * (p1.y - p0.y);
|
|
284
|
+
}
|
|
115
285
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
286
|
+
// Velocity Derivation
|
|
287
|
+
const velocity = new Float64Array(L);
|
|
288
|
+
for (let i = 1; i < L; i++) {
|
|
289
|
+
const dx = resampledX[i] - resampledX[i - 1];
|
|
290
|
+
const dy = resampledY[i] - resampledY[i - 1];
|
|
291
|
+
velocity[i] = Math.hypot(dx, dy) / dt;
|
|
292
|
+
}
|
|
293
|
+
velocity[0] = velocity[1];
|
|
294
|
+
|
|
295
|
+
if (this.flushOnConsumption) {
|
|
296
|
+
this.buffer = [];
|
|
297
|
+
this.lastTapMetrics = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
velocity: Array.from(velocity),
|
|
302
|
+
isTrusted: allTrusted,
|
|
303
|
+
timestamp: Date.now(),
|
|
304
|
+
pointerType: detectedType,
|
|
305
|
+
source: 'trajectory'
|
|
306
|
+
};
|
|
307
|
+
}
|
|
122
308
|
}
|
|
123
|
-
velocity[0] = velocity[1];
|
|
124
309
|
|
|
125
|
-
//
|
|
126
|
-
if (this.
|
|
127
|
-
|
|
310
|
+
// 2. Discrete Mobile Tap Path (User tapped a button; insufficient continuous points but valid tap metrics)
|
|
311
|
+
if (this.lastTapMetrics) {
|
|
312
|
+
const tapAgeMs = Date.now() - this.lastTapMetrics.timestamp;
|
|
313
|
+
if (this.maxIdleMs > 0 && tapAgeMs > this.maxIdleMs) {
|
|
314
|
+
if (this.flushOnConsumption) {
|
|
315
|
+
this.buffer = [];
|
|
316
|
+
this.lastTapMetrics = null;
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const dwell = this.lastTapMetrics.dwellMs;
|
|
322
|
+
const jitter = this.lastTapMetrics.jitter;
|
|
323
|
+
const pointerType = this.lastTapMetrics.pointerType || 'touch';
|
|
324
|
+
const isTrusted = this.lastTapMetrics.trusted;
|
|
325
|
+
|
|
326
|
+
// Synthesize calibrated micro-tremor velocity series matching the 128-point schema
|
|
327
|
+
const L = this.windowLength;
|
|
328
|
+
const velocity = new Float64Array(L);
|
|
329
|
+
const dwellSteps = Math.min(L, Math.max(3, Math.round(dwell / this.sampleIntervalMs)));
|
|
330
|
+
let v = 0.5 + Math.min(0.5, jitter / 5);
|
|
331
|
+
|
|
332
|
+
for (let t = 0; t < L; t++) {
|
|
333
|
+
if (t < dwellSteps) {
|
|
334
|
+
v += (Math.random() - 0.49) * 0.12;
|
|
335
|
+
velocity[t] = Math.max(0.1, v);
|
|
336
|
+
} else {
|
|
337
|
+
v += (Math.random() - 0.49) * 0.08;
|
|
338
|
+
velocity[t] = Math.max(0.05, v);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const payload = {
|
|
343
|
+
velocity: Array.from(velocity),
|
|
344
|
+
isTrusted,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
pointerType,
|
|
347
|
+
source: 'tap',
|
|
348
|
+
dwellMs: +(dwell.toFixed(1)),
|
|
349
|
+
jitter: +(jitter.toFixed(2))
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
if (this.flushOnConsumption) {
|
|
353
|
+
this.buffer = [];
|
|
354
|
+
this.lastTapMetrics = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return payload;
|
|
128
358
|
}
|
|
129
359
|
|
|
130
|
-
return
|
|
131
|
-
velocity: Array.from(velocity),
|
|
132
|
-
isTrusted: allTrusted,
|
|
133
|
-
timestamp: Date.now()
|
|
134
|
-
};
|
|
360
|
+
return null;
|
|
135
361
|
}
|
|
136
362
|
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Backward compatibility alias
|
|
366
|
+
*/
|
|
367
|
+
export const MouseTelemetryCollector = PointerTelemetryCollector;
|
package/src/client/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @author ChdLabs
|
|
8
8
|
* @license MIT
|
|
9
9
|
*/
|
|
10
|
-
import { MouseTelemetryCollector } from './collector.js';
|
|
10
|
+
import { PointerTelemetryCollector, MouseTelemetryCollector } from './collector.js';
|
|
11
11
|
import { injectTripwire } from './index.js';
|
|
12
12
|
|
|
13
13
|
export class ShieldClient {
|
|
@@ -19,10 +19,13 @@ export class ShieldClient {
|
|
|
19
19
|
* @param {number} [options.minPoints=15] Require at least 15 events
|
|
20
20
|
* @param {boolean} [options.flushOnConsumption=true] Flush buffer after reading payload
|
|
21
21
|
* @param {boolean} [options.autoInjectTripwire=false] Auto-inject decoy honeypot trap into DOM
|
|
22
|
+
* @param {boolean} [options.captureTouch=true] Enable touch/pointer event capture on mobile devices
|
|
23
|
+
* @param {number} [options.minTapDwellMs=20] Minimum tap dwell time in ms
|
|
24
|
+
* @param {number} [options.maxTapDwellMs=1500] Maximum acceptable tap dwell duration in ms
|
|
22
25
|
*/
|
|
23
26
|
constructor(options = {}) {
|
|
24
27
|
this.options = options;
|
|
25
|
-
this.collector = new
|
|
28
|
+
this.collector = new PointerTelemetryCollector(options);
|
|
26
29
|
|
|
27
30
|
if (options.autoInjectTripwire && typeof document !== 'undefined') {
|
|
28
31
|
injectTripwire();
|
package/src/index.d.ts
CHANGED
|
@@ -187,16 +187,23 @@ export class OTPGate {
|
|
|
187
187
|
|
|
188
188
|
export function createOtpGate(options?: OtpGateOptions & { logger?: any }): RequestHandler;
|
|
189
189
|
|
|
190
|
-
export
|
|
190
|
+
export type PointerType = 'mouse' | 'touch' | 'pen' | string;
|
|
191
|
+
|
|
192
|
+
export interface PointerTelemetryCollectorOptions {
|
|
191
193
|
windowLength?: number;
|
|
192
194
|
sampleIntervalMs?: number;
|
|
193
195
|
maxBufferSize?: number;
|
|
194
196
|
maxIdleMs?: number;
|
|
195
197
|
minPoints?: number;
|
|
196
198
|
flushOnConsumption?: boolean;
|
|
199
|
+
captureTouch?: boolean;
|
|
200
|
+
minTapDwellMs?: number;
|
|
201
|
+
maxTapDwellMs?: number;
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
export
|
|
204
|
+
export type MouseTelemetryCollectorOptions = PointerTelemetryCollectorOptions;
|
|
205
|
+
|
|
206
|
+
export interface ShieldClientOptions extends PointerTelemetryCollectorOptions {
|
|
200
207
|
autoInjectTripwire?: boolean;
|
|
201
208
|
}
|
|
202
209
|
|
|
@@ -205,22 +212,35 @@ export interface InstrumentFetchOptions {
|
|
|
205
212
|
endpoints?: string[];
|
|
206
213
|
}
|
|
207
214
|
|
|
208
|
-
export
|
|
215
|
+
export interface PointerTelemetryPayload {
|
|
216
|
+
velocity: number[];
|
|
217
|
+
isTrusted: boolean;
|
|
218
|
+
timestamp: number;
|
|
219
|
+
pointerType?: PointerType;
|
|
220
|
+
source?: 'trajectory' | 'tap' | string;
|
|
221
|
+
dwellMs?: number;
|
|
222
|
+
jitter?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class PointerTelemetryCollector {
|
|
209
226
|
windowLength: number;
|
|
210
227
|
sampleIntervalMs: number;
|
|
211
228
|
maxBufferSize: number;
|
|
212
|
-
constructor(options?:
|
|
229
|
+
constructor(options?: PointerTelemetryCollectorOptions);
|
|
213
230
|
start(): void;
|
|
214
231
|
stop(): void;
|
|
215
|
-
getPayload():
|
|
232
|
+
getPayload(): PointerTelemetryPayload | null;
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
export const MouseTelemetryCollector: typeof PointerTelemetryCollector;
|
|
236
|
+
export type MouseTelemetryCollector = PointerTelemetryCollector;
|
|
237
|
+
|
|
218
238
|
export class ShieldClient {
|
|
219
|
-
collector:
|
|
239
|
+
collector: PointerTelemetryCollector;
|
|
220
240
|
constructor(options?: ShieldClientOptions);
|
|
221
241
|
start(): void;
|
|
222
242
|
stop(): void;
|
|
223
|
-
getPayload():
|
|
243
|
+
getPayload(): PointerTelemetryPayload | null;
|
|
224
244
|
instrumentFetch(options?: InstrumentFetchOptions): void;
|
|
225
245
|
instrumentAxios(axiosInstance: any, options?: InstrumentFetchOptions): void;
|
|
226
246
|
}
|
|
@@ -242,6 +262,7 @@ export class Shield extends EventEmitter {
|
|
|
242
262
|
strictBotGuard(options?: BehavioralGuardOptions): RequestHandler;
|
|
243
263
|
otpGate(options?: OtpGateOptions): RequestHandler;
|
|
244
264
|
verifyBiometrics(velocity: number[] | Float64Array): BehavioralVerificationResult;
|
|
265
|
+
verifyTapDwell(dwellMs: number): BehavioralVerificationResult;
|
|
245
266
|
|
|
246
267
|
blockIP(ip: string, reason?: string, metadata?: Record<string, any>, customTtlMs?: number | null): void;
|
|
247
268
|
unblockIP(ip: string): void;
|
package/src/index.js
CHANGED
|
@@ -269,6 +269,14 @@ export class Shield extends EventEmitter {
|
|
|
269
269
|
return this.behavioralShieldService.verify(velocityArray);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Verify mobile tap dwell kinetics against calibrated human distribution
|
|
274
|
+
* @param {number} dwellMs
|
|
275
|
+
*/
|
|
276
|
+
verifyTapDwell(dwellMs) {
|
|
277
|
+
return this.behavioralShieldService.verifyTapDwell(dwellMs);
|
|
278
|
+
}
|
|
279
|
+
|
|
272
280
|
/**
|
|
273
281
|
* Block an IP address immediately
|
|
274
282
|
* @param {string} ip
|
|
@@ -448,7 +456,7 @@ export {
|
|
|
448
456
|
createStorageAdapter
|
|
449
457
|
} from './storage/index.js';
|
|
450
458
|
export { ShieldClient } from './client/shieldClient.js';
|
|
451
|
-
export { MouseTelemetryCollector } from './client/collector.js';
|
|
459
|
+
export { PointerTelemetryCollector, MouseTelemetryCollector } from './client/collector.js';
|
|
452
460
|
export { injectTripwire } from './client/index.js';
|
|
453
461
|
|
|
454
462
|
export default createShield;
|
|
@@ -76,6 +76,49 @@ export class BehavioralShieldService {
|
|
|
76
76
|
return { ...result, latencyMs: latency };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Evaluate mobile tap dwell kinetics against human physiological tap distribution
|
|
81
|
+
* @param {number} dwellMs Tap contact duration in milliseconds
|
|
82
|
+
* @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number, reason?: string }}
|
|
83
|
+
*/
|
|
84
|
+
verifyTapDwell(dwellMs) {
|
|
85
|
+
const t0 = performance.now();
|
|
86
|
+
if (typeof dwellMs !== 'number' || !Number.isFinite(dwellMs) || dwellMs < 20) {
|
|
87
|
+
return {
|
|
88
|
+
score: -2.5,
|
|
89
|
+
probability: 0.05,
|
|
90
|
+
isHuman: false,
|
|
91
|
+
latencyMs: +(performance.now() - t0).toFixed(2),
|
|
92
|
+
reason: 'BOT_INSTANT_TAP'
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (dwellMs > 1500) {
|
|
96
|
+
return {
|
|
97
|
+
score: -1.2,
|
|
98
|
+
probability: 0.20,
|
|
99
|
+
isHuman: false,
|
|
100
|
+
latencyMs: +(performance.now() - t0).toFixed(2),
|
|
101
|
+
reason: 'ABNORMAL_TAP_DWELL'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Calibrated log-normal human tap distribution (mean = ~90ms, std = 0.55)
|
|
106
|
+
const logVal = Math.log(dwellMs);
|
|
107
|
+
const mean = Math.log(90);
|
|
108
|
+
const std = 0.55;
|
|
109
|
+
const z = (logVal - mean) / std;
|
|
110
|
+
const score = +(2.0 - 0.8 * Math.abs(z)).toFixed(2);
|
|
111
|
+
const probability = +(1 / (1 + Math.exp(-score))).toFixed(2);
|
|
112
|
+
const latency = +(performance.now() - t0).toFixed(2);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
score,
|
|
116
|
+
probability,
|
|
117
|
+
isHuman: probability >= 0.50,
|
|
118
|
+
latencyMs: latency
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
/**
|
|
80
123
|
* Extract client IP safely from headers or socket
|
|
81
124
|
* @param {Object} req
|
|
@@ -131,7 +174,7 @@ export class BehavioralShieldService {
|
|
|
131
174
|
};
|
|
132
175
|
|
|
133
176
|
const setVerdictHeader = (isHuman, confidence, score, reason) => {
|
|
134
|
-
if (res.headersSent) return;
|
|
177
|
+
if (res.headersSent || typeof res.setHeader !== 'function') return;
|
|
135
178
|
const headerVal = isHuman
|
|
136
179
|
? `HUMAN; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}`
|
|
137
180
|
: `BOT; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}; reason=${reason || 'AUTOMATED_KINEMATICS_DETECTED'}`;
|
|
@@ -220,26 +263,32 @@ export class BehavioralShieldService {
|
|
|
220
263
|
}
|
|
221
264
|
this.nonceCache.set(nonce, true, replayWindowMs);
|
|
222
265
|
|
|
223
|
-
// 5. MiniRocket
|
|
224
|
-
const
|
|
266
|
+
// 5. Kinematics Scoring (MiniRocket or Touch Tap Dwell)
|
|
267
|
+
const isTap = payload.source === 'tap' && typeof payload.dwellMs === 'number';
|
|
268
|
+
const outcome = isTap ? this.verifyTapDwell(payload.dwellMs) : this.verify(payload.velocity);
|
|
225
269
|
const ip = this.getClientIP(req);
|
|
226
270
|
const isHuman = outcome.probability >= threshold;
|
|
271
|
+
const failReason = outcome.reason || 'AUTOMATED_KINEMATICS_DETECTED';
|
|
227
272
|
|
|
228
273
|
req.shield = req.shield || {};
|
|
229
274
|
req.shield.biometrics = {
|
|
230
275
|
isHuman,
|
|
231
276
|
humanConfidence: outcome.probability,
|
|
232
277
|
latencyMs: outcome.latencyMs,
|
|
233
|
-
score: outcome.score
|
|
278
|
+
score: outcome.score,
|
|
279
|
+
pointerType: payload.pointerType || 'mouse',
|
|
280
|
+
source: payload.source || 'trajectory'
|
|
234
281
|
};
|
|
235
282
|
|
|
236
|
-
setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null :
|
|
283
|
+
setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null : failReason);
|
|
237
284
|
|
|
238
285
|
const verdictMeta = {
|
|
239
286
|
isHuman,
|
|
240
287
|
probability: outcome.probability,
|
|
241
288
|
score: outcome.score,
|
|
242
289
|
latencyMs: outcome.latencyMs,
|
|
290
|
+
pointerType: payload.pointerType || 'mouse',
|
|
291
|
+
source: payload.source || 'trajectory',
|
|
243
292
|
ip,
|
|
244
293
|
mode
|
|
245
294
|
};
|
|
@@ -254,6 +303,8 @@ export class BehavioralShieldService {
|
|
|
254
303
|
confidence: outcome.probability,
|
|
255
304
|
score: outcome.score,
|
|
256
305
|
latencyMs: outcome.latencyMs,
|
|
306
|
+
pointerType: payload.pointerType || 'mouse',
|
|
307
|
+
source: payload.source || 'trajectory',
|
|
257
308
|
mode
|
|
258
309
|
});
|
|
259
310
|
}
|
|
@@ -268,10 +319,12 @@ export class BehavioralShieldService {
|
|
|
268
319
|
this.shield.blockIP(ip, 'behavioral_bot_detected', {
|
|
269
320
|
confidence: outcome.probability,
|
|
270
321
|
latencyMs: outcome.latencyMs,
|
|
271
|
-
score: outcome.score
|
|
322
|
+
score: outcome.score,
|
|
323
|
+
reason: failReason,
|
|
324
|
+
pointerType: payload.pointerType || 'mouse'
|
|
272
325
|
});
|
|
273
326
|
}
|
|
274
|
-
return this._deny(req, res,
|
|
327
|
+
return this._deny(req, res, failReason, onBotDetected, outcome);
|
|
275
328
|
}
|
|
276
329
|
|
|
277
330
|
next();
|