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/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
@@ -22,6 +68,11 @@ export type Options = {
22
68
  * Return false to drop a message; an 'error' event will be emitted.
23
69
  */
24
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
25
76
  }
26
77
 
27
78
  export interface RegisteredEvents {
@@ -34,14 +85,25 @@ export type Peer = {
34
85
  origin?: string
35
86
  connected?: boolean
36
87
  lastHeartbeat?: number
88
+ protocolVersion?: number
89
+ sessionId?: string
37
90
  }
38
91
 
39
92
  export type MessageData = {
93
+ v: number // Protocol version
40
94
  _event: string
41
95
  payload: any
42
96
  cid: string | undefined
43
97
  timestamp?: number
44
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
45
107
  }
46
108
 
47
109
  export type Message = {
@@ -57,6 +119,9 @@ export type QueuedMessage = {
57
119
  timestamp: number
58
120
  }
59
121
 
122
+ // Current protocol version
123
+ const PROTOCOL_VERSION = 1
124
+
60
125
  function newObject( data: object ){
61
126
  return JSON.parse( JSON.stringify( data ) )
62
127
  }
@@ -77,13 +142,119 @@ function sanitizePayload( payload: any, maxSize: number ): any {
77
142
  return JSON.parse( JSON.stringify( payload ) )
78
143
  }
79
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
+
80
254
  const ackId = () => {
81
255
  // Prefer cryptographically strong randomness when available
82
256
  try {
83
- const globalCrypto = (typeof crypto !== 'undefined'
84
- ? crypto
85
- : (typeof window !== 'undefined' && (window as any).crypto)
86
- || (typeof globalThis !== 'undefined' && (globalThis as any).crypto))
257
+ const globalCrypto = getGlobalCrypto()
87
258
 
88
259
  if( globalCrypto && typeof globalCrypto.getRandomValues === 'function' ){
89
260
  const buffer = new Uint32Array(4)
@@ -106,11 +277,22 @@ const ackId = () => {
106
277
  return `${timestampFallback}_${randomFallback}`
107
278
  }
108
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
+
109
288
  const RESERVED_EVENTS = [
110
289
  'ping',
111
290
  'pong',
112
291
  '__heartbeat',
113
- '__heartbeat_response'
292
+ '__heartbeat_response',
293
+ '__session_key_init',
294
+ '__session_key_rotate',
295
+ '__session_key_ack'
114
296
  ]
115
297
 
116
298
  export default class IOF {
@@ -120,10 +302,18 @@ export default class IOF {
120
302
  private messageListener?: ( event: MessageEvent ) => void
121
303
  private heartbeatTimer?: number
122
304
  private reconnectTimer?: number
305
+ private sessionKeyRotationTimer?: number
123
306
  private messageQueue: QueuedMessage[] = []
124
307
  private messageRateTracker: number[] = []
125
308
  private reconnectAttempts: number = 0
126
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
127
317
 
128
318
  constructor( options: Options = {} ){
129
319
  if( options && typeof options !== 'object' )
@@ -146,6 +336,414 @@ export default class IOF {
146
336
  this.peer.type = options.type.toUpperCase() as PeerType
147
337
  }
148
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
+
149
747
  debug( ...args: any[] ){
150
748
  this.options.debug && console.debug( ...args )
151
749
  }
@@ -194,6 +792,7 @@ export default class IOF {
194
792
 
195
793
  this.peer.connected = false
196
794
  this.stopHeartbeat()
795
+ this.stopSessionKeyRotation()
197
796
  this.fire('disconnect', { reason: 'CONNECTION_LOST' })
198
797
 
199
798
  this.options.autoReconnect
@@ -318,7 +917,50 @@ export default class IOF {
318
917
  || typeof data !== 'object'
319
918
  || !data.hasOwnProperty('_event') ) return
320
919
 
321
- 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
+ }
322
964
 
323
965
  // Handle heartbeat responses
324
966
  if( _event === '__heartbeat_response' ){
@@ -333,7 +975,7 @@ export default class IOF {
333
975
  return
334
976
  }
335
977
 
336
- this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '')
978
+ this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '')
337
979
 
338
980
  // Handshake or availability check events
339
981
  if( _event == 'pong' ){
@@ -344,37 +986,35 @@ export default class IOF {
344
986
 
345
987
  this.startHeartbeat()
346
988
  this.fire('connect')
989
+
990
+ // Initiate session key exchange if enabled
991
+ this.initiateSessionKeyExchange()
992
+
347
993
  this.processMessageQueue()
348
994
  this.debug(`[${this.peer.type}] connected`)
349
995
 
350
996
  return
351
997
  }
352
998
 
353
- // Optional application-level incoming validation (non-reserved events only)
354
- if( !RESERVED_EVENTS.includes( _event ) ){
355
- if( this.options.allowedIncomingEvents
356
- && !this.options.allowedIncomingEvents.includes( _event ) ){
357
- this.fire('error', {
358
- type: 'DISALLOWED_EVENT',
359
- direction: 'incoming',
360
- event: _event,
361
- origin
362
- })
363
- return
364
- }
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
365
1009
 
366
- if( this.options.validateIncoming
367
- && !this.options.validateIncoming( _event, payload, origin ) ){
368
- this.fire('error', {
369
- type: 'INVALID_MESSAGE',
370
- direction: 'incoming',
371
- event: _event,
372
- origin
1010
+ this.fire( _event, payload, cid )
373
1011
  })
374
- return
375
- }
1012
+ .catch( error => this.fire('error', { type: 'AUTH_ERROR', origin, event: _event, error: String(error) }) )
1013
+ return
376
1014
  }
377
1015
 
1016
+ if( !this.acceptIncoming( _event, payload, origin ) ) return
1017
+
378
1018
  // Fire available event listeners
379
1019
  this.fire( _event, payload, cid )
380
1020
  }
@@ -442,7 +1082,50 @@ export default class IOF {
442
1082
  return
443
1083
  }
444
1084
 
445
- 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
+ }
446
1129
 
447
1130
  // Handle heartbeat responses
448
1131
  if( _event === '__heartbeat_response' ){
@@ -457,7 +1140,7 @@ export default class IOF {
457
1140
  return
458
1141
  }
459
1142
 
460
- this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '')
1143
+ this.debug(`[${this.peer.type}] Message v${messageVersion}: ${_event}`, payload || '')
461
1144
 
462
1145
  // Handshake or availability check events
463
1146
  if( _event == 'ping' ){
@@ -469,12 +1152,35 @@ export default class IOF {
469
1152
  this.peer.lastHeartbeat = Date.now()
470
1153
  this.startHeartbeat()
471
1154
  this.fire('connect')
1155
+
1156
+ // Initiate session key exchange if enabled
1157
+ this.initiateSessionKeyExchange()
1158
+
472
1159
  this.processMessageQueue()
473
1160
 
474
1161
  this.debug(`[${this.peer.type}] connected`)
475
1162
  return
476
1163
  }
477
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
+
478
1184
  // Fire available event listeners
479
1185
  this.fire( _event, payload, cid )
480
1186
  }
@@ -570,6 +1276,7 @@ export default class IOF {
570
1276
  }
571
1277
 
572
1278
  const messageData = {
1279
+ v: PROTOCOL_VERSION,
573
1280
  _event,
574
1281
  payload: sanitizedPayload,
575
1282
  cid,
@@ -595,6 +1302,91 @@ export default class IOF {
595
1302
  return this
596
1303
  }
597
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
+
598
1390
  on( _event: string, fn: Listener ){
599
1391
  // Add Event listener
600
1392
  if( !this.Events[_event] ) this.Events[_event] = []
@@ -698,6 +1490,7 @@ export default class IOF {
698
1490
  }
699
1491
 
700
1492
  this.stopHeartbeat()
1493
+ this.stopSessionKeyRotation()
701
1494
 
702
1495
  if( this.reconnectTimer ){
703
1496
  clearTimeout( this.reconnectTimer )
@@ -713,9 +1506,18 @@ export default class IOF {
713
1506
  this.peer.source = undefined
714
1507
  this.peer.origin = undefined
715
1508
  this.peer.lastHeartbeat = undefined
1509
+ this.peer.protocolVersion = undefined
1510
+ this.peer.sessionId = undefined
1511
+
716
1512
  this.messageQueue = []
717
1513
  this.messageRateTracker = []
718
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
719
1521
 
720
1522
  this.removeListeners()
721
1523
 
@@ -735,7 +1537,11 @@ export default class IOF {
735
1537
  queuedMessages: this.messageQueue.length,
736
1538
  reconnectAttempts: this.reconnectAttempts,
737
1539
  activeListeners: Object.keys( this.Events ).length,
738
- 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
739
1545
  }
740
1546
  }
741
1547
 
@@ -747,4 +1553,4 @@ export default class IOF {
747
1553
  this.debug(`[${this.peer.type}] Cleared ${queueSize} queued messages`)
748
1554
  return this
749
1555
  }
750
- }
1556
+ }