@chdlabs/my-shield 0.2.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.
@@ -1,57 +1,114 @@
1
1
  /**
2
2
  * @file src/client/collector.js
3
3
  * Browser Client Kinematics Telemetry Collector
4
- * Captures pointer dynamics and normalizes trajectories to equispaced kinematic series
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 MouseTelemetryCollector {
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
+ * @param {number} [options.maxIdleMs=2500] Maximum acceptable idle gap before trajectory considered stale
18
+ * @param {number} [options.minPoints=15] Minimum raw points required for continuous trajectories
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
17
23
  */
18
24
  constructor(options = {}) {
19
25
  this.windowLength = options.windowLength || 128;
20
26
  this.sampleIntervalMs = options.sampleIntervalMs || 10;
21
27
  this.maxBufferSize = options.maxBufferSize || 500;
28
+ this.maxIdleMs = options.maxIdleMs ?? 2500;
29
+ this.minPoints = options.minPoints ?? 15;
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
+
22
35
  this.buffer = [];
36
+ this.activePointers = new Map();
37
+ this.lastTapMetrics = null;
38
+ this.detectedPointerType = 'mouse';
23
39
  this.isListening = false;
24
- this._onMove = this._onMove.bind(this);
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);
25
51
  }
26
52
 
27
53
  /**
28
- * Start capturing mouse movement events
54
+ * Start capturing pointer and touch movement events
29
55
  */
30
56
  start() {
31
57
  if (this.isListening || typeof window === 'undefined') return;
32
- window.addEventListener('mousemove', this._onMove, { passive: true });
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
+
33
74
  this.isListening = true;
34
75
  }
35
76
 
36
77
  /**
37
- * Stop capturing mouse movement events
78
+ * Stop capturing pointer and touch movement events
38
79
  */
39
80
  stop() {
40
81
  if (!this.isListening || typeof window === 'undefined') return;
41
- window.removeEventListener('mousemove', this._onMove);
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();
42
97
  this.isListening = false;
43
98
  }
44
99
 
45
100
  /**
46
- * Event listener callback
101
+ * Record a raw point into the sliding buffer
47
102
  * @private
48
103
  */
49
- _onMove(e) {
104
+ _recordPoint(x, y, trusted, pointerType = 'mouse') {
105
+ this.detectedPointerType = pointerType;
50
106
  this.buffer.push({
51
- x: e.clientX,
52
- y: e.clientY,
107
+ x,
108
+ y,
53
109
  t: typeof performance !== 'undefined' ? performance.now() : Date.now(),
54
- trusted: e.isTrusted !== false
110
+ trusted: trusted !== false,
111
+ pointerType
55
112
  });
56
113
 
57
114
  if (this.buffer.length > this.maxBufferSize) {
@@ -60,58 +117,251 @@ export class MouseTelemetryCollector {
60
117
  }
61
118
 
62
119
  /**
63
- * Resamples trajectory to fixed length and derives kinematic velocity vector.
64
- * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
120
+ * Pointer Events callbacks
121
+ * @private
65
122
  */
66
- getPayload() {
67
- const requiredDuration = this.windowLength * this.sampleIntervalMs;
68
- if (this.buffer.length < 10) return null;
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
+ }
69
136
 
70
- const latest = this.buffer[this.buffer.length - 1];
71
- const threshold = latest.t - requiredDuration;
72
- const points = this.buffer.filter(p => p.t >= threshold);
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
+ }
73
141
 
74
- if (points.length < 8) return null;
142
+ _onPointerUp(e) {
143
+ const pointerId = e.pointerId ?? 0;
144
+ const start = this.activePointers.get(pointerId);
145
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
146
+ const pointerType = e.pointerType || (start ? start.pointerType : 'touch');
75
147
 
76
- // Check synthetic dispatch
77
- const allTrusted = points.every(p => p.trusted === true);
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);
160
+ }
78
161
 
79
- const startTime = points[0].t;
80
- const L = this.windowLength;
81
- const dt = this.sampleIntervalMs;
162
+ this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, pointerType);
163
+ }
82
164
 
83
- const resampledX = new Float64Array(L);
84
- const resampledY = new Float64Array(L);
85
- let rawIdx = 0;
165
+ _onPointerCancel(e) {
166
+ const pointerId = e.pointerId ?? 0;
167
+ this.activePointers.delete(pointerId);
168
+ }
169
+
170
+ /**
171
+ * Fallback Mouse Events callback
172
+ * @private
173
+ */
174
+ _onMouseMove(e) {
175
+ this._recordPoint(e.clientX, e.clientY, e.isTrusted !== false, 'mouse');
176
+ }
177
+
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
+ }
86
197
 
87
- // 1. Equispaced linear interpolation
88
- for (let i = 0; i < L; i++) {
89
- const targetT = startTime + i * dt;
90
- while (rawIdx < points.length - 2 && points[rawIdx + 1].t < targetT) {
91
- rawIdx++;
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
+ }
203
+
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);
92
222
  }
93
- const p0 = points[rawIdx];
94
- const p1 = points[Math.min(rawIdx + 1, points.length - 1)];
95
- const segmentDt = p1.t - p0.t;
96
- 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
+ }
226
+
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);
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;
97
281
 
98
- resampledX[i] = p0.x + alpha * (p1.x - p0.x);
99
- resampledY[i] = p0.y + alpha * (p1.y - p0.y);
282
+ resampledX[i] = p0.x + alpha * (p1.x - p0.x);
283
+ resampledY[i] = p0.y + alpha * (p1.y - p0.y);
284
+ }
285
+
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
+ }
100
308
  }
101
309
 
102
- // 2. Velocity Derivation
103
- const velocity = new Float64Array(L);
104
- for (let i = 1; i < L; i++) {
105
- const dx = resampledX[i] - resampledX[i - 1];
106
- const dy = resampledY[i] - resampledY[i - 1];
107
- velocity[i] = Math.hypot(dx, dy) / dt;
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;
108
358
  }
109
- velocity[0] = velocity[1];
110
359
 
111
- return {
112
- velocity: Array.from(velocity),
113
- isTrusted: allTrusted,
114
- timestamp: Date.now()
115
- };
360
+ return null;
116
361
  }
117
362
  }
363
+
364
+ /**
365
+ * Backward compatibility alias
366
+ */
367
+ export const MouseTelemetryCollector = PointerTelemetryCollector;
@@ -7,7 +7,8 @@
7
7
  * @license MIT
8
8
  */
9
9
 
10
- export { MouseTelemetryCollector } from './collector.js';
10
+ export { PointerTelemetryCollector, MouseTelemetryCollector } from './collector.js';
11
+ export { ShieldClient } from './shieldClient.js';
11
12
 
12
13
  /**
13
14
  * Injects invisible DOM tripwire decoy forms and links to trap automated scrapers
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ShieldClient - Browser Client SDK for Behavioral Biometrics & Tripwires
3
+ *
4
+ * Provides real-time mouse kinematics telemetry capture, freshness verification,
5
+ * replay protection, and transparent HTTP interceptors for fetch and Axios.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+ import { PointerTelemetryCollector, MouseTelemetryCollector } from './collector.js';
11
+ import { injectTripwire } from './index.js';
12
+
13
+ export class ShieldClient {
14
+ /**
15
+ * @param {Object} [options={}]
16
+ * @param {number} [options.windowLength=128]
17
+ * @param {number} [options.sampleIntervalMs=10]
18
+ * @param {number} [options.maxIdleMs=2500] Discard stale trajectory if pointer idle > 2.5s
19
+ * @param {number} [options.minPoints=15] Require at least 15 events
20
+ * @param {boolean} [options.flushOnConsumption=true] Flush buffer after reading payload
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
25
+ */
26
+ constructor(options = {}) {
27
+ this.options = options;
28
+ this.collector = new PointerTelemetryCollector(options);
29
+
30
+ if (options.autoInjectTripwire && typeof document !== 'undefined') {
31
+ injectTripwire();
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Begin recording pointer movements
37
+ */
38
+ start() {
39
+ this.collector.start();
40
+ }
41
+
42
+ /**
43
+ * Stop recording pointer movements
44
+ */
45
+ stop() {
46
+ this.collector.stop();
47
+ }
48
+
49
+ /**
50
+ * Extract normalized 128-sample kinematic telemetry vector
51
+ * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
52
+ */
53
+ getPayload() {
54
+ return this.collector.getPayload();
55
+ }
56
+
57
+ /**
58
+ * Transparently instrument window.fetch to attach x-shield-telemetry header
59
+ * @param {Object} [options={}]
60
+ * @param {string} [options.headerName='x-shield-telemetry']
61
+ * @param {string[]} [options.endpoints=['*']] URL patterns to match (e.g. ['/api/auth/*', '/checkout'])
62
+ */
63
+ instrumentFetch({ headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
64
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
65
+ const originalFetch = window.fetch;
66
+ const client = this;
67
+
68
+ window.fetch = async function(input, init = {}) {
69
+ let url = typeof input === 'string' ? input : (input?.url || '');
70
+
71
+ const isMatch = endpoints.some(pattern => {
72
+ if (pattern === '*' || pattern === '/*') return true;
73
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
74
+ return url === pattern;
75
+ });
76
+
77
+ if (isMatch) {
78
+ const payload = client.getPayload();
79
+ if (payload) {
80
+ const headers = new Headers(init.headers || {});
81
+ headers.set(headerName, JSON.stringify(payload));
82
+ init = { ...init, headers };
83
+ }
84
+ }
85
+
86
+ return originalFetch.call(this, input, init);
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Transparently instrument an Axios instance to attach telemetry headers
92
+ * @param {Object} axiosInstance Axios instance
93
+ * @param {Object} [options={}]
94
+ * @param {string} [options.headerName='x-shield-telemetry']
95
+ * @param {string[]} [options.endpoints=['*']]
96
+ */
97
+ instrumentAxios(axiosInstance, { headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
98
+ if (!axiosInstance || !axiosInstance.interceptors || !axiosInstance.interceptors.request) return;
99
+ const client = this;
100
+
101
+ axiosInstance.interceptors.request.use((config) => {
102
+ const url = config.url || '';
103
+ const isMatch = endpoints.some(pattern => {
104
+ if (pattern === '*' || pattern === '/*') return true;
105
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
106
+ return url === pattern;
107
+ });
108
+
109
+ if (isMatch) {
110
+ const payload = client.getPayload();
111
+ if (payload) {
112
+ config.headers = config.headers || {};
113
+ config.headers[headerName] = JSON.stringify(payload);
114
+ }
115
+ }
116
+ return config;
117
+ });
118
+ }
119
+ }
120
+
121
+ export default ShieldClient;