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/README.md +407 -110
- package/dist/index.d.ts +162 -0
- package/dist/index.js +866 -229
- package/package.json +4 -6
- package/src/index.ts +872 -12
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
|
-
|
|
75
|
-
|
|
274
|
+
timestampFallback = Date.now(),
|
|
275
|
+
randomFallback = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin )
|
|
76
276
|
|
|
77
|
-
return `${
|
|
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?:
|
|
93
|
-
private reconnectTimer?:
|
|
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
|
+
}
|