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/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 = (typeof crypto !== 'undefined'
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
- // Optional application-level incoming validation (non-reserved events only)
244
- if (!RESERVED_EVENTS.includes(_event)) {
245
- if (this.options.allowedIncomingEvents
246
- && !this.options.allowedIncomingEvents.includes(_event)) {
247
- this.fire('error', {
248
- type: 'DISALLOWED_EVENT',
249
- direction: 'incoming',
250
- event: _event,
251
- origin
252
- });
253
- return;
254
- }
255
- if (this.options.validateIncoming
256
- && !this.options.validateIncoming(_event, payload, origin)) {
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