@chdlabs/my-shield 0.1.3 → 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.
@@ -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
+ }