@chdlabs/my-shield 0.2.0 → 0.3.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,370 @@
1
+ /**
2
+ * Shield OTP Admin Verification Gate
3
+ *
4
+ * Provides a zero-dependency MFA challenge gate for the Shield Admin Dashboard
5
+ * and sensitive governance endpoints. Uses cryptographically secure OTP generation,
6
+ * timing-safe HMAC validation, rate limiting, and a self-contained dark-mode challenge UI.
7
+ *
8
+ * @author ChdLabs
9
+ * @license MIT
10
+ */
11
+ import crypto from 'node:crypto';
12
+
13
+ export class OTPGate {
14
+ /**
15
+ * @param {Object} [options={}]
16
+ * @param {string} [options.adminEmail]
17
+ * @param {string} [options.jwtSecret] Secret for HMAC signing (auto-generated if omitted)
18
+ * @param {string} [options.cookieName='shield_session']
19
+ * @param {number} [options.codeTtlMs=300000] 5 minutes default
20
+ * @param {number} [options.sessionTtlMs=86400000] 24 hours default
21
+ * @param {number} [options.maxAttempts=5]
22
+ * @param {Function} [options.sendOtp] Async delivery function: async (email, code) => void
23
+ * @param {Object} [options.logger]
24
+ */
25
+ constructor(options = {}) {
26
+ this.adminEmail = options.adminEmail || 'admin@localhost';
27
+ this.secret = options.jwtSecret || options.secret || crypto.randomBytes(32).toString('hex');
28
+ this.cookieName = options.cookieName || 'shield_session';
29
+ this.codeTtlMs = options.codeTtlMs || 5 * 60 * 1000;
30
+ this.sessionTtlMs = options.sessionTtlMs || 24 * 60 * 60 * 1000;
31
+ this.maxAttempts = Math.max(1, options.maxAttempts || 5);
32
+ this.logger = options.logger || console;
33
+
34
+ // Fallback default sender logs code to console if no mailer provided
35
+ this.sendOtp = options.sendOtp || (async (email, code) => {
36
+ this.logger.info?.(`[SHIELD:OTP] Verification code for ${email}: ${code}`);
37
+ });
38
+
39
+ // In-memory active challenge store: email -> { code, expiresAt, attempts }
40
+ this.challenges = new Map();
41
+ }
42
+
43
+ /**
44
+ * Generate secure 6-digit numeric OTP code
45
+ * @returns {string}
46
+ */
47
+ generateCode() {
48
+ return crypto.randomInt(100000, 1000000).toString();
49
+ }
50
+
51
+ /**
52
+ * Create an HMAC signed session token
53
+ * @param {string} email
54
+ * @returns {string}
55
+ */
56
+ createSessionToken(email) {
57
+ const expiresAt = Date.now() + this.sessionTtlMs;
58
+ const payload = `${email}:${expiresAt}`;
59
+ const signature = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
60
+ return Buffer.from(`${payload}:${signature}`).toString('base64url');
61
+ }
62
+
63
+ /**
64
+ * Verify session token using timing-safe comparison
65
+ * @param {string} token
66
+ * @returns {{ valid: boolean, email?: string }}
67
+ */
68
+ verifySessionToken(token) {
69
+ if (!token || typeof token !== 'string') return { valid: false };
70
+ try {
71
+ const raw = Buffer.from(token, 'base64url').toString('utf8');
72
+ const parts = raw.split(':');
73
+ if (parts.length < 3) return { valid: false };
74
+
75
+ const signature = parts.pop();
76
+ const expiresAt = parseInt(parts.pop(), 10);
77
+ const email = parts.join(':');
78
+
79
+ if (!expiresAt || expiresAt < Date.now()) {
80
+ return { valid: false };
81
+ }
82
+
83
+ const payload = `${email}:${expiresAt}`;
84
+ const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
85
+
86
+ const sigBuf = Buffer.from(signature, 'hex');
87
+ const expBuf = Buffer.from(expected, 'hex');
88
+
89
+ if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
90
+ return { valid: false };
91
+ }
92
+
93
+ return { valid: true, email };
94
+ } catch {
95
+ return { valid: false };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Parse cookies from request header
101
+ * @param {Object} req
102
+ * @returns {Record<string, string>}
103
+ */
104
+ parseCookies(req) {
105
+ const header = req.headers?.cookie;
106
+ if (!header) return {};
107
+ const cookies = {};
108
+ for (const pair of header.split(';')) {
109
+ const [k, ...v] = pair.trim().split('=');
110
+ if (k) cookies[k] = decodeURIComponent(v.join('='));
111
+ }
112
+ return cookies;
113
+ }
114
+
115
+ /**
116
+ * Render self-contained zero-dependency dark-mode HTML challenge page
117
+ */
118
+ renderChallengeHtml(req, errorMessage = '') {
119
+ const baseUrl = req.baseUrl || '';
120
+ return `<!DOCTYPE html>
121
+ <html lang="en">
122
+ <head>
123
+ <meta charset="UTF-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
125
+ <title>Shield Admin Authentication</title>
126
+ <style>
127
+ :root {
128
+ --bg: #090d16;
129
+ --surface: #131b2e;
130
+ --border: #1f2d4d;
131
+ --primary: #3b82f6;
132
+ --primary-hover: #2563eb;
133
+ --text: #f8fafc;
134
+ --muted: #94a3b8;
135
+ --danger: #ef4444;
136
+ --success: #10b981;
137
+ }
138
+ * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
139
+ body {
140
+ background-color: var(--bg);
141
+ color: var(--text);
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: center;
145
+ min-height: 100vh;
146
+ padding: 1.5rem;
147
+ }
148
+ .card {
149
+ background: var(--surface);
150
+ border: 1px solid var(--border);
151
+ border-radius: 12px;
152
+ padding: 2.5rem;
153
+ width: 100%;
154
+ max-width: 420px;
155
+ box-shadow: 0 20px 40px rgba(0,0,0,0.4);
156
+ }
157
+ .header { text-align: center; margin-bottom: 2rem; }
158
+ .logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
159
+ h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
160
+ p.subtitle { color: var(--muted); font-size: 0.875rem; }
161
+ .alert {
162
+ background: rgba(239, 68, 68, 0.15);
163
+ border: 1px solid var(--danger);
164
+ color: #fca5a5;
165
+ padding: 0.75rem 1rem;
166
+ border-radius: 6px;
167
+ font-size: 0.875rem;
168
+ margin-bottom: 1.25rem;
169
+ }
170
+ .success-box {
171
+ background: rgba(16, 185, 129, 0.15);
172
+ border: 1px solid var(--success);
173
+ color: #6ee7b7;
174
+ padding: 0.75rem 1rem;
175
+ border-radius: 6px;
176
+ font-size: 0.875rem;
177
+ margin-bottom: 1.25rem;
178
+ }
179
+ .form-group { margin-bottom: 1.25rem; }
180
+ label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; color: var(--muted); }
181
+ input[type="text"] {
182
+ width: 100%;
183
+ background: #0b1120;
184
+ border: 1px solid var(--border);
185
+ color: var(--text);
186
+ padding: 0.75rem 1rem;
187
+ border-radius: 6px;
188
+ font-size: 1.125rem;
189
+ letter-spacing: 0.25em;
190
+ text-align: center;
191
+ outline: none;
192
+ transition: border-color 0.2s;
193
+ }
194
+ input[type="text"]:focus { border-color: var(--primary); }
195
+ button {
196
+ width: 100%;
197
+ background: var(--primary);
198
+ color: white;
199
+ border: none;
200
+ padding: 0.75rem;
201
+ border-radius: 6px;
202
+ font-size: 0.95rem;
203
+ font-weight: 600;
204
+ cursor: pointer;
205
+ transition: background 0.2s;
206
+ }
207
+ button:hover { background: var(--primary-hover); }
208
+ .actions { display: flex; justify-content: space-between; margin-top: 1rem; }
209
+ .actions button.secondary {
210
+ background: transparent;
211
+ border: 1px solid var(--border);
212
+ color: var(--muted);
213
+ font-weight: normal;
214
+ }
215
+ .actions button.secondary:hover { color: var(--text); border-color: var(--text); background: transparent; }
216
+ </style>
217
+ </head>
218
+ <body>
219
+ <div class="card">
220
+ <div class="header">
221
+ <div class="logo">🛡️</div>
222
+ <h1>Shield Security Gate</h1>
223
+ <p class="subtitle">Enter the 6-digit authentication code sent to administrator.</p>
224
+ </div>
225
+
226
+ ${errorMessage ? `<div class="alert">${errorMessage}</div>` : ''}
227
+
228
+ <form method="POST" action="${baseUrl}/__shield_verify">
229
+ <div class="form-group">
230
+ <label for="otp">One-Time Passcode</label>
231
+ <input id="otp" name="code" type="text" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" placeholder="••••••" required autofocus autocomplete="one-time-code" />
232
+ </div>
233
+ <button type="submit">Verify & Access Console</button>
234
+ </form>
235
+
236
+ <form method="POST" action="${baseUrl}/__shield_request" style="margin-top: 1rem;">
237
+ <button type="submit" class="secondary">Request New Code</button>
238
+ </form>
239
+ </div>
240
+ </body>
241
+ </html>`;
242
+ }
243
+
244
+ /**
245
+ * Express middleware handler
246
+ */
247
+ middleware() {
248
+ return async (req, res, next) => {
249
+ const cookies = this.parseCookies(req);
250
+ const sessionToken = cookies[this.cookieName] || req.headers?.['x-shield-auth-token'];
251
+
252
+ // 1. Check existing verified session
253
+ if (sessionToken) {
254
+ const verification = this.verifySessionToken(sessionToken);
255
+ if (verification.valid) {
256
+ req.shieldAdmin = { email: verification.email };
257
+ return next();
258
+ }
259
+ }
260
+
261
+ const path = req.path || '';
262
+
263
+ // 2. Handle POST Request to send a new code
264
+ if (req.method === 'POST' && path.endsWith('/__shield_request')) {
265
+ const code = this.generateCode();
266
+ this.challenges.set(this.adminEmail, {
267
+ code,
268
+ expiresAt: Date.now() + this.codeTtlMs,
269
+ attempts: 0
270
+ });
271
+
272
+ try {
273
+ await this.sendOtp(this.adminEmail, code);
274
+ } catch (err) {
275
+ this.logger.error?.('[SHIELD:OTP] Error dispatching OTP email:', err.message);
276
+ }
277
+
278
+ if (req.accepts('html')) {
279
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
280
+ return res.send(this.renderChallengeHtml(req, ''));
281
+ }
282
+ return res.json({ success: true, message: 'Verification code generated' });
283
+ }
284
+
285
+ // 3. Handle POST Request to verify code
286
+ if (req.method === 'POST' && path.endsWith('/__shield_verify')) {
287
+ // Parse body if express.urlencoded/json not mounted
288
+ let code = req.body?.code;
289
+ if (!code && typeof req.body === 'string') {
290
+ const match = req.body.match(/code=([^&]+)/);
291
+ if (match) code = decodeURIComponent(match[1]);
292
+ }
293
+
294
+ const challenge = this.challenges.get(this.adminEmail);
295
+ if (!challenge || Date.now() > challenge.expiresAt) {
296
+ if (req.accepts('html')) {
297
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
298
+ return res.status(401).send(this.renderChallengeHtml(req, 'Code has expired. Please request a new code.'));
299
+ }
300
+ return res.status(401).json({ error: 'Code has expired', code: 'EXPIRED_CODE' });
301
+ }
302
+
303
+ challenge.attempts = (challenge.attempts || 0) + 1;
304
+ if (challenge.attempts > this.maxAttempts) {
305
+ this.challenges.delete(this.adminEmail);
306
+ if (req.accepts('html')) {
307
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
308
+ return res.status(403).send(this.renderChallengeHtml(req, 'Too many failed attempts. Locked out.'));
309
+ }
310
+ return res.status(403).json({ error: 'Maximum verification attempts exceeded', code: 'MAX_ATTEMPTS_EXCEEDED' });
311
+ }
312
+
313
+ // Verify code with timing-safe comparison
314
+ const userCodeBuf = Buffer.from(String(code || '').trim());
315
+ const expectedCodeBuf = Buffer.from(challenge.code);
316
+
317
+ const isMatch = userCodeBuf.length === expectedCodeBuf.length &&
318
+ crypto.timingSafeEqual(userCodeBuf, expectedCodeBuf);
319
+
320
+ if (isMatch) {
321
+ this.challenges.delete(this.adminEmail);
322
+ const token = this.createSessionToken(this.adminEmail);
323
+
324
+ // Set HttpOnly signed cookie
325
+ const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
326
+ const cookieFlags = [
327
+ `${this.cookieName}=${encodeURIComponent(token)}`,
328
+ 'Path=/',
329
+ 'HttpOnly',
330
+ 'SameSite=Lax',
331
+ `Max-Age=${Math.floor(this.sessionTtlMs / 1000)}`
332
+ ];
333
+ if (isSecure) cookieFlags.push('Secure');
334
+
335
+ res.setHeader('Set-Cookie', cookieFlags.join('; '));
336
+
337
+ if (req.accepts('html')) {
338
+ const targetUrl = req.baseUrl || '/';
339
+ return res.redirect(targetUrl || '/');
340
+ }
341
+ return res.json({ success: true, token });
342
+ }
343
+
344
+ if (req.accepts('html')) {
345
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
346
+ return res.status(401).send(this.renderChallengeHtml(req, 'Invalid verification code. Please try again.'));
347
+ }
348
+ return res.status(401).json({ error: 'Invalid verification code', code: 'INVALID_CODE' });
349
+ }
350
+
351
+ // 4. Default unauthenticated presentation
352
+ if (req.accepts('html')) {
353
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
354
+ return res.status(401).send(this.renderChallengeHtml(req, ''));
355
+ }
356
+
357
+ return res.status(401).json({
358
+ error: 'Shield OTP authentication required',
359
+ code: 'SHIELD_OTP_REQUIRED'
360
+ });
361
+ };
362
+ }
363
+ }
364
+
365
+ export function createOtpGate(options = {}) {
366
+ const gate = new OTPGate(options);
367
+ return gate.middleware();
368
+ }
369
+
370
+ export default createOtpGate;
@@ -14,11 +14,17 @@ export class MouseTelemetryCollector {
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 to avoid flat interpolation
19
+ * @param {boolean} [options.flushOnConsumption=true] Flush buffer after getPayload to avoid replaying identical vectors
17
20
  */
18
21
  constructor(options = {}) {
19
22
  this.windowLength = options.windowLength || 128;
20
23
  this.sampleIntervalMs = options.sampleIntervalMs || 10;
21
24
  this.maxBufferSize = options.maxBufferSize || 500;
25
+ this.maxIdleMs = options.maxIdleMs ?? 2500;
26
+ this.minPoints = options.minPoints ?? 15;
27
+ this.flushOnConsumption = options.flushOnConsumption !== false;
22
28
  this.buffer = [];
23
29
  this.isListening = false;
24
30
  this._onMove = this._onMove.bind(this);
@@ -64,14 +70,22 @@ export class MouseTelemetryCollector {
64
70
  * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
65
71
  */
66
72
  getPayload() {
67
- const requiredDuration = this.windowLength * this.sampleIntervalMs;
68
- if (this.buffer.length < 10) return null;
73
+ if (this.buffer.length < this.minPoints) return null;
69
74
 
75
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
70
76
  const latest = this.buffer[this.buffer.length - 1];
77
+
78
+ // 1. Freshness Guard: If pointer has been stationary for > maxIdleMs (e.g. 2.5s), discard stale vectors
79
+ if (this.maxIdleMs > 0 && (now - latest.t > this.maxIdleMs)) {
80
+ if (this.flushOnConsumption) this.buffer = [];
81
+ return null;
82
+ }
83
+
84
+ const requiredDuration = this.windowLength * this.sampleIntervalMs;
71
85
  const threshold = latest.t - requiredDuration;
72
86
  const points = this.buffer.filter(p => p.t >= threshold);
73
87
 
74
- if (points.length < 8) return null;
88
+ if (points.length < this.minPoints) return null;
75
89
 
76
90
  // Check synthetic dispatch
77
91
  const allTrusted = points.every(p => p.trusted === true);
@@ -84,7 +98,7 @@ export class MouseTelemetryCollector {
84
98
  const resampledY = new Float64Array(L);
85
99
  let rawIdx = 0;
86
100
 
87
- // 1. Equispaced linear interpolation
101
+ // 2. Equispaced linear interpolation
88
102
  for (let i = 0; i < L; i++) {
89
103
  const targetT = startTime + i * dt;
90
104
  while (rawIdx < points.length - 2 && points[rawIdx + 1].t < targetT) {
@@ -99,7 +113,7 @@ export class MouseTelemetryCollector {
99
113
  resampledY[i] = p0.y + alpha * (p1.y - p0.y);
100
114
  }
101
115
 
102
- // 2. Velocity Derivation
116
+ // 3. Velocity Derivation
103
117
  const velocity = new Float64Array(L);
104
118
  for (let i = 1; i < L; i++) {
105
119
  const dx = resampledX[i] - resampledX[i - 1];
@@ -108,6 +122,11 @@ export class MouseTelemetryCollector {
108
122
  }
109
123
  velocity[0] = velocity[1];
110
124
 
125
+ // 4. Buffer Flush on Consumption (Stops replay of identical kinematics across multiple clicks)
126
+ if (this.flushOnConsumption) {
127
+ this.buffer = [];
128
+ }
129
+
111
130
  return {
112
131
  velocity: Array.from(velocity),
113
132
  isTrusted: allTrusted,
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  export { 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,118 @@
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 { 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
+ */
23
+ constructor(options = {}) {
24
+ this.options = options;
25
+ this.collector = new MouseTelemetryCollector(options);
26
+
27
+ if (options.autoInjectTripwire && typeof document !== 'undefined') {
28
+ injectTripwire();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Begin recording pointer movements
34
+ */
35
+ start() {
36
+ this.collector.start();
37
+ }
38
+
39
+ /**
40
+ * Stop recording pointer movements
41
+ */
42
+ stop() {
43
+ this.collector.stop();
44
+ }
45
+
46
+ /**
47
+ * Extract normalized 128-sample kinematic telemetry vector
48
+ * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
49
+ */
50
+ getPayload() {
51
+ return this.collector.getPayload();
52
+ }
53
+
54
+ /**
55
+ * Transparently instrument window.fetch to attach x-shield-telemetry header
56
+ * @param {Object} [options={}]
57
+ * @param {string} [options.headerName='x-shield-telemetry']
58
+ * @param {string[]} [options.endpoints=['*']] URL patterns to match (e.g. ['/api/auth/*', '/checkout'])
59
+ */
60
+ instrumentFetch({ headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
61
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
62
+ const originalFetch = window.fetch;
63
+ const client = this;
64
+
65
+ window.fetch = async function(input, init = {}) {
66
+ let url = typeof input === 'string' ? input : (input?.url || '');
67
+
68
+ const isMatch = endpoints.some(pattern => {
69
+ if (pattern === '*' || pattern === '/*') return true;
70
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
71
+ return url === pattern;
72
+ });
73
+
74
+ if (isMatch) {
75
+ const payload = client.getPayload();
76
+ if (payload) {
77
+ const headers = new Headers(init.headers || {});
78
+ headers.set(headerName, JSON.stringify(payload));
79
+ init = { ...init, headers };
80
+ }
81
+ }
82
+
83
+ return originalFetch.call(this, input, init);
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Transparently instrument an Axios instance to attach telemetry headers
89
+ * @param {Object} axiosInstance Axios instance
90
+ * @param {Object} [options={}]
91
+ * @param {string} [options.headerName='x-shield-telemetry']
92
+ * @param {string[]} [options.endpoints=['*']]
93
+ */
94
+ instrumentAxios(axiosInstance, { headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
95
+ if (!axiosInstance || !axiosInstance.interceptors || !axiosInstance.interceptors.request) return;
96
+ const client = this;
97
+
98
+ axiosInstance.interceptors.request.use((config) => {
99
+ const url = config.url || '';
100
+ const isMatch = endpoints.some(pattern => {
101
+ if (pattern === '*' || pattern === '/*') return true;
102
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
103
+ return url === pattern;
104
+ });
105
+
106
+ if (isMatch) {
107
+ const payload = client.getPayload();
108
+ if (payload) {
109
+ config.headers = config.headers || {};
110
+ config.headers[headerName] = JSON.stringify(payload);
111
+ }
112
+ }
113
+ return config;
114
+ });
115
+ }
116
+ }
117
+
118
+ export default ShieldClient;