@chdlabs/my-shield 0.1.2 → 0.2.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/README.md +154 -1
- package/models/default-weights.json +682 -0
- package/package.json +16 -5
- package/src/biometrics/index.js +10 -0
- package/src/biometrics/minirocket.js +213 -0
- package/src/biometrics/ridge.js +144 -0
- package/src/client/collector.js +117 -0
- package/src/client/index.js +67 -0
- package/src/index.d.ts +60 -0
- package/src/index.js +33 -1
- package/src/middleware/behavioralGuard.js +209 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/middleware/behavioralGuard.js
|
|
3
|
+
* Behavioral Biometrics Express Middleware
|
|
4
|
+
* Evaluates pointer kinematics telemetry against MiniRocket + Dual Ridge model.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { MiniRocket } from '../biometrics/minirocket.js';
|
|
15
|
+
import { DualRidgeClassifier } from '../biometrics/ridge.js';
|
|
16
|
+
import { LRUCache } from '../core/lruCache.js';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
|
|
21
|
+
export class BehavioralShieldService {
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} [options={}]
|
|
24
|
+
* @param {Object} [options.modelWeights] Custom model weights { thresholds, weights, bias }
|
|
25
|
+
* @param {Object} [options.shield] Shield instance
|
|
26
|
+
* @param {Object} [options.logger=console] Logger instance
|
|
27
|
+
*/
|
|
28
|
+
constructor({ modelWeights = null, shield = null, logger = console } = {}) {
|
|
29
|
+
this.shield = shield;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
|
|
32
|
+
let weights = modelWeights;
|
|
33
|
+
if (!weights) {
|
|
34
|
+
try {
|
|
35
|
+
const defaultWeightsPath = path.resolve(__dirname, '../../models/default-weights.json');
|
|
36
|
+
if (fs.existsSync(defaultWeightsPath)) {
|
|
37
|
+
weights = JSON.parse(fs.readFileSync(defaultWeightsPath, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.logger?.warn?.('[SHIELD:BIOMETRICS] Could not load default-weights.json:', err.message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const featureCount = weights?.weights?.length || 336;
|
|
45
|
+
const windowLength = weights?.windowLength || 128;
|
|
46
|
+
|
|
47
|
+
this.transformer = new MiniRocket(windowLength, featureCount);
|
|
48
|
+
if (weights?.thresholds) {
|
|
49
|
+
this.transformer.loadThresholds(weights.thresholds);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.classifier = new DualRidgeClassifier();
|
|
53
|
+
if (weights?.weights) {
|
|
54
|
+
this.classifier.loadWeights(weights.weights, weights.bias || 0.0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bounded LRU cache for replay nonce protection (eliminates memory leaks)
|
|
58
|
+
this.nonceCache = new LRUCache({
|
|
59
|
+
maxSize: 10_000,
|
|
60
|
+
ttlMs: 60_000
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify a 128-sample velocity vector
|
|
66
|
+
* @param {number[]|Float64Array} velocityArray
|
|
67
|
+
* @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number }}
|
|
68
|
+
*/
|
|
69
|
+
verify(velocityArray) {
|
|
70
|
+
const t0 = performance.now();
|
|
71
|
+
const series = Float64Array.from(velocityArray);
|
|
72
|
+
const features = this.transformer.transform(series);
|
|
73
|
+
const result = this.classifier.predict(features);
|
|
74
|
+
const latency = +(performance.now() - t0).toFixed(2);
|
|
75
|
+
|
|
76
|
+
return { ...result, latencyMs: latency };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract client IP safely from headers or socket
|
|
81
|
+
* @param {Object} req
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
getClientIP(req) {
|
|
85
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
86
|
+
if (forwarded && typeof forwarded === 'string') {
|
|
87
|
+
const first = forwarded.split(',')[0].trim();
|
|
88
|
+
if (first) return first;
|
|
89
|
+
}
|
|
90
|
+
return req.socket?.remoteAddress || req.ip || '127.0.0.1';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Express middleware factory
|
|
95
|
+
* @param {Object} [options={}]
|
|
96
|
+
* @param {number} [options.threshold=0.70] Probability threshold for human classification
|
|
97
|
+
* @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
|
|
98
|
+
* @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
|
|
99
|
+
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed
|
|
100
|
+
* @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
|
|
101
|
+
* @param {Function} [options.onBotDetected] Custom callback for bot detection
|
|
102
|
+
*/
|
|
103
|
+
middleware(options = {}) {
|
|
104
|
+
const {
|
|
105
|
+
threshold = 0.70,
|
|
106
|
+
headerName = 'x-shield-telemetry',
|
|
107
|
+
replayWindowMs = 15_000,
|
|
108
|
+
allowFallbackOnError = false,
|
|
109
|
+
autoBlockIP = false,
|
|
110
|
+
onBotDetected
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
return (req, res, next) => {
|
|
114
|
+
const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
|
|
115
|
+
|
|
116
|
+
if (!rawHeader) {
|
|
117
|
+
if (allowFallbackOnError) return next();
|
|
118
|
+
return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const payload = typeof rawHeader === 'string' ? JSON.parse(rawHeader) : rawHeader;
|
|
123
|
+
|
|
124
|
+
// 1. Synthetic Dispatch Check
|
|
125
|
+
if (payload.isTrusted === false) {
|
|
126
|
+
return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Replay Protection Window Check
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (!payload.timestamp || typeof payload.timestamp !== 'number' || Math.abs(now - payload.timestamp) > replayWindowMs) {
|
|
132
|
+
return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Payload Schema Validation
|
|
136
|
+
if (!Array.isArray(payload.velocity) || payload.velocity.length !== this.transformer.windowLength) {
|
|
137
|
+
return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < payload.velocity.length; i++) {
|
|
141
|
+
const val = payload.velocity[i];
|
|
142
|
+
if (typeof val !== 'number' || !Number.isFinite(val)) {
|
|
143
|
+
return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. Coordinate Signature Nonce Check
|
|
148
|
+
const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
|
|
149
|
+
if (this.nonceCache.has(nonce)) {
|
|
150
|
+
return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
|
|
151
|
+
}
|
|
152
|
+
this.nonceCache.set(nonce, true, replayWindowMs);
|
|
153
|
+
|
|
154
|
+
// 5. MiniRocket & Dual Ridge Scoring
|
|
155
|
+
const outcome = this.verify(payload.velocity);
|
|
156
|
+
const ip = this.getClientIP(req);
|
|
157
|
+
|
|
158
|
+
req.shield = req.shield || {};
|
|
159
|
+
req.shield.biometrics = {
|
|
160
|
+
isHuman: outcome.probability >= threshold,
|
|
161
|
+
humanConfidence: outcome.probability,
|
|
162
|
+
latencyMs: outcome.latencyMs,
|
|
163
|
+
score: outcome.score
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Record event in Shield core if attached
|
|
167
|
+
if (this.shield) {
|
|
168
|
+
this.shield.recordEvent({
|
|
169
|
+
type: 'BIOMETRICS_VERIFIED',
|
|
170
|
+
ip,
|
|
171
|
+
isHuman: outcome.probability >= threshold,
|
|
172
|
+
confidence: outcome.probability,
|
|
173
|
+
latencyMs: outcome.latencyMs
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (outcome.probability < threshold) {
|
|
178
|
+
if (autoBlockIP && this.shield) {
|
|
179
|
+
this.shield.blockIP(ip, 'behavioral_bot_detected', {
|
|
180
|
+
confidence: outcome.probability,
|
|
181
|
+
latencyMs: outcome.latencyMs
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
next();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (allowFallbackOnError) return next();
|
|
190
|
+
res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle blocked request
|
|
197
|
+
* @private
|
|
198
|
+
*/
|
|
199
|
+
_deny(req, res, reason, customHandler, meta = {}) {
|
|
200
|
+
if (customHandler && typeof customHandler === 'function') {
|
|
201
|
+
return customHandler(req, res, { reason, ...meta });
|
|
202
|
+
}
|
|
203
|
+
return res.status(403).json({
|
|
204
|
+
status: 'BLOCKED',
|
|
205
|
+
shieldCode: reason,
|
|
206
|
+
details: meta
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|