iframe.io 1.1.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/src/index.ts CHANGED
@@ -3,6 +3,52 @@ export type PeerType = 'WINDOW' | 'IFRAME'
3
3
  export type AckFunction = ( error: boolean | string, ...args: any[] ) => void
4
4
  export type Listener = ( payload?: any, ack?: AckFunction ) => void
5
5
 
6
+ export type CryptoAuthOptions = {
7
+ /**
8
+ * Shared secret used for HMAC-SHA256 signing.
9
+ *
10
+ * IMPORTANT: If an attacker can execute JS in either peer, they can read the secret.
11
+ * This is for authenticity/integrity between cooperating peers, not a sandbox boundary.
12
+ */
13
+ secret: string
14
+ /**
15
+ * If true, drop any incoming message that doesn't carry valid auth.
16
+ * Default: false (accept unsigned messages)
17
+ */
18
+ requireSigned?: boolean
19
+ /**
20
+ * Maximum allowed clock skew for signed messages (ms).
21
+ * Default: 2 minutes
22
+ */
23
+ maxSkewMs?: number
24
+ /**
25
+ * Replay window size (max number of nonces kept in memory).
26
+ * Default: 500
27
+ */
28
+ replayWindowSize?: number
29
+ /**
30
+ * Enable session-derived keys for enhanced security.
31
+ * When enabled, a unique session key is derived from the master secret
32
+ * and exchanged session IDs during connection handshake.
33
+ * Recommended for long-lived connections and high-security applications.
34
+ * Default: false
35
+ */
36
+ enableSessionKeys?: boolean
37
+ /**
38
+ * How often to rotate session keys (in milliseconds).
39
+ * Only applies when enableSessionKeys is true.
40
+ * Default: 3600000 (1 hour)
41
+ */
42
+ sessionKeyRotationInterval?: number
43
+ }
44
+
45
+ export type SessionKeyInfo = {
46
+ keyId: string
47
+ key: string
48
+ createdAt: number
49
+ expiresAt: number
50
+ }
51
+
6
52
  export type Options = {
7
53
  type?: PeerType
8
54
  debug?: boolean
@@ -12,6 +58,21 @@ export type Options = {
12
58
  maxMessagesPerSecond?: number
13
59
  autoReconnect?: boolean
14
60
  messageQueueSize?: number
61
+ /**
62
+ * Optional allowlist of incoming application-level events.
63
+ * Reserved internal events (ping/pong/heartbeats) are always allowed.
64
+ */
65
+ allowedIncomingEvents?: string[]
66
+ /**
67
+ * Optional custom validator for incoming messages.
68
+ * Return false to drop a message; an 'error' event will be emitted.
69
+ */
70
+ validateIncoming?: ( event: string, payload: any, origin: string ) => boolean
71
+ /**
72
+ * Optional cryptographic message authentication (HMAC-SHA256).
73
+ * When enabled, use `emitSigned` / `emitAsyncSigned` to send signed messages.
74
+ */
75
+ cryptoAuth?: CryptoAuthOptions
15
76
  }
16
77
 
17
78
  export interface RegisteredEvents {
@@ -24,14 +85,25 @@ export type Peer = {
24
85
  origin?: string
25
86
  connected?: boolean
26
87
  lastHeartbeat?: number
88
+ protocolVersion?: number
89
+ sessionId?: string
27
90
  }
28
91
 
29
92
  export type MessageData = {
93
+ v: number // Protocol version
30
94
  _event: string
31
95
  payload: any
32
96
  cid: string | undefined
33
97
  timestamp?: number
34
98
  size?: number
99
+ auth?: {
100
+ alg: 'HMAC-SHA256'
101
+ ts: number
102
+ nonce: string
103
+ sig: string
104
+ keyId?: string // Used for session key rotation
105
+ }
106
+ sessionId?: string // Used for session key exchange
35
107
  }
36
108
 
37
109
  export type Message = {
@@ -47,6 +119,9 @@ export type QueuedMessage = {
47
119
  timestamp: number
48
120
  }
49
121
 
122
+ // Current protocol version
123
+ const PROTOCOL_VERSION = 1
124
+
50
125
  function newObject( data: object ){
51
126
  return JSON.parse( JSON.stringify( data ) )
52
127
  }
@@ -67,21 +142,157 @@ function sanitizePayload( payload: any, maxSize: number ): any {
67
142
  return JSON.parse( JSON.stringify( payload ) )
68
143
  }
69
144
 
145
+ function constantTimeEqual( a: string, b: string ): boolean {
146
+ if( a.length !== b.length ) return false
147
+ let out = 0
148
+ for( let i = 0; i < a.length; i++ ) out |= a.charCodeAt( i ) ^ b.charCodeAt( i )
149
+ return out === 0
150
+ }
151
+
152
+ function getGlobalCrypto(){
153
+ return (typeof crypto !== 'undefined'
154
+ ? crypto
155
+ : (typeof window !== 'undefined' && (window as any).crypto)
156
+ || (typeof globalThis !== 'undefined' && (globalThis as any).crypto))
157
+ }
158
+
159
+ function randomHex( bytes: number ): string {
160
+ try {
161
+ const globalCrypto = getGlobalCrypto()
162
+ if( globalCrypto && typeof globalCrypto.getRandomValues === 'function' ){
163
+ const buf = new Uint8Array( bytes )
164
+ globalCrypto.getRandomValues( buf )
165
+ return Array.from( buf ).map( b => b.toString( 16 ).padStart( 2, '0' ) ).join('')
166
+ }
167
+ }
168
+ catch{}
169
+
170
+ // Fallback (NOT cryptographically strong)
171
+ return Array.from({ length: bytes }, () => Math.floor( Math.random() * 256 ).toString( 16 ).padStart( 2, '0' ) ).join('')
172
+ }
173
+
174
+ async function hmacSha256Base64Url( secret: string, message: string ): Promise<string> {
175
+ // Browser/WebCrypto
176
+ try {
177
+ const globalCrypto = getGlobalCrypto() as any
178
+ const subtle = globalCrypto?.subtle
179
+ if( subtle && typeof subtle.importKey === 'function' ){
180
+ const enc = new TextEncoder()
181
+ const key = await subtle.importKey(
182
+ 'raw',
183
+ enc.encode( secret ),
184
+ { name: 'HMAC', hash: 'SHA-256' },
185
+ false,
186
+ ['sign']
187
+ )
188
+ const sig = await subtle.sign( 'HMAC', key, enc.encode( message ) )
189
+ const bytes = new Uint8Array( sig )
190
+ const b64 = btoa( String.fromCharCode( ...bytes ) )
191
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
192
+ }
193
+ }
194
+ catch{
195
+ // fallthrough to Node implementation
196
+ }
197
+
198
+ // Node.js (commonjs) - optional
199
+ try {
200
+ const nodeCrypto = (globalThis as any).__iof_node_crypto
201
+ || ((globalThis as any).__iof_node_crypto = (typeof (globalThis as any).require === 'function'
202
+ ? (globalThis as any).require('crypto')
203
+ : undefined))
204
+
205
+ if( !nodeCrypto ) throw new Error('node crypto unavailable')
206
+
207
+ const b64 = nodeCrypto.createHmac('sha256', secret).update( message ).digest('base64')
208
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
209
+ }
210
+ catch{
211
+ throw new Error('No crypto implementation available for HMAC-SHA256')
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Derive a session key using HKDF-like construction
217
+ * HKDF(masterSecret, salt, info) where:
218
+ * - masterSecret: the shared secret
219
+ * - salt: combined session IDs
220
+ * - info: context string
221
+ */
222
+ async function deriveSessionKey( masterSecret: string, sessionId1: string, sessionId2: string, keyId: string ): Promise<string> {
223
+ /**
224
+ * Two HMAC steps, both through `hmacSha256Base64Url`, deliberately.
225
+ *
226
+ * This used to have a WebCrypto branch alongside this one, and the two did
227
+ * not agree: the WebCrypto path fed the extract step's RAW bytes into the
228
+ * expand step and appended \x01 to the info string, while this path fed the
229
+ * base64url TEXT of those bytes and appended nothing. Same inputs, different
230
+ * keys.
231
+ *
232
+ * Peers do not have to share an implementation for that to matter, only an
233
+ * environment: `crypto.subtle` is undefined outside a secure context, so a
234
+ * WebView or iframe served over plain HTTP took one branch while an HTTPS
235
+ * host took the other. Every signed message then failed to verify — and
236
+ * because the WebCrypto branch was also entered from a silent catch, a
237
+ * single transient failure on one side desynchronised the pair for the rest
238
+ * of the connection.
239
+ *
240
+ * `hmacSha256Base64Url` already resolves WebCrypto vs Node internally and
241
+ * returns the same string either way, so deriving through it twice is both
242
+ * shorter and the only version that can agree with itself.
243
+ */
244
+ const
245
+ salt = sessionId1 + '|' + sessionId2,
246
+ // Extract: PRK = HMAC( masterSecret, salt )
247
+ prk = await hmacSha256Base64Url( masterSecret, salt ),
248
+ // Expand: OKM = HMAC( PRK, info )
249
+ info = 'iframe.io-session-key-' + keyId
250
+
251
+ return await hmacSha256Base64Url( prk, info )
252
+ }
253
+
70
254
  const ackId = () => {
255
+ // Prefer cryptographically strong randomness when available
256
+ try {
257
+ const globalCrypto = getGlobalCrypto()
258
+
259
+ if( globalCrypto && typeof globalCrypto.getRandomValues === 'function' ){
260
+ const buffer = new Uint32Array(4)
261
+ globalCrypto.getRandomValues( buffer )
262
+
263
+ const randomPart = Array.from( buffer ).map( n => n.toString( 16 ) ).join('')
264
+ return `${Date.now()}_${randomPart}`
265
+ }
266
+ }
267
+ catch{
268
+ // Fall back to Math.random-based implementation below
269
+ }
270
+
71
271
  const
72
272
  rmin = 100000,
73
273
  rmax = 999999,
74
- timestamp = Date.now(),
75
- random = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin )
274
+ timestampFallback = Date.now(),
275
+ randomFallback = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin )
76
276
 
77
- return `${timestamp}_${random}`
277
+ return `${timestampFallback}_${randomFallback}`
78
278
  }
79
279
 
280
+ // Answered before authentication, so they are gated separately — see the
281
+ // handlers in initiate() and listen().
282
+ const RESERVED_SESSION_KEY_EVENTS = [
283
+ '__session_key_init',
284
+ '__session_key_rotate',
285
+ '__session_key_ack'
286
+ ]
287
+
80
288
  const RESERVED_EVENTS = [
81
289
  'ping',
82
290
  'pong',
83
291
  '__heartbeat',
84
- '__heartbeat_response'
292
+ '__heartbeat_response',
293
+ '__session_key_init',
294
+ '__session_key_rotate',
295
+ '__session_key_ack'
85
296
  ]
86
297
 
87
298
  export default class IOF {
@@ -89,12 +300,20 @@ export default class IOF {
89
300
  peer: Peer
90
301
  options: Options
91
302
  private messageListener?: ( event: MessageEvent ) => void
92
- private heartbeatTimer?: NodeJS.Timeout
93
- private reconnectTimer?: NodeJS.Timeout
303
+ private heartbeatTimer?: number
304
+ private reconnectTimer?: number
305
+ private sessionKeyRotationTimer?: number
94
306
  private messageQueue: QueuedMessage[] = []
95
307
  private messageRateTracker: number[] = []
96
308
  private reconnectAttempts: number = 0
97
309
  private maxReconnectAttempts: number = 5
310
+ private seenNonces: Map<string, number> = new Map()
311
+
312
+ // Session key management
313
+ private currentSessionKey?: SessionKeyInfo
314
+ private pendingSessionKey?: SessionKeyInfo
315
+ private previousSessionKey?: SessionKeyInfo
316
+ private mySessionId?: string
98
317
 
99
318
  constructor( options: Options = {} ){
100
319
  if( options && typeof options !== 'object' )
@@ -117,6 +336,414 @@ export default class IOF {
117
336
  this.peer.type = options.type.toUpperCase() as PeerType
118
337
  }
119
338
 
339
+ private cryptoCfg(){
340
+ if( !this.options.cryptoAuth ) return undefined
341
+ return {
342
+ secret: this.options.cryptoAuth.secret,
343
+ requireSigned: !!this.options.cryptoAuth.requireSigned,
344
+ maxSkewMs: this.options.cryptoAuth.maxSkewMs ?? 2 * 60 * 1000,
345
+ replayWindowSize: this.options.cryptoAuth.replayWindowSize ?? 500,
346
+ enableSessionKeys: !!this.options.cryptoAuth.enableSessionKeys,
347
+ sessionKeyRotationInterval: this.options.cryptoAuth.sessionKeyRotationInterval ?? 3600000 // 1 hour
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Forget nonces that can no longer be replayed, and only then cap the map.
353
+ *
354
+ * Age is what actually decides replayability: a captured message is refused
355
+ * once its `ts` falls outside maxSkewMs, so a nonce is only worth keeping
356
+ * that long. Pruning purely by count — as this did — made the two defaults
357
+ * contradict each other: 500 remembered nonces at the default 100 messages a
358
+ * second is five seconds of history guarding a two-minute acceptance window,
359
+ * so anything captured could simply be replayed after five seconds.
360
+ *
361
+ * The count cap stays as a memory bound. Reaching it means the rate limiter
362
+ * is admitting more traffic than the window can remember, so it is reported
363
+ * rather than applied in silence.
364
+ */
365
+ private pruneNonces( maxSize: number ){
366
+ const cutoff = Date.now() - ( this.cryptoCfg()?.maxSkewMs ?? 2 * 60 * 1000 )
367
+
368
+ for( const [ nonce, ts ] of this.seenNonces )
369
+ if( ts < cutoff ) this.seenNonces.delete( nonce )
370
+
371
+ if( this.seenNonces.size <= maxSize ) return
372
+
373
+ this.fire('error', {
374
+ type: 'REPLAY_WINDOW_EXCEEDED',
375
+ remembered: this.seenNonces.size,
376
+ maxSize
377
+ })
378
+
379
+ // Oldest first — Map iterates in insertion order, and nonces are inserted
380
+ // as they arrive.
381
+ const toRemove = this.seenNonces.size - maxSize
382
+ let i = 0
383
+ for( const key of this.seenNonces.keys() ){
384
+ this.seenNonces.delete( key )
385
+ if( ++i >= toRemove ) break
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Get the appropriate secret for signing messages
391
+ * Uses session key if available, otherwise falls back to master secret
392
+ */
393
+ private getSigningSecret(): { secret: string, keyId?: string } {
394
+ const cfg = this.cryptoCfg()
395
+ if( !cfg ) return { secret: '' }
396
+
397
+ // Use session key if enabled and available
398
+ if( cfg.enableSessionKeys && this.currentSessionKey ){
399
+ return {
400
+ secret: this.currentSessionKey.key,
401
+ keyId: this.currentSessionKey.keyId
402
+ }
403
+ }
404
+
405
+ // Fall back to master secret
406
+ return { secret: cfg.secret }
407
+ }
408
+
409
+ /**
410
+ * Get the appropriate secret for verifying incoming messages
411
+ * Tries current key, then pending, then previous, then master
412
+ */
413
+ private getVerificationSecrets(): Array<{ secret: string, keyId?: string }> {
414
+ const cfg = this.cryptoCfg()
415
+ if( !cfg ) return []
416
+
417
+ const secrets: Array<{ secret: string, keyId?: string }> = []
418
+
419
+ if( cfg.enableSessionKeys ){
420
+ // Try current session key first
421
+ if( this.currentSessionKey ){
422
+ secrets.push({
423
+ secret: this.currentSessionKey.key,
424
+ keyId: this.currentSessionKey.keyId
425
+ })
426
+ }
427
+
428
+ // Try pending key during rotation
429
+ if( this.pendingSessionKey ){
430
+ secrets.push({
431
+ secret: this.pendingSessionKey.key,
432
+ keyId: this.pendingSessionKey.keyId
433
+ })
434
+ }
435
+
436
+ // Try previous key for grace period
437
+ if( this.previousSessionKey ){
438
+ const now = Date.now()
439
+ if( now < this.previousSessionKey.expiresAt ){
440
+ secrets.push({
441
+ secret: this.previousSessionKey.key,
442
+ keyId: this.previousSessionKey.keyId
443
+ })
444
+ }
445
+ }
446
+ }
447
+
448
+ // Always try master secret as fallback
449
+ secrets.push({ secret: cfg.secret })
450
+
451
+ return secrets
452
+ }
453
+
454
+ /**
455
+ * Application-level admission check for one incoming message.
456
+ *
457
+ * Reserved events bypass it deliberately: the handshake and the heartbeats
458
+ * must survive an allow-list that does not name them, or configuring one
459
+ * would silently sever the connection.
460
+ *
461
+ * This lives in a method because it used to be written out at each place a
462
+ * message can arrive — the authenticated and unauthenticated branches of
463
+ * `initiate()` and of `listen()` — and `listen()`'s unauthenticated branch
464
+ * never got a copy. An embedded bridge configured with an allow-list and no
465
+ * cryptoAuth, which is exactly how de.eui runs it, therefore accepted every
466
+ * event name a host cared to send.
467
+ */
468
+ private acceptIncoming( _event: string, payload: any, origin: string ): boolean {
469
+ if( RESERVED_EVENTS.includes( _event ) ) return true
470
+
471
+ if( this.options.allowedIncomingEvents
472
+ && !this.options.allowedIncomingEvents.includes( _event ) ){
473
+ this.fire('error', {
474
+ type: 'DISALLOWED_EVENT',
475
+ direction: 'incoming',
476
+ event: _event,
477
+ origin
478
+ })
479
+ return false
480
+ }
481
+
482
+ if( this.options.validateIncoming
483
+ && !this.options.validateIncoming( _event, payload, origin ) ){
484
+ this.fire('error', {
485
+ type: 'INVALID_MESSAGE',
486
+ direction: 'incoming',
487
+ event: _event,
488
+ origin
489
+ })
490
+ return false
491
+ }
492
+
493
+ return true
494
+ }
495
+
496
+ /**
497
+ * Initialize session key exchange
498
+ * Called after connection is established if enableSessionKeys is true
499
+ */
500
+ private async initiateSessionKeyExchange(){
501
+ const cfg = this.cryptoCfg()
502
+ if( !cfg || !cfg.enableSessionKeys ) return
503
+
504
+ this.debug(`[${this.peer.type}] Initiating session key exchange`)
505
+
506
+ // Generate my session ID
507
+ this.mySessionId = randomHex( 32 )
508
+
509
+ // Send session ID to peer
510
+ this.emit('__session_key_init', { sessionId: this.mySessionId })
511
+ }
512
+
513
+ /**
514
+ * Handle incoming session key initialization
515
+ */
516
+ private async handleSessionKeyInit( peerSessionId: string ){
517
+ const cfg = this.cryptoCfg()
518
+ if( !cfg || !cfg.enableSessionKeys ) return
519
+
520
+ this.debug(`[${this.peer.type}] Received session key init from peer`)
521
+
522
+ // Generate my session ID if not already done
523
+ if( !this.mySessionId ){
524
+ this.mySessionId = randomHex( 32 )
525
+ }
526
+
527
+ // Store peer session ID
528
+ this.peer.sessionId = peerSessionId
529
+
530
+ // Derive session key
531
+ const keyId = `key-${Date.now()}-${randomHex(8)}`
532
+ const sessionKey = await this.deriveAndStoreSessionKey( keyId )
533
+
534
+ // Send acknowledgment with my session ID
535
+ this.emit('__session_key_ack', {
536
+ sessionId: this.mySessionId,
537
+ keyId: keyId
538
+ })
539
+
540
+ this.debug(`[${this.peer.type}] Session key established: ${keyId}`)
541
+
542
+ // Start rotation timer
543
+ this.startSessionKeyRotation()
544
+ }
545
+
546
+ /**
547
+ * Handle session key acknowledgment
548
+ */
549
+ private async handleSessionKeyAck( data: { sessionId: string, keyId: string } ){
550
+ const cfg = this.cryptoCfg()
551
+ if( !cfg || !cfg.enableSessionKeys ) return
552
+
553
+ this.debug(`[${this.peer.type}] Received session key ack from peer`)
554
+
555
+ // Store peer session ID
556
+ this.peer.sessionId = data.sessionId
557
+
558
+ // Derive session key using the same keyId
559
+ await this.deriveAndStoreSessionKey( data.keyId )
560
+
561
+ this.debug(`[${this.peer.type}] Session key established: ${data.keyId}`)
562
+
563
+ // Start rotation timer
564
+ this.startSessionKeyRotation()
565
+ }
566
+
567
+ /**
568
+ * Derive and store a session key
569
+ */
570
+ private async deriveAndStoreSessionKey( keyId: string ): Promise<SessionKeyInfo> {
571
+ const cfg = this.cryptoCfg()
572
+ if( !cfg || !this.mySessionId || !this.peer.sessionId ){
573
+ throw new Error('Cannot derive session key: missing session IDs')
574
+ }
575
+
576
+ // Ensure consistent ordering of session IDs
577
+ const [id1, id2] = [this.mySessionId, this.peer.sessionId].sort()
578
+
579
+ const key = await deriveSessionKey( cfg.secret, id1, id2, keyId )
580
+
581
+ const now = Date.now()
582
+ const sessionKeyInfo: SessionKeyInfo = {
583
+ keyId,
584
+ key,
585
+ createdAt: now,
586
+ expiresAt: now + cfg.sessionKeyRotationInterval + 60000 // Grace period of 1 minute
587
+ }
588
+
589
+ // Rotate keys: current -> previous, new -> current
590
+ if( this.currentSessionKey ){
591
+ this.previousSessionKey = this.currentSessionKey
592
+ }
593
+
594
+ this.currentSessionKey = sessionKeyInfo
595
+
596
+ this.fire('session_key_established', { keyId })
597
+
598
+ return sessionKeyInfo
599
+ }
600
+
601
+ /**
602
+ * Start session key rotation timer
603
+ */
604
+ private startSessionKeyRotation(){
605
+ const cfg = this.cryptoCfg()
606
+ if( !cfg || !cfg.enableSessionKeys ) return
607
+
608
+ // Clear existing timer
609
+ if( this.sessionKeyRotationTimer ){
610
+ clearInterval( this.sessionKeyRotationTimer )
611
+ }
612
+
613
+ this.sessionKeyRotationTimer = setInterval(() => {
614
+ this.rotateSessionKey()
615
+ }, cfg.sessionKeyRotationInterval )
616
+
617
+ this.debug(`[${this.peer.type}] Session key rotation timer started (${cfg.sessionKeyRotationInterval}ms)`)
618
+ }
619
+
620
+ /**
621
+ * Rotate session key
622
+ */
623
+ private async rotateSessionKey(){
624
+ const cfg = this.cryptoCfg()
625
+ if( !cfg || !cfg.enableSessionKeys || !this.isConnected() ) return
626
+
627
+ this.debug(`[${this.peer.type}] Rotating session key`)
628
+
629
+ const newKeyId = `key-${Date.now()}-${randomHex(8)}`
630
+
631
+ // Derive new key
632
+ const newSessionKey = await this.deriveAndStoreSessionKey( newKeyId )
633
+
634
+ // Set as pending until peer acknowledges
635
+ this.pendingSessionKey = newSessionKey
636
+
637
+ // Notify peer of rotation
638
+ this.emit('__session_key_rotate', { keyId: newKeyId })
639
+
640
+ this.fire('session_key_rotating', { keyId: newKeyId })
641
+ }
642
+
643
+ /**
644
+ * Handle incoming session key rotation
645
+ */
646
+ private async handleSessionKeyRotate( data: { keyId: string } ){
647
+ const cfg = this.cryptoCfg()
648
+ if( !cfg || !cfg.enableSessionKeys ) return
649
+
650
+ this.debug(`[${this.peer.type}] Peer rotating session key to: ${data.keyId}`)
651
+
652
+ // Derive the same key
653
+ await this.deriveAndStoreSessionKey( data.keyId )
654
+
655
+ this.fire('session_key_rotated', { keyId: data.keyId })
656
+ }
657
+
658
+ /**
659
+ * Stop session key rotation timer
660
+ */
661
+ private stopSessionKeyRotation(){
662
+ if( this.sessionKeyRotationTimer ){
663
+ clearInterval( this.sessionKeyRotationTimer )
664
+ this.sessionKeyRotationTimer = undefined
665
+ }
666
+ }
667
+
668
+ private async signOutgoing( messageData: Omit<MessageData, 'auth'> ): Promise<MessageData['auth']> {
669
+ const cfg = this.cryptoCfg()
670
+ if( !cfg ) return undefined
671
+
672
+ const { secret, keyId } = this.getSigningSecret()
673
+
674
+ const ts = Date.now()
675
+ const nonce = randomHex( 16 )
676
+ const canonical = JSON.stringify({
677
+ v: messageData.v,
678
+ _event: messageData._event,
679
+ payload: messageData.payload,
680
+ cid: messageData.cid,
681
+ timestamp: messageData.timestamp,
682
+ size: messageData.size,
683
+ ts,
684
+ nonce
685
+ })
686
+ const sig = await hmacSha256Base64Url( secret, canonical )
687
+
688
+ return { alg: 'HMAC-SHA256', ts, nonce, sig, keyId }
689
+ }
690
+
691
+ private async verifyIncomingAuth( data: MessageData, origin: string ): Promise<boolean> {
692
+ const cfg = this.cryptoCfg()
693
+ if( !cfg ) return true
694
+
695
+ if( !data.auth ){
696
+ return !cfg.requireSigned
697
+ }
698
+
699
+ const { alg, ts, nonce, sig, keyId } = data.auth
700
+ if( alg !== 'HMAC-SHA256' ) return false
701
+ if( typeof ts !== 'number' || typeof nonce !== 'string' || typeof sig !== 'string' ) return false
702
+
703
+ const now = Date.now()
704
+ if( Math.abs( now - ts ) > cfg.maxSkewMs ) return false
705
+
706
+ // Replay protection. The nonce is only recorded once the signature has been
707
+ // checked, further down: burning it here let an unsigned or badly signed
708
+ // message consume the nonce of a legitimate one still in flight.
709
+ if( this.seenNonces.has( nonce ) ) return false
710
+
711
+ const canonical = JSON.stringify({
712
+ v: data.v,
713
+ _event: data._event,
714
+ payload: data.payload,
715
+ cid: data.cid,
716
+ timestamp: data.timestamp,
717
+ size: data.size,
718
+ ts,
719
+ nonce
720
+ })
721
+
722
+ // Try all available secrets
723
+ const secrets = this.getVerificationSecrets()
724
+
725
+ for( const { secret, keyId: secretKeyId } of secrets ){
726
+ // If message has keyId, only try matching secret
727
+ if( keyId && secretKeyId && keyId !== secretKeyId ) continue
728
+
729
+ try {
730
+ const expected = await hmacSha256Base64Url( secret, canonical )
731
+ if( constantTimeEqual( expected, sig ) ){
732
+ this.seenNonces.set( nonce, ts )
733
+ this.pruneNonces( cfg.replayWindowSize )
734
+
735
+ this.debug(`[${this.peer.type}] Auth verified${keyId ? ` with key: ${keyId}` : ''}`)
736
+ return true
737
+ }
738
+ }
739
+ catch( error ){
740
+ this.debug(`[${this.peer.type}] Auth verification error:`, error)
741
+ }
742
+ }
743
+
744
+ return false
745
+ }
746
+
120
747
  debug( ...args: any[] ){
121
748
  this.options.debug && console.debug( ...args )
122
749
  }
@@ -165,6 +792,7 @@ export default class IOF {
165
792
 
166
793
  this.peer.connected = false
167
794
  this.stopHeartbeat()
795
+ this.stopSessionKeyRotation()
168
796
  this.fire('disconnect', { reason: 'CONNECTION_LOST' })
169
797
 
170
798
  this.options.autoReconnect
@@ -289,7 +917,50 @@ export default class IOF {
289
917
  || typeof data !== 'object'
290
918
  || !data.hasOwnProperty('_event') ) return
291
919
 
292
- const { _event, payload, cid, timestamp } = data as Message['data']
920
+ const { v, _event, payload, cid, timestamp, sessionId } = data as Message['data']
921
+
922
+ // Protocol version check
923
+ const messageVersion = v || 1
924
+ if( messageVersion > PROTOCOL_VERSION ){
925
+ this.fire('error', {
926
+ type: 'UNSUPPORTED_VERSION',
927
+ received: messageVersion,
928
+ supported: PROTOCOL_VERSION
929
+ })
930
+ return
931
+ }
932
+
933
+ // Store peer protocol version
934
+ if( !this.peer.protocolVersion || this.peer.protocolVersion < messageVersion ){
935
+ this.peer.protocolVersion = messageVersion
936
+ }
937
+
938
+ /**
939
+ * Session key control events.
940
+ *
941
+ * These are answered before authentication — they are what establishes
942
+ * the key authentication will use — so they are gated on the feature
943
+ * actually being switched on. Without that, a peer that never enabled
944
+ * session keys would still derive and rotate them on request, and a
945
+ * malformed payload would throw out of the handler.
946
+ */
947
+ if( RESERVED_SESSION_KEY_EVENTS.includes( _event ) ){
948
+ if( !this.cryptoCfg()?.enableSessionKeys ){
949
+ this.fire('error', { type: 'SESSION_KEYS_DISABLED', event: _event, origin })
950
+ return
951
+ }
952
+
953
+ if( !payload || typeof payload !== 'object' ){
954
+ this.fire('error', { type: 'MALFORMED_SESSION_KEY_EVENT', event: _event, origin })
955
+ return
956
+ }
957
+
958
+ if( _event === '__session_key_init' ) this.handleSessionKeyInit( payload.sessionId )
959
+ else if( _event === '__session_key_ack' ) this.handleSessionKeyAck( payload )
960
+ else this.handleSessionKeyRotate( payload )
961
+
962
+ return
963
+ }
293
964
 
294
965
  // Handle heartbeat responses
295
966
  if( _event === '__heartbeat_response' ){
@@ -304,7 +975,7 @@ export default class IOF {
304
975
  return
305
976
  }
306
977
 
307
- this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '')
978
+ this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '')
308
979
 
309
980
  // Handshake or availability check events
310
981
  if( _event == 'pong' ){
@@ -315,12 +986,35 @@ export default class IOF {
315
986
 
316
987
  this.startHeartbeat()
317
988
  this.fire('connect')
989
+
990
+ // Initiate session key exchange if enabled
991
+ this.initiateSessionKeyExchange()
992
+
318
993
  this.processMessageQueue()
319
994
  this.debug(`[${this.peer.type}] connected`)
320
995
 
321
996
  return
322
997
  }
323
998
 
999
+ // Cryptographic authentication (optional)
1000
+ if( this.options.cryptoAuth ){
1001
+ this.verifyIncomingAuth( data as MessageData, origin )
1002
+ .then( ok => {
1003
+ if( !ok ){
1004
+ this.fire('error', { type: 'AUTH_FAILED', origin, event: _event })
1005
+ return
1006
+ }
1007
+
1008
+ if( !this.acceptIncoming( _event, payload, origin ) ) return
1009
+
1010
+ this.fire( _event, payload, cid )
1011
+ })
1012
+ .catch( error => this.fire('error', { type: 'AUTH_ERROR', origin, event: _event, error: String(error) }) )
1013
+ return
1014
+ }
1015
+
1016
+ if( !this.acceptIncoming( _event, payload, origin ) ) return
1017
+
324
1018
  // Fire available event listeners
325
1019
  this.fire( _event, payload, cid )
326
1020
  }
@@ -388,7 +1082,50 @@ export default class IOF {
388
1082
  return
389
1083
  }
390
1084
 
391
- const { _event, payload, cid, timestamp } = data
1085
+ const { v, _event, payload, cid, timestamp } = data
1086
+
1087
+ // Protocol version check
1088
+ const messageVersion = v || 1
1089
+ if( messageVersion > PROTOCOL_VERSION ){
1090
+ this.fire('error', {
1091
+ type: 'UNSUPPORTED_VERSION',
1092
+ received: messageVersion,
1093
+ supported: PROTOCOL_VERSION
1094
+ })
1095
+ return
1096
+ }
1097
+
1098
+ // Store peer protocol version
1099
+ if( !this.peer.protocolVersion || this.peer.protocolVersion < messageVersion ){
1100
+ this.peer.protocolVersion = messageVersion
1101
+ }
1102
+
1103
+ /**
1104
+ * Session key control events.
1105
+ *
1106
+ * These are answered before authentication — they are what establishes
1107
+ * the key authentication will use — so they are gated on the feature
1108
+ * actually being switched on. Without that, a peer that never enabled
1109
+ * session keys would still derive and rotate them on request, and a
1110
+ * malformed payload would throw out of the handler.
1111
+ */
1112
+ if( RESERVED_SESSION_KEY_EVENTS.includes( _event ) ){
1113
+ if( !this.cryptoCfg()?.enableSessionKeys ){
1114
+ this.fire('error', { type: 'SESSION_KEYS_DISABLED', event: _event, origin })
1115
+ return
1116
+ }
1117
+
1118
+ if( !payload || typeof payload !== 'object' ){
1119
+ this.fire('error', { type: 'MALFORMED_SESSION_KEY_EVENT', event: _event, origin })
1120
+ return
1121
+ }
1122
+
1123
+ if( _event === '__session_key_init' ) this.handleSessionKeyInit( payload.sessionId )
1124
+ else if( _event === '__session_key_ack' ) this.handleSessionKeyAck( payload )
1125
+ else this.handleSessionKeyRotate( payload )
1126
+
1127
+ return
1128
+ }
392
1129
 
393
1130
  // Handle heartbeat responses
394
1131
  if( _event === '__heartbeat_response' ){
@@ -403,7 +1140,7 @@ export default class IOF {
403
1140
  return
404
1141
  }
405
1142
 
406
- this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '')
1143
+ this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '')
407
1144
 
408
1145
  // Handshake or availability check events
409
1146
  if( _event == 'ping' ){
@@ -415,12 +1152,35 @@ export default class IOF {
415
1152
  this.peer.lastHeartbeat = Date.now()
416
1153
  this.startHeartbeat()
417
1154
  this.fire('connect')
1155
+
1156
+ // Initiate session key exchange if enabled
1157
+ this.initiateSessionKeyExchange()
1158
+
418
1159
  this.processMessageQueue()
419
1160
 
420
1161
  this.debug(`[${this.peer.type}] connected`)
421
1162
  return
422
1163
  }
423
1164
 
1165
+ // Cryptographic authentication (optional)
1166
+ if( this.options.cryptoAuth ){
1167
+ this.verifyIncomingAuth( data as MessageData, origin )
1168
+ .then( ok => {
1169
+ if( !ok ){
1170
+ this.fire('error', { type: 'AUTH_FAILED', origin, event: _event })
1171
+ return
1172
+ }
1173
+
1174
+ if( !this.acceptIncoming( _event, payload, origin ) ) return
1175
+
1176
+ this.fire( _event, payload, cid )
1177
+ })
1178
+ .catch( error => this.fire('error', { type: 'AUTH_ERROR', origin, event: _event, error: String(error) }) )
1179
+ return
1180
+ }
1181
+
1182
+ if( !this.acceptIncoming( _event, payload, origin ) ) return
1183
+
424
1184
  // Fire available event listeners
425
1185
  this.fire( _event, payload, cid )
426
1186
  }
@@ -516,6 +1276,7 @@ export default class IOF {
516
1276
  }
517
1277
 
518
1278
  const messageData = {
1279
+ v: PROTOCOL_VERSION,
519
1280
  _event,
520
1281
  payload: sanitizedPayload,
521
1282
  cid,
@@ -541,6 +1302,91 @@ export default class IOF {
541
1302
  return this
542
1303
  }
543
1304
 
1305
+ /**
1306
+ * Send a signed message (HMAC-SHA256) when `options.cryptoAuth` is configured.
1307
+ * This is async because WebCrypto signing is async.
1308
+ */
1309
+ async emitSigned<T = any>( _event: string, payload?: T | AckFunction, fn?: AckFunction ): Promise<this> {
1310
+ // Check rate limiting
1311
+ if( !this.checkRateLimit() ) return this
1312
+
1313
+ if( !this.options.cryptoAuth ){
1314
+ // If auth not enabled, fall back to normal emit behavior
1315
+ this.emit( _event as any, payload as any, fn )
1316
+ return this
1317
+ }
1318
+
1319
+ if( !this.isConnected() && !RESERVED_EVENTS.includes(_event) ){
1320
+ this.queueMessage( _event, payload, fn )
1321
+ return this
1322
+ }
1323
+
1324
+ if( !this.peer.source ){
1325
+ this.fire('error', { type: 'NO_CONNECTION', event: _event })
1326
+ return this
1327
+ }
1328
+
1329
+ if( typeof payload == 'function' ){
1330
+ fn = payload as AckFunction
1331
+ payload = undefined
1332
+ }
1333
+
1334
+ try {
1335
+ const sanitizedPayload = payload
1336
+ ? sanitizePayload( payload, this.options.maxMessageSize! )
1337
+ : payload
1338
+
1339
+ let cid: string | undefined
1340
+ if( typeof fn === 'function' ){
1341
+ const ackFunction = fn
1342
+ cid = ackId()
1343
+ this.once(`${_event}--${cid}--@ack`, ({ error, args }) => ackFunction( error, ...args ))
1344
+ }
1345
+
1346
+ const unsigned: Omit<MessageData, 'auth'> = {
1347
+ v: PROTOCOL_VERSION,
1348
+ _event,
1349
+ payload: sanitizedPayload,
1350
+ cid,
1351
+ timestamp: Date.now(),
1352
+ size: getMessageSize( sanitizedPayload )
1353
+ }
1354
+
1355
+ const auth = await this.signOutgoing( unsigned )
1356
+ const messageData: MessageData = { ...unsigned, auth }
1357
+
1358
+ this.peer.source.postMessage( newObject( messageData ), this.peer.origin as string )
1359
+ }
1360
+ catch( error ){
1361
+ this.debug(`[${this.peer.type}] EmitSigned error:`, error)
1362
+ this.fire('error', {
1363
+ type: 'EMIT_ERROR',
1364
+ event: _event,
1365
+ error: error instanceof Error ? error.message : String(error)
1366
+ })
1367
+ typeof fn === 'function'
1368
+ && fn( error instanceof Error ? error.message : String(error) )
1369
+ }
1370
+
1371
+ return this
1372
+ }
1373
+
1374
+ async emitAsyncSigned<T = any, R = any>( _event: string, payload?: T, timeout: number = 5000 ): Promise<R> {
1375
+ return new Promise(( resolve, reject ) => {
1376
+ const timeoutId = setTimeout(() => reject( new Error(`Event '${_event}' acknowledgment timeout after ${timeout}ms`) ), timeout )
1377
+
1378
+ this.emitSigned( _event, payload as any, ( error, ...args ) => {
1379
+ clearTimeout( timeoutId )
1380
+ error
1381
+ ? reject( new Error( typeof error === 'string' ? error : 'Ack error' ) )
1382
+ : resolve( args.length === 0 ? undefined : args.length === 1 ? args[0] : args as any )
1383
+ }).catch( err => {
1384
+ clearTimeout( timeoutId )
1385
+ reject( err )
1386
+ })
1387
+ })
1388
+ }
1389
+
544
1390
  on( _event: string, fn: Listener ){
545
1391
  // Add Event listener
546
1392
  if( !this.Events[_event] ) this.Events[_event] = []
@@ -644,6 +1490,7 @@ export default class IOF {
644
1490
  }
645
1491
 
646
1492
  this.stopHeartbeat()
1493
+ this.stopSessionKeyRotation()
647
1494
 
648
1495
  if( this.reconnectTimer ){
649
1496
  clearTimeout( this.reconnectTimer )
@@ -659,9 +1506,18 @@ export default class IOF {
659
1506
  this.peer.source = undefined
660
1507
  this.peer.origin = undefined
661
1508
  this.peer.lastHeartbeat = undefined
1509
+ this.peer.protocolVersion = undefined
1510
+ this.peer.sessionId = undefined
1511
+
662
1512
  this.messageQueue = []
663
1513
  this.messageRateTracker = []
664
1514
  this.reconnectAttempts = 0
1515
+
1516
+ // Clear session keys
1517
+ this.currentSessionKey = undefined
1518
+ this.pendingSessionKey = undefined
1519
+ this.previousSessionKey = undefined
1520
+ this.mySessionId = undefined
665
1521
 
666
1522
  this.removeListeners()
667
1523
 
@@ -681,7 +1537,11 @@ export default class IOF {
681
1537
  queuedMessages: this.messageQueue.length,
682
1538
  reconnectAttempts: this.reconnectAttempts,
683
1539
  activeListeners: Object.keys( this.Events ).length,
684
- messageRate: this.messageRateTracker.length
1540
+ messageRate: this.messageRateTracker.length,
1541
+ protocolVersion: PROTOCOL_VERSION,
1542
+ peerProtocolVersion: this.peer.protocolVersion,
1543
+ sessionKeyActive: !!this.currentSessionKey,
1544
+ sessionKeyId: this.currentSessionKey?.keyId
685
1545
  }
686
1546
  }
687
1547
 
@@ -693,4 +1553,4 @@ export default class IOF {
693
1553
  this.debug(`[${this.peer.type}] Cleared ${queueSize} queued messages`)
694
1554
  return this
695
1555
  }
696
- }
1556
+ }