iframe.io 1.2.0 → 1.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.
- package/README.md +406 -136
- package/dist/index.d.ts +152 -0
- package/dist/index.js +670 -32
- package/package.json +3 -5
- package/src/index.ts +838 -32
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// Current protocol version
|
|
4
|
+
const PROTOCOL_VERSION = 1;
|
|
3
5
|
function newObject(data) {
|
|
4
6
|
return JSON.parse(JSON.stringify(data));
|
|
5
7
|
}
|
|
@@ -20,13 +22,105 @@ function sanitizePayload(payload, maxSize) {
|
|
|
20
22
|
// Basic sanitization - remove functions and undefined values
|
|
21
23
|
return JSON.parse(JSON.stringify(payload));
|
|
22
24
|
}
|
|
25
|
+
function constantTimeEqual(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
let out = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++)
|
|
30
|
+
out |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
31
|
+
return out === 0;
|
|
32
|
+
}
|
|
33
|
+
function getGlobalCrypto() {
|
|
34
|
+
return (typeof crypto !== 'undefined'
|
|
35
|
+
? crypto
|
|
36
|
+
: (typeof window !== 'undefined' && window.crypto)
|
|
37
|
+
|| (typeof globalThis !== 'undefined' && globalThis.crypto));
|
|
38
|
+
}
|
|
39
|
+
function randomHex(bytes) {
|
|
40
|
+
try {
|
|
41
|
+
const globalCrypto = getGlobalCrypto();
|
|
42
|
+
if (globalCrypto && typeof globalCrypto.getRandomValues === 'function') {
|
|
43
|
+
const buf = new Uint8Array(bytes);
|
|
44
|
+
globalCrypto.getRandomValues(buf);
|
|
45
|
+
return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
// Fallback (NOT cryptographically strong)
|
|
50
|
+
return Array.from({ length: bytes }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0')).join('');
|
|
51
|
+
}
|
|
52
|
+
async function hmacSha256Base64Url(secret, message) {
|
|
53
|
+
// Browser/WebCrypto
|
|
54
|
+
try {
|
|
55
|
+
const globalCrypto = getGlobalCrypto();
|
|
56
|
+
const subtle = globalCrypto?.subtle;
|
|
57
|
+
if (subtle && typeof subtle.importKey === 'function') {
|
|
58
|
+
const enc = new TextEncoder();
|
|
59
|
+
const key = await subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
60
|
+
const sig = await subtle.sign('HMAC', key, enc.encode(message));
|
|
61
|
+
const bytes = new Uint8Array(sig);
|
|
62
|
+
const b64 = btoa(String.fromCharCode(...bytes));
|
|
63
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// fallthrough to Node implementation
|
|
68
|
+
}
|
|
69
|
+
// Node.js (commonjs) - optional
|
|
70
|
+
try {
|
|
71
|
+
const nodeCrypto = globalThis.__iof_node_crypto
|
|
72
|
+
|| (globalThis.__iof_node_crypto = (typeof globalThis.require === 'function'
|
|
73
|
+
? globalThis.require('crypto')
|
|
74
|
+
: undefined));
|
|
75
|
+
if (!nodeCrypto)
|
|
76
|
+
throw new Error('node crypto unavailable');
|
|
77
|
+
const b64 = nodeCrypto.createHmac('sha256', secret).update(message).digest('base64');
|
|
78
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('No crypto implementation available for HMAC-SHA256');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Derive a session key using HKDF-like construction
|
|
86
|
+
* HKDF(masterSecret, salt, info) where:
|
|
87
|
+
* - masterSecret: the shared secret
|
|
88
|
+
* - salt: combined session IDs
|
|
89
|
+
* - info: context string
|
|
90
|
+
*/
|
|
91
|
+
async function deriveSessionKey(masterSecret, sessionId1, sessionId2, keyId) {
|
|
92
|
+
/**
|
|
93
|
+
* Two HMAC steps, both through `hmacSha256Base64Url`, deliberately.
|
|
94
|
+
*
|
|
95
|
+
* This used to have a WebCrypto branch alongside this one, and the two did
|
|
96
|
+
* not agree: the WebCrypto path fed the extract step's RAW bytes into the
|
|
97
|
+
* expand step and appended \x01 to the info string, while this path fed the
|
|
98
|
+
* base64url TEXT of those bytes and appended nothing. Same inputs, different
|
|
99
|
+
* keys.
|
|
100
|
+
*
|
|
101
|
+
* Peers do not have to share an implementation for that to matter, only an
|
|
102
|
+
* environment: `crypto.subtle` is undefined outside a secure context, so a
|
|
103
|
+
* WebView or iframe served over plain HTTP took one branch while an HTTPS
|
|
104
|
+
* host took the other. Every signed message then failed to verify — and
|
|
105
|
+
* because the WebCrypto branch was also entered from a silent catch, a
|
|
106
|
+
* single transient failure on one side desynchronised the pair for the rest
|
|
107
|
+
* of the connection.
|
|
108
|
+
*
|
|
109
|
+
* `hmacSha256Base64Url` already resolves WebCrypto vs Node internally and
|
|
110
|
+
* returns the same string either way, so deriving through it twice is both
|
|
111
|
+
* shorter and the only version that can agree with itself.
|
|
112
|
+
*/
|
|
113
|
+
const salt = sessionId1 + '|' + sessionId2,
|
|
114
|
+
// Extract: PRK = HMAC( masterSecret, salt )
|
|
115
|
+
prk = await hmacSha256Base64Url(masterSecret, salt),
|
|
116
|
+
// Expand: OKM = HMAC( PRK, info )
|
|
117
|
+
info = 'iframe.io-session-key-' + keyId;
|
|
118
|
+
return await hmacSha256Base64Url(prk, info);
|
|
119
|
+
}
|
|
23
120
|
const ackId = () => {
|
|
24
121
|
// Prefer cryptographically strong randomness when available
|
|
25
122
|
try {
|
|
26
|
-
const globalCrypto = (
|
|
27
|
-
? crypto
|
|
28
|
-
: (typeof window !== 'undefined' && window.crypto)
|
|
29
|
-
|| (typeof globalThis !== 'undefined' && globalThis.crypto));
|
|
123
|
+
const globalCrypto = getGlobalCrypto();
|
|
30
124
|
if (globalCrypto && typeof globalCrypto.getRandomValues === 'function') {
|
|
31
125
|
const buffer = new Uint32Array(4);
|
|
32
126
|
globalCrypto.getRandomValues(buffer);
|
|
@@ -40,11 +134,21 @@ const ackId = () => {
|
|
|
40
134
|
const rmin = 100000, rmax = 999999, timestampFallback = Date.now(), randomFallback = Math.floor(Math.random() * (rmax - rmin + 1) + rmin);
|
|
41
135
|
return `${timestampFallback}_${randomFallback}`;
|
|
42
136
|
};
|
|
137
|
+
// Answered before authentication, so they are gated separately — see the
|
|
138
|
+
// handlers in initiate() and listen().
|
|
139
|
+
const RESERVED_SESSION_KEY_EVENTS = [
|
|
140
|
+
'__session_key_init',
|
|
141
|
+
'__session_key_rotate',
|
|
142
|
+
'__session_key_ack'
|
|
143
|
+
];
|
|
43
144
|
const RESERVED_EVENTS = [
|
|
44
145
|
'ping',
|
|
45
146
|
'pong',
|
|
46
147
|
'__heartbeat',
|
|
47
|
-
'__heartbeat_response'
|
|
148
|
+
'__heartbeat_response',
|
|
149
|
+
'__session_key_init',
|
|
150
|
+
'__session_key_rotate',
|
|
151
|
+
'__session_key_ack'
|
|
48
152
|
];
|
|
49
153
|
class IOF {
|
|
50
154
|
constructor(options = {}) {
|
|
@@ -52,6 +156,7 @@ class IOF {
|
|
|
52
156
|
this.messageRateTracker = [];
|
|
53
157
|
this.reconnectAttempts = 0;
|
|
54
158
|
this.maxReconnectAttempts = 5;
|
|
159
|
+
this.seenNonces = new Map();
|
|
55
160
|
if (options && typeof options !== 'object')
|
|
56
161
|
throw new Error('Invalid Options');
|
|
57
162
|
this.options = {
|
|
@@ -69,6 +174,357 @@ class IOF {
|
|
|
69
174
|
if (options.type)
|
|
70
175
|
this.peer.type = options.type.toUpperCase();
|
|
71
176
|
}
|
|
177
|
+
cryptoCfg() {
|
|
178
|
+
if (!this.options.cryptoAuth)
|
|
179
|
+
return undefined;
|
|
180
|
+
return {
|
|
181
|
+
secret: this.options.cryptoAuth.secret,
|
|
182
|
+
requireSigned: !!this.options.cryptoAuth.requireSigned,
|
|
183
|
+
maxSkewMs: this.options.cryptoAuth.maxSkewMs ?? 2 * 60 * 1000,
|
|
184
|
+
replayWindowSize: this.options.cryptoAuth.replayWindowSize ?? 500,
|
|
185
|
+
enableSessionKeys: !!this.options.cryptoAuth.enableSessionKeys,
|
|
186
|
+
sessionKeyRotationInterval: this.options.cryptoAuth.sessionKeyRotationInterval ?? 3600000 // 1 hour
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Forget nonces that can no longer be replayed, and only then cap the map.
|
|
191
|
+
*
|
|
192
|
+
* Age is what actually decides replayability: a captured message is refused
|
|
193
|
+
* once its `ts` falls outside maxSkewMs, so a nonce is only worth keeping
|
|
194
|
+
* that long. Pruning purely by count — as this did — made the two defaults
|
|
195
|
+
* contradict each other: 500 remembered nonces at the default 100 messages a
|
|
196
|
+
* second is five seconds of history guarding a two-minute acceptance window,
|
|
197
|
+
* so anything captured could simply be replayed after five seconds.
|
|
198
|
+
*
|
|
199
|
+
* The count cap stays as a memory bound. Reaching it means the rate limiter
|
|
200
|
+
* is admitting more traffic than the window can remember, so it is reported
|
|
201
|
+
* rather than applied in silence.
|
|
202
|
+
*/
|
|
203
|
+
pruneNonces(maxSize) {
|
|
204
|
+
const cutoff = Date.now() - (this.cryptoCfg()?.maxSkewMs ?? 2 * 60 * 1000);
|
|
205
|
+
for (const [nonce, ts] of this.seenNonces)
|
|
206
|
+
if (ts < cutoff)
|
|
207
|
+
this.seenNonces.delete(nonce);
|
|
208
|
+
if (this.seenNonces.size <= maxSize)
|
|
209
|
+
return;
|
|
210
|
+
this.fire('error', {
|
|
211
|
+
type: 'REPLAY_WINDOW_EXCEEDED',
|
|
212
|
+
remembered: this.seenNonces.size,
|
|
213
|
+
maxSize
|
|
214
|
+
});
|
|
215
|
+
// Oldest first — Map iterates in insertion order, and nonces are inserted
|
|
216
|
+
// as they arrive.
|
|
217
|
+
const toRemove = this.seenNonces.size - maxSize;
|
|
218
|
+
let i = 0;
|
|
219
|
+
for (const key of this.seenNonces.keys()) {
|
|
220
|
+
this.seenNonces.delete(key);
|
|
221
|
+
if (++i >= toRemove)
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Get the appropriate secret for signing messages
|
|
227
|
+
* Uses session key if available, otherwise falls back to master secret
|
|
228
|
+
*/
|
|
229
|
+
getSigningSecret() {
|
|
230
|
+
const cfg = this.cryptoCfg();
|
|
231
|
+
if (!cfg)
|
|
232
|
+
return { secret: '' };
|
|
233
|
+
// Use session key if enabled and available
|
|
234
|
+
if (cfg.enableSessionKeys && this.currentSessionKey) {
|
|
235
|
+
return {
|
|
236
|
+
secret: this.currentSessionKey.key,
|
|
237
|
+
keyId: this.currentSessionKey.keyId
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Fall back to master secret
|
|
241
|
+
return { secret: cfg.secret };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get the appropriate secret for verifying incoming messages
|
|
245
|
+
* Tries current key, then pending, then previous, then master
|
|
246
|
+
*/
|
|
247
|
+
getVerificationSecrets() {
|
|
248
|
+
const cfg = this.cryptoCfg();
|
|
249
|
+
if (!cfg)
|
|
250
|
+
return [];
|
|
251
|
+
const secrets = [];
|
|
252
|
+
if (cfg.enableSessionKeys) {
|
|
253
|
+
// Try current session key first
|
|
254
|
+
if (this.currentSessionKey) {
|
|
255
|
+
secrets.push({
|
|
256
|
+
secret: this.currentSessionKey.key,
|
|
257
|
+
keyId: this.currentSessionKey.keyId
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Try pending key during rotation
|
|
261
|
+
if (this.pendingSessionKey) {
|
|
262
|
+
secrets.push({
|
|
263
|
+
secret: this.pendingSessionKey.key,
|
|
264
|
+
keyId: this.pendingSessionKey.keyId
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// Try previous key for grace period
|
|
268
|
+
if (this.previousSessionKey) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
if (now < this.previousSessionKey.expiresAt) {
|
|
271
|
+
secrets.push({
|
|
272
|
+
secret: this.previousSessionKey.key,
|
|
273
|
+
keyId: this.previousSessionKey.keyId
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Always try master secret as fallback
|
|
279
|
+
secrets.push({ secret: cfg.secret });
|
|
280
|
+
return secrets;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Application-level admission check for one incoming message.
|
|
284
|
+
*
|
|
285
|
+
* Reserved events bypass it deliberately: the handshake and the heartbeats
|
|
286
|
+
* must survive an allow-list that does not name them, or configuring one
|
|
287
|
+
* would silently sever the connection.
|
|
288
|
+
*
|
|
289
|
+
* This lives in a method because it used to be written out at each place a
|
|
290
|
+
* message can arrive — the authenticated and unauthenticated branches of
|
|
291
|
+
* `initiate()` and of `listen()` — and `listen()`'s unauthenticated branch
|
|
292
|
+
* never got a copy. An embedded bridge configured with an allow-list and no
|
|
293
|
+
* cryptoAuth, which is exactly how de.eui runs it, therefore accepted every
|
|
294
|
+
* event name a host cared to send.
|
|
295
|
+
*/
|
|
296
|
+
acceptIncoming(_event, payload, origin) {
|
|
297
|
+
if (RESERVED_EVENTS.includes(_event))
|
|
298
|
+
return true;
|
|
299
|
+
if (this.options.allowedIncomingEvents
|
|
300
|
+
&& !this.options.allowedIncomingEvents.includes(_event)) {
|
|
301
|
+
this.fire('error', {
|
|
302
|
+
type: 'DISALLOWED_EVENT',
|
|
303
|
+
direction: 'incoming',
|
|
304
|
+
event: _event,
|
|
305
|
+
origin
|
|
306
|
+
});
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (this.options.validateIncoming
|
|
310
|
+
&& !this.options.validateIncoming(_event, payload, origin)) {
|
|
311
|
+
this.fire('error', {
|
|
312
|
+
type: 'INVALID_MESSAGE',
|
|
313
|
+
direction: 'incoming',
|
|
314
|
+
event: _event,
|
|
315
|
+
origin
|
|
316
|
+
});
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Initialize session key exchange
|
|
323
|
+
* Called after connection is established if enableSessionKeys is true
|
|
324
|
+
*/
|
|
325
|
+
async initiateSessionKeyExchange() {
|
|
326
|
+
const cfg = this.cryptoCfg();
|
|
327
|
+
if (!cfg || !cfg.enableSessionKeys)
|
|
328
|
+
return;
|
|
329
|
+
this.debug(`[${this.peer.type}] Initiating session key exchange`);
|
|
330
|
+
// Generate my session ID
|
|
331
|
+
this.mySessionId = randomHex(32);
|
|
332
|
+
// Send session ID to peer
|
|
333
|
+
this.emit('__session_key_init', { sessionId: this.mySessionId });
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Handle incoming session key initialization
|
|
337
|
+
*/
|
|
338
|
+
async handleSessionKeyInit(peerSessionId) {
|
|
339
|
+
const cfg = this.cryptoCfg();
|
|
340
|
+
if (!cfg || !cfg.enableSessionKeys)
|
|
341
|
+
return;
|
|
342
|
+
this.debug(`[${this.peer.type}] Received session key init from peer`);
|
|
343
|
+
// Generate my session ID if not already done
|
|
344
|
+
if (!this.mySessionId) {
|
|
345
|
+
this.mySessionId = randomHex(32);
|
|
346
|
+
}
|
|
347
|
+
// Store peer session ID
|
|
348
|
+
this.peer.sessionId = peerSessionId;
|
|
349
|
+
// Derive session key
|
|
350
|
+
const keyId = `key-${Date.now()}-${randomHex(8)}`;
|
|
351
|
+
const sessionKey = await this.deriveAndStoreSessionKey(keyId);
|
|
352
|
+
// Send acknowledgment with my session ID
|
|
353
|
+
this.emit('__session_key_ack', {
|
|
354
|
+
sessionId: this.mySessionId,
|
|
355
|
+
keyId: keyId
|
|
356
|
+
});
|
|
357
|
+
this.debug(`[${this.peer.type}] Session key established: ${keyId}`);
|
|
358
|
+
// Start rotation timer
|
|
359
|
+
this.startSessionKeyRotation();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Handle session key acknowledgment
|
|
363
|
+
*/
|
|
364
|
+
async handleSessionKeyAck(data) {
|
|
365
|
+
const cfg = this.cryptoCfg();
|
|
366
|
+
if (!cfg || !cfg.enableSessionKeys)
|
|
367
|
+
return;
|
|
368
|
+
this.debug(`[${this.peer.type}] Received session key ack from peer`);
|
|
369
|
+
// Store peer session ID
|
|
370
|
+
this.peer.sessionId = data.sessionId;
|
|
371
|
+
// Derive session key using the same keyId
|
|
372
|
+
await this.deriveAndStoreSessionKey(data.keyId);
|
|
373
|
+
this.debug(`[${this.peer.type}] Session key established: ${data.keyId}`);
|
|
374
|
+
// Start rotation timer
|
|
375
|
+
this.startSessionKeyRotation();
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Derive and store a session key
|
|
379
|
+
*/
|
|
380
|
+
async deriveAndStoreSessionKey(keyId) {
|
|
381
|
+
const cfg = this.cryptoCfg();
|
|
382
|
+
if (!cfg || !this.mySessionId || !this.peer.sessionId) {
|
|
383
|
+
throw new Error('Cannot derive session key: missing session IDs');
|
|
384
|
+
}
|
|
385
|
+
// Ensure consistent ordering of session IDs
|
|
386
|
+
const [id1, id2] = [this.mySessionId, this.peer.sessionId].sort();
|
|
387
|
+
const key = await deriveSessionKey(cfg.secret, id1, id2, keyId);
|
|
388
|
+
const now = Date.now();
|
|
389
|
+
const sessionKeyInfo = {
|
|
390
|
+
keyId,
|
|
391
|
+
key,
|
|
392
|
+
createdAt: now,
|
|
393
|
+
expiresAt: now + cfg.sessionKeyRotationInterval + 60000 // Grace period of 1 minute
|
|
394
|
+
};
|
|
395
|
+
// Rotate keys: current -> previous, new -> current
|
|
396
|
+
if (this.currentSessionKey) {
|
|
397
|
+
this.previousSessionKey = this.currentSessionKey;
|
|
398
|
+
}
|
|
399
|
+
this.currentSessionKey = sessionKeyInfo;
|
|
400
|
+
this.fire('session_key_established', { keyId });
|
|
401
|
+
return sessionKeyInfo;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Start session key rotation timer
|
|
405
|
+
*/
|
|
406
|
+
startSessionKeyRotation() {
|
|
407
|
+
const cfg = this.cryptoCfg();
|
|
408
|
+
if (!cfg || !cfg.enableSessionKeys)
|
|
409
|
+
return;
|
|
410
|
+
// Clear existing timer
|
|
411
|
+
if (this.sessionKeyRotationTimer) {
|
|
412
|
+
clearInterval(this.sessionKeyRotationTimer);
|
|
413
|
+
}
|
|
414
|
+
this.sessionKeyRotationTimer = setInterval(() => {
|
|
415
|
+
this.rotateSessionKey();
|
|
416
|
+
}, cfg.sessionKeyRotationInterval);
|
|
417
|
+
this.debug(`[${this.peer.type}] Session key rotation timer started (${cfg.sessionKeyRotationInterval}ms)`);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Rotate session key
|
|
421
|
+
*/
|
|
422
|
+
async rotateSessionKey() {
|
|
423
|
+
const cfg = this.cryptoCfg();
|
|
424
|
+
if (!cfg || !cfg.enableSessionKeys || !this.isConnected())
|
|
425
|
+
return;
|
|
426
|
+
this.debug(`[${this.peer.type}] Rotating session key`);
|
|
427
|
+
const newKeyId = `key-${Date.now()}-${randomHex(8)}`;
|
|
428
|
+
// Derive new key
|
|
429
|
+
const newSessionKey = await this.deriveAndStoreSessionKey(newKeyId);
|
|
430
|
+
// Set as pending until peer acknowledges
|
|
431
|
+
this.pendingSessionKey = newSessionKey;
|
|
432
|
+
// Notify peer of rotation
|
|
433
|
+
this.emit('__session_key_rotate', { keyId: newKeyId });
|
|
434
|
+
this.fire('session_key_rotating', { keyId: newKeyId });
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Handle incoming session key rotation
|
|
438
|
+
*/
|
|
439
|
+
async handleSessionKeyRotate(data) {
|
|
440
|
+
const cfg = this.cryptoCfg();
|
|
441
|
+
if (!cfg || !cfg.enableSessionKeys)
|
|
442
|
+
return;
|
|
443
|
+
this.debug(`[${this.peer.type}] Peer rotating session key to: ${data.keyId}`);
|
|
444
|
+
// Derive the same key
|
|
445
|
+
await this.deriveAndStoreSessionKey(data.keyId);
|
|
446
|
+
this.fire('session_key_rotated', { keyId: data.keyId });
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Stop session key rotation timer
|
|
450
|
+
*/
|
|
451
|
+
stopSessionKeyRotation() {
|
|
452
|
+
if (this.sessionKeyRotationTimer) {
|
|
453
|
+
clearInterval(this.sessionKeyRotationTimer);
|
|
454
|
+
this.sessionKeyRotationTimer = undefined;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async signOutgoing(messageData) {
|
|
458
|
+
const cfg = this.cryptoCfg();
|
|
459
|
+
if (!cfg)
|
|
460
|
+
return undefined;
|
|
461
|
+
const { secret, keyId } = this.getSigningSecret();
|
|
462
|
+
const ts = Date.now();
|
|
463
|
+
const nonce = randomHex(16);
|
|
464
|
+
const canonical = JSON.stringify({
|
|
465
|
+
v: messageData.v,
|
|
466
|
+
_event: messageData._event,
|
|
467
|
+
payload: messageData.payload,
|
|
468
|
+
cid: messageData.cid,
|
|
469
|
+
timestamp: messageData.timestamp,
|
|
470
|
+
size: messageData.size,
|
|
471
|
+
ts,
|
|
472
|
+
nonce
|
|
473
|
+
});
|
|
474
|
+
const sig = await hmacSha256Base64Url(secret, canonical);
|
|
475
|
+
return { alg: 'HMAC-SHA256', ts, nonce, sig, keyId };
|
|
476
|
+
}
|
|
477
|
+
async verifyIncomingAuth(data, origin) {
|
|
478
|
+
const cfg = this.cryptoCfg();
|
|
479
|
+
if (!cfg)
|
|
480
|
+
return true;
|
|
481
|
+
if (!data.auth) {
|
|
482
|
+
return !cfg.requireSigned;
|
|
483
|
+
}
|
|
484
|
+
const { alg, ts, nonce, sig, keyId } = data.auth;
|
|
485
|
+
if (alg !== 'HMAC-SHA256')
|
|
486
|
+
return false;
|
|
487
|
+
if (typeof ts !== 'number' || typeof nonce !== 'string' || typeof sig !== 'string')
|
|
488
|
+
return false;
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
if (Math.abs(now - ts) > cfg.maxSkewMs)
|
|
491
|
+
return false;
|
|
492
|
+
// Replay protection. The nonce is only recorded once the signature has been
|
|
493
|
+
// checked, further down: burning it here let an unsigned or badly signed
|
|
494
|
+
// message consume the nonce of a legitimate one still in flight.
|
|
495
|
+
if (this.seenNonces.has(nonce))
|
|
496
|
+
return false;
|
|
497
|
+
const canonical = JSON.stringify({
|
|
498
|
+
v: data.v,
|
|
499
|
+
_event: data._event,
|
|
500
|
+
payload: data.payload,
|
|
501
|
+
cid: data.cid,
|
|
502
|
+
timestamp: data.timestamp,
|
|
503
|
+
size: data.size,
|
|
504
|
+
ts,
|
|
505
|
+
nonce
|
|
506
|
+
});
|
|
507
|
+
// Try all available secrets
|
|
508
|
+
const secrets = this.getVerificationSecrets();
|
|
509
|
+
for (const { secret, keyId: secretKeyId } of secrets) {
|
|
510
|
+
// If message has keyId, only try matching secret
|
|
511
|
+
if (keyId && secretKeyId && keyId !== secretKeyId)
|
|
512
|
+
continue;
|
|
513
|
+
try {
|
|
514
|
+
const expected = await hmacSha256Base64Url(secret, canonical);
|
|
515
|
+
if (constantTimeEqual(expected, sig)) {
|
|
516
|
+
this.seenNonces.set(nonce, ts);
|
|
517
|
+
this.pruneNonces(cfg.replayWindowSize);
|
|
518
|
+
this.debug(`[${this.peer.type}] Auth verified${keyId ? ` with key: ${keyId}` : ''}`);
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
this.debug(`[${this.peer.type}] Auth verification error:`, error);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
72
528
|
debug(...args) {
|
|
73
529
|
this.options.debug && console.debug(...args);
|
|
74
530
|
}
|
|
@@ -112,6 +568,7 @@ class IOF {
|
|
|
112
568
|
return;
|
|
113
569
|
this.peer.connected = false;
|
|
114
570
|
this.stopHeartbeat();
|
|
571
|
+
this.stopSessionKeyRotation();
|
|
115
572
|
this.fire('disconnect', { reason: 'CONNECTION_LOST' });
|
|
116
573
|
this.options.autoReconnect
|
|
117
574
|
&& this.reconnectAttempts < this.maxReconnectAttempts
|
|
@@ -215,7 +672,47 @@ class IOF {
|
|
|
215
672
|
|| typeof data !== 'object'
|
|
216
673
|
|| !data.hasOwnProperty('_event'))
|
|
217
674
|
return;
|
|
218
|
-
const { _event, payload, cid, timestamp } = data;
|
|
675
|
+
const { v, _event, payload, cid, timestamp, sessionId } = data;
|
|
676
|
+
// Protocol version check
|
|
677
|
+
const messageVersion = v || 1;
|
|
678
|
+
if (messageVersion > PROTOCOL_VERSION) {
|
|
679
|
+
this.fire('error', {
|
|
680
|
+
type: 'UNSUPPORTED_VERSION',
|
|
681
|
+
received: messageVersion,
|
|
682
|
+
supported: PROTOCOL_VERSION
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
// Store peer protocol version
|
|
687
|
+
if (!this.peer.protocolVersion || this.peer.protocolVersion < messageVersion) {
|
|
688
|
+
this.peer.protocolVersion = messageVersion;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Session key control events.
|
|
692
|
+
*
|
|
693
|
+
* These are answered before authentication — they are what establishes
|
|
694
|
+
* the key authentication will use — so they are gated on the feature
|
|
695
|
+
* actually being switched on. Without that, a peer that never enabled
|
|
696
|
+
* session keys would still derive and rotate them on request, and a
|
|
697
|
+
* malformed payload would throw out of the handler.
|
|
698
|
+
*/
|
|
699
|
+
if (RESERVED_SESSION_KEY_EVENTS.includes(_event)) {
|
|
700
|
+
if (!this.cryptoCfg()?.enableSessionKeys) {
|
|
701
|
+
this.fire('error', { type: 'SESSION_KEYS_DISABLED', event: _event, origin });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!payload || typeof payload !== 'object') {
|
|
705
|
+
this.fire('error', { type: 'MALFORMED_SESSION_KEY_EVENT', event: _event, origin });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (_event === '__session_key_init')
|
|
709
|
+
this.handleSessionKeyInit(payload.sessionId);
|
|
710
|
+
else if (_event === '__session_key_ack')
|
|
711
|
+
this.handleSessionKeyAck(payload);
|
|
712
|
+
else
|
|
713
|
+
this.handleSessionKeyRotate(payload);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
219
716
|
// Handle heartbeat responses
|
|
220
717
|
if (_event === '__heartbeat_response') {
|
|
221
718
|
this.peer.lastHeartbeat = Date.now();
|
|
@@ -227,7 +724,7 @@ class IOF {
|
|
|
227
724
|
this.peer.lastHeartbeat = Date.now();
|
|
228
725
|
return;
|
|
229
726
|
}
|
|
230
|
-
this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '');
|
|
727
|
+
this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '');
|
|
231
728
|
// Handshake or availability check events
|
|
232
729
|
if (_event == 'pong') {
|
|
233
730
|
// Content Window is connected to iframe
|
|
@@ -236,33 +733,29 @@ class IOF {
|
|
|
236
733
|
this.peer.lastHeartbeat = Date.now();
|
|
237
734
|
this.startHeartbeat();
|
|
238
735
|
this.fire('connect');
|
|
736
|
+
// Initiate session key exchange if enabled
|
|
737
|
+
this.initiateSessionKeyExchange();
|
|
239
738
|
this.processMessageQueue();
|
|
240
739
|
this.debug(`[${this.peer.type}] connected`);
|
|
241
740
|
return;
|
|
242
741
|
}
|
|
243
|
-
//
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
type: '
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
this.fire('error', {
|
|
258
|
-
type: 'INVALID_MESSAGE',
|
|
259
|
-
direction: 'incoming',
|
|
260
|
-
event: _event,
|
|
261
|
-
origin
|
|
262
|
-
});
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
742
|
+
// Cryptographic authentication (optional)
|
|
743
|
+
if (this.options.cryptoAuth) {
|
|
744
|
+
this.verifyIncomingAuth(data, origin)
|
|
745
|
+
.then(ok => {
|
|
746
|
+
if (!ok) {
|
|
747
|
+
this.fire('error', { type: 'AUTH_FAILED', origin, event: _event });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (!this.acceptIncoming(_event, payload, origin))
|
|
751
|
+
return;
|
|
752
|
+
this.fire(_event, payload, cid);
|
|
753
|
+
})
|
|
754
|
+
.catch(error => this.fire('error', { type: 'AUTH_ERROR', origin, event: _event, error: String(error) }));
|
|
755
|
+
return;
|
|
265
756
|
}
|
|
757
|
+
if (!this.acceptIncoming(_event, payload, origin))
|
|
758
|
+
return;
|
|
266
759
|
// Fire available event listeners
|
|
267
760
|
this.fire(_event, payload, cid);
|
|
268
761
|
}
|
|
@@ -320,7 +813,47 @@ class IOF {
|
|
|
320
813
|
});
|
|
321
814
|
return;
|
|
322
815
|
}
|
|
323
|
-
const { _event, payload, cid, timestamp } = data;
|
|
816
|
+
const { v, _event, payload, cid, timestamp } = data;
|
|
817
|
+
// Protocol version check
|
|
818
|
+
const messageVersion = v || 1;
|
|
819
|
+
if (messageVersion > PROTOCOL_VERSION) {
|
|
820
|
+
this.fire('error', {
|
|
821
|
+
type: 'UNSUPPORTED_VERSION',
|
|
822
|
+
received: messageVersion,
|
|
823
|
+
supported: PROTOCOL_VERSION
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// Store peer protocol version
|
|
828
|
+
if (!this.peer.protocolVersion || this.peer.protocolVersion < messageVersion) {
|
|
829
|
+
this.peer.protocolVersion = messageVersion;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Session key control events.
|
|
833
|
+
*
|
|
834
|
+
* These are answered before authentication — they are what establishes
|
|
835
|
+
* the key authentication will use — so they are gated on the feature
|
|
836
|
+
* actually being switched on. Without that, a peer that never enabled
|
|
837
|
+
* session keys would still derive and rotate them on request, and a
|
|
838
|
+
* malformed payload would throw out of the handler.
|
|
839
|
+
*/
|
|
840
|
+
if (RESERVED_SESSION_KEY_EVENTS.includes(_event)) {
|
|
841
|
+
if (!this.cryptoCfg()?.enableSessionKeys) {
|
|
842
|
+
this.fire('error', { type: 'SESSION_KEYS_DISABLED', event: _event, origin });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!payload || typeof payload !== 'object') {
|
|
846
|
+
this.fire('error', { type: 'MALFORMED_SESSION_KEY_EVENT', event: _event, origin });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (_event === '__session_key_init')
|
|
850
|
+
this.handleSessionKeyInit(payload.sessionId);
|
|
851
|
+
else if (_event === '__session_key_ack')
|
|
852
|
+
this.handleSessionKeyAck(payload);
|
|
853
|
+
else
|
|
854
|
+
this.handleSessionKeyRotate(payload);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
324
857
|
// Handle heartbeat responses
|
|
325
858
|
if (_event === '__heartbeat_response') {
|
|
326
859
|
this.peer.lastHeartbeat = Date.now();
|
|
@@ -332,7 +865,7 @@ class IOF {
|
|
|
332
865
|
this.peer.lastHeartbeat = Date.now();
|
|
333
866
|
return;
|
|
334
867
|
}
|
|
335
|
-
this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '');
|
|
868
|
+
this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '');
|
|
336
869
|
// Handshake or availability check events
|
|
337
870
|
if (_event == 'ping') {
|
|
338
871
|
this.emit('pong');
|
|
@@ -342,10 +875,29 @@ class IOF {
|
|
|
342
875
|
this.peer.lastHeartbeat = Date.now();
|
|
343
876
|
this.startHeartbeat();
|
|
344
877
|
this.fire('connect');
|
|
878
|
+
// Initiate session key exchange if enabled
|
|
879
|
+
this.initiateSessionKeyExchange();
|
|
345
880
|
this.processMessageQueue();
|
|
346
881
|
this.debug(`[${this.peer.type}] connected`);
|
|
347
882
|
return;
|
|
348
883
|
}
|
|
884
|
+
// Cryptographic authentication (optional)
|
|
885
|
+
if (this.options.cryptoAuth) {
|
|
886
|
+
this.verifyIncomingAuth(data, origin)
|
|
887
|
+
.then(ok => {
|
|
888
|
+
if (!ok) {
|
|
889
|
+
this.fire('error', { type: 'AUTH_FAILED', origin, event: _event });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!this.acceptIncoming(_event, payload, origin))
|
|
893
|
+
return;
|
|
894
|
+
this.fire(_event, payload, cid);
|
|
895
|
+
})
|
|
896
|
+
.catch(error => this.fire('error', { type: 'AUTH_ERROR', origin, event: _event, error: String(error) }));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (!this.acceptIncoming(_event, payload, origin))
|
|
900
|
+
return;
|
|
349
901
|
// Fire available event listeners
|
|
350
902
|
this.fire(_event, payload, cid);
|
|
351
903
|
}
|
|
@@ -431,6 +983,7 @@ class IOF {
|
|
|
431
983
|
this.once(`${_event}--${cid}--@ack`, ({ error, args }) => ackFunction(error, ...args));
|
|
432
984
|
}
|
|
433
985
|
const messageData = {
|
|
986
|
+
v: PROTOCOL_VERSION,
|
|
434
987
|
_event,
|
|
435
988
|
payload: sanitizedPayload,
|
|
436
989
|
cid,
|
|
@@ -452,6 +1005,79 @@ class IOF {
|
|
|
452
1005
|
}
|
|
453
1006
|
return this;
|
|
454
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Send a signed message (HMAC-SHA256) when `options.cryptoAuth` is configured.
|
|
1010
|
+
* This is async because WebCrypto signing is async.
|
|
1011
|
+
*/
|
|
1012
|
+
async emitSigned(_event, payload, fn) {
|
|
1013
|
+
// Check rate limiting
|
|
1014
|
+
if (!this.checkRateLimit())
|
|
1015
|
+
return this;
|
|
1016
|
+
if (!this.options.cryptoAuth) {
|
|
1017
|
+
// If auth not enabled, fall back to normal emit behavior
|
|
1018
|
+
this.emit(_event, payload, fn);
|
|
1019
|
+
return this;
|
|
1020
|
+
}
|
|
1021
|
+
if (!this.isConnected() && !RESERVED_EVENTS.includes(_event)) {
|
|
1022
|
+
this.queueMessage(_event, payload, fn);
|
|
1023
|
+
return this;
|
|
1024
|
+
}
|
|
1025
|
+
if (!this.peer.source) {
|
|
1026
|
+
this.fire('error', { type: 'NO_CONNECTION', event: _event });
|
|
1027
|
+
return this;
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof payload == 'function') {
|
|
1030
|
+
fn = payload;
|
|
1031
|
+
payload = undefined;
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
const sanitizedPayload = payload
|
|
1035
|
+
? sanitizePayload(payload, this.options.maxMessageSize)
|
|
1036
|
+
: payload;
|
|
1037
|
+
let cid;
|
|
1038
|
+
if (typeof fn === 'function') {
|
|
1039
|
+
const ackFunction = fn;
|
|
1040
|
+
cid = ackId();
|
|
1041
|
+
this.once(`${_event}--${cid}--@ack`, ({ error, args }) => ackFunction(error, ...args));
|
|
1042
|
+
}
|
|
1043
|
+
const unsigned = {
|
|
1044
|
+
v: PROTOCOL_VERSION,
|
|
1045
|
+
_event,
|
|
1046
|
+
payload: sanitizedPayload,
|
|
1047
|
+
cid,
|
|
1048
|
+
timestamp: Date.now(),
|
|
1049
|
+
size: getMessageSize(sanitizedPayload)
|
|
1050
|
+
};
|
|
1051
|
+
const auth = await this.signOutgoing(unsigned);
|
|
1052
|
+
const messageData = { ...unsigned, auth };
|
|
1053
|
+
this.peer.source.postMessage(newObject(messageData), this.peer.origin);
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
this.debug(`[${this.peer.type}] EmitSigned error:`, error);
|
|
1057
|
+
this.fire('error', {
|
|
1058
|
+
type: 'EMIT_ERROR',
|
|
1059
|
+
event: _event,
|
|
1060
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1061
|
+
});
|
|
1062
|
+
typeof fn === 'function'
|
|
1063
|
+
&& fn(error instanceof Error ? error.message : String(error));
|
|
1064
|
+
}
|
|
1065
|
+
return this;
|
|
1066
|
+
}
|
|
1067
|
+
async emitAsyncSigned(_event, payload, timeout = 5000) {
|
|
1068
|
+
return new Promise((resolve, reject) => {
|
|
1069
|
+
const timeoutId = setTimeout(() => reject(new Error(`Event '${_event}' acknowledgment timeout after ${timeout}ms`)), timeout);
|
|
1070
|
+
this.emitSigned(_event, payload, (error, ...args) => {
|
|
1071
|
+
clearTimeout(timeoutId);
|
|
1072
|
+
error
|
|
1073
|
+
? reject(new Error(typeof error === 'string' ? error : 'Ack error'))
|
|
1074
|
+
: resolve(args.length === 0 ? undefined : args.length === 1 ? args[0] : args);
|
|
1075
|
+
}).catch(err => {
|
|
1076
|
+
clearTimeout(timeoutId);
|
|
1077
|
+
reject(err);
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
455
1081
|
on(_event, fn) {
|
|
456
1082
|
// Add Event listener
|
|
457
1083
|
if (!this.Events[_event])
|
|
@@ -539,6 +1165,7 @@ class IOF {
|
|
|
539
1165
|
this.messageListener = undefined;
|
|
540
1166
|
}
|
|
541
1167
|
this.stopHeartbeat();
|
|
1168
|
+
this.stopSessionKeyRotation();
|
|
542
1169
|
if (this.reconnectTimer) {
|
|
543
1170
|
clearTimeout(this.reconnectTimer);
|
|
544
1171
|
this.reconnectTimer = undefined;
|
|
@@ -551,9 +1178,16 @@ class IOF {
|
|
|
551
1178
|
this.peer.source = undefined;
|
|
552
1179
|
this.peer.origin = undefined;
|
|
553
1180
|
this.peer.lastHeartbeat = undefined;
|
|
1181
|
+
this.peer.protocolVersion = undefined;
|
|
1182
|
+
this.peer.sessionId = undefined;
|
|
554
1183
|
this.messageQueue = [];
|
|
555
1184
|
this.messageRateTracker = [];
|
|
556
1185
|
this.reconnectAttempts = 0;
|
|
1186
|
+
// Clear session keys
|
|
1187
|
+
this.currentSessionKey = undefined;
|
|
1188
|
+
this.pendingSessionKey = undefined;
|
|
1189
|
+
this.previousSessionKey = undefined;
|
|
1190
|
+
this.mySessionId = undefined;
|
|
557
1191
|
this.removeListeners();
|
|
558
1192
|
typeof fn == 'function' && fn();
|
|
559
1193
|
this.debug(`[${this.peer.type}] Disconnected`);
|
|
@@ -569,7 +1203,11 @@ class IOF {
|
|
|
569
1203
|
queuedMessages: this.messageQueue.length,
|
|
570
1204
|
reconnectAttempts: this.reconnectAttempts,
|
|
571
1205
|
activeListeners: Object.keys(this.Events).length,
|
|
572
|
-
messageRate: this.messageRateTracker.length
|
|
1206
|
+
messageRate: this.messageRateTracker.length,
|
|
1207
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1208
|
+
peerProtocolVersion: this.peer.protocolVersion,
|
|
1209
|
+
sessionKeyActive: !!this.currentSessionKey,
|
|
1210
|
+
sessionKeyId: this.currentSessionKey?.keyId
|
|
573
1211
|
};
|
|
574
1212
|
}
|
|
575
1213
|
// Clear message queue manually
|