iframe.io 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +406 -136
- package/dist/index.d.ts +152 -0
- package/dist/index.js +670 -32
- package/package.json +3 -5
- package/src/index.ts +838 -32
package/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 = (
|
|
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
|
-
//
|
|
354
|
-
if(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|