webview.io 1.0.6 → 1.2.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
@@ -6,6 +6,31 @@ export type PeerType = 'WEBVIEW' | 'EMBEDDED'
6
6
  export type AckFunction = ( error: boolean | string, ...args: any[] ) => void
7
7
  export type Listener = ( payload?: any, ack?: AckFunction ) => void
8
8
 
9
+ export type CryptoAuthOptions = {
10
+ /**
11
+ * Shared secret used for HMAC-SHA256 signing.
12
+ *
13
+ * IMPORTANT: If an attacker can execute JS in either peer, they can read the secret.
14
+ * This is for authenticity/integrity between cooperating peers, not a sandbox boundary.
15
+ */
16
+ secret: string
17
+ /**
18
+ * If true, drop any incoming message that doesn't carry valid auth.
19
+ * Default: false (accept unsigned messages)
20
+ */
21
+ requireSigned?: boolean
22
+ /**
23
+ * Maximum allowed clock skew for signed messages (ms).
24
+ * Default: 2 minutes
25
+ */
26
+ maxSkewMs?: number
27
+ /**
28
+ * Replay window size (max number of nonces kept in memory).
29
+ * Default: 500
30
+ */
31
+ replayWindowSize?: number
32
+ }
33
+
9
34
  export type Options = {
10
35
  type?: PeerType
11
36
  debug?: boolean
@@ -17,6 +42,22 @@ export type Options = {
17
42
  messageQueueSize?: number
18
43
  connectionPingInterval?: number
19
44
  maxConnectionAttempts?: number
45
+ /**
46
+ * Optional allowlist of incoming application-level events.
47
+ * Reserved internal events (ping/pong/heartbeat/handshake) are always allowed.
48
+ */
49
+ allowedIncomingEvents?: string[]
50
+ /**
51
+ * Optional custom validator for incoming messages.
52
+ * Return false to drop a message; an 'error' event will be emitted.
53
+ */
54
+ validateIncoming?: ( event: string, payload: any ) => boolean
55
+ /**
56
+ * Optional cryptographic message authentication (HMAC-SHA256).
57
+ * When enabled, use `emitSigned` / `emitAsyncSigned` to send signed messages.
58
+ * For EMBEDDED (WebView content) you must set the secret in injected bridge too (handled by `getInjectedJavaScript()` when configured).
59
+ */
60
+ cryptoAuth?: CryptoAuthOptions
20
61
  }
21
62
 
22
63
  export interface RegisteredEvents {
@@ -25,6 +66,7 @@ export interface RegisteredEvents {
25
66
 
26
67
  export type Peer = {
27
68
  type: PeerType
69
+ protocolVersion?: number
28
70
  webViewRef?: RefObject<WebView>
29
71
  origin?: string
30
72
  connected?: boolean
@@ -33,12 +75,19 @@ export type Peer = {
33
75
  }
34
76
 
35
77
  export type MessageData = {
78
+ v?: number // Protocol version
36
79
  _event: string
37
80
  payload: any
38
81
  cid: string | undefined
39
82
  timestamp?: number
40
83
  size?: number
41
84
  token?: string
85
+ auth?: {
86
+ alg: 'HMAC-SHA256'
87
+ ts: number
88
+ nonce: string
89
+ sig: string
90
+ }
42
91
  }
43
92
 
44
93
  export type Message = {
@@ -52,6 +101,36 @@ export type QueuedMessage = {
52
101
  timestamp: number
53
102
  }
54
103
 
104
+ // Current protocol version
105
+ const PROTOCOL_VERSION = 1
106
+
107
+ /**
108
+ * The exact fields, in the exact order, that a signature covers.
109
+ *
110
+ * Kept as data rather than written out at each call site because there are
111
+ * three implementations of this protocol — the class signing, the class
112
+ * verifying, and the hand-written bridge that getInjectedJavaScript() injects
113
+ * into the WebView — and they did not agree. The class signed over `size`; the
114
+ * injected bridge left it out of both its sign and its verify. Every signed
115
+ * message the native side sent was therefore refused by the WebView, in
116
+ * silence, for as long as cryptoAuth has existed.
117
+ *
118
+ * The injected bridge now interpolates this same array, so the three cannot
119
+ * drift apart again without changing one line.
120
+ */
121
+ const CANONICAL_FIELDS = [ 'v', '_event', 'payload', 'cid', 'timestamp', 'size', 'token' ] as const
122
+
123
+ function canonicalMessage( data: Record<string, any>, ts: number, nonce: string ): string {
124
+ const canonical: Record<string, any> = {}
125
+
126
+ for( const field of CANONICAL_FIELDS ) canonical[ field ] = data[ field ]
127
+
128
+ canonical.ts = ts
129
+ canonical.nonce = nonce
130
+
131
+ return JSON.stringify( canonical )
132
+ }
133
+
55
134
  function newObject( data: object ){
56
135
  return JSON.parse( JSON.stringify( data ) )
57
136
  }
@@ -72,6 +151,77 @@ function sanitizePayload( payload: any, maxSize: number ): any {
72
151
  return JSON.parse( JSON.stringify( payload ) )
73
152
  }
74
153
 
154
+ function constantTimeEqual( a: string, b: string ): boolean {
155
+ if( a.length !== b.length ) return false
156
+ let out = 0
157
+ for( let i = 0; i < a.length; i++ ) out |= a.charCodeAt( i ) ^ b.charCodeAt( i )
158
+ return out === 0
159
+ }
160
+
161
+ function getGlobalCrypto(){
162
+ return (typeof crypto !== 'undefined'
163
+ ? crypto
164
+ : (typeof window !== 'undefined' && (window as any).crypto)
165
+ || (typeof globalThis !== 'undefined' && (globalThis as any).crypto))
166
+ }
167
+
168
+ function randomHex( bytes: number ): string {
169
+ try {
170
+ const globalCrypto = getGlobalCrypto()
171
+ if( globalCrypto && typeof globalCrypto.getRandomValues === 'function' ){
172
+ const buf = new Uint8Array( bytes )
173
+ globalCrypto.getRandomValues( buf )
174
+ return Array.from( buf ).map( b => b.toString( 16 ).padStart( 2, '0' ) ).join('')
175
+ }
176
+ }
177
+ catch{}
178
+
179
+ // Fallback (NOT cryptographically strong)
180
+ return Array.from({ length: bytes }, () => Math.floor( Math.random() * 256 ).toString( 16 ).padStart( 2, '0' ) ).join('')
181
+ }
182
+
183
+ async function hmacSha256Base64Url( secret: string, message: string ): Promise<string> {
184
+ // Browser/WebCrypto (useful for EMBEDDED web content)
185
+ try {
186
+ const globalCrypto = getGlobalCrypto() as any
187
+ const subtle = globalCrypto?.subtle
188
+ if( subtle && typeof subtle.importKey === 'function' ){
189
+ const enc = new TextEncoder()
190
+ const key = await subtle.importKey(
191
+ 'raw',
192
+ enc.encode( secret ),
193
+ { name: 'HMAC', hash: 'SHA-256' },
194
+ false,
195
+ ['sign']
196
+ )
197
+ const sig = await subtle.sign( 'HMAC', key, enc.encode( message ) )
198
+ const bytes = new Uint8Array( sig )
199
+ let bin = ''
200
+ for( let i = 0; i < bytes.length; i++ ) bin += String.fromCharCode( bytes[i] )
201
+ const b64 = btoa( bin )
202
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
203
+ }
204
+ }
205
+ catch{
206
+ // fallthrough to Node implementation
207
+ }
208
+
209
+ // Node.js (commonjs) - optional (React Native metro can provide crypto polyfills in some setups)
210
+ try {
211
+ const nodeCrypto = (globalThis as any).__wio_node_crypto
212
+ || ((globalThis as any).__wio_node_crypto = (typeof (globalThis as any).require === 'function'
213
+ ? (globalThis as any).require('crypto')
214
+ : undefined))
215
+
216
+ if( !nodeCrypto ) throw new Error('node crypto unavailable')
217
+
218
+ const b64 = nodeCrypto.createHmac('sha256', secret).update( message ).digest('base64')
219
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
220
+ }
221
+ catch{
222
+ throw new Error('No crypto implementation available for HMAC-SHA256')
223
+ }
224
+ }
75
225
  const ackId = () => {
76
226
  const
77
227
  rmin = 100000,
@@ -83,7 +233,26 @@ const ackId = () => {
83
233
  }
84
234
 
85
235
  const generateToken = () => {
86
- return `${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
236
+ // Prefer cryptographically strong randomness when available
237
+ try {
238
+ const globalCrypto = (typeof crypto !== 'undefined'
239
+ ? crypto
240
+ : (typeof window !== 'undefined' && (window as any).crypto)
241
+ || (typeof globalThis !== 'undefined' && (globalThis as any).crypto))
242
+
243
+ if( globalCrypto && typeof globalCrypto.getRandomValues === 'function' ){
244
+ const buffer = new Uint32Array(4)
245
+ globalCrypto.getRandomValues( buffer )
246
+
247
+ const randomPart = Array.from( buffer ).map( n => n.toString( 16 ) ).join('')
248
+ return `${Date.now()}_${randomPart}`
249
+ }
250
+ }
251
+ catch{
252
+ // Fall back to Math.random-based implementation below
253
+ }
254
+
255
+ return `${Date.now()}_${Math.random().toString( 36 ).substring( 2, 15 )}`
87
256
  }
88
257
 
89
258
  const RESERVED_EVENTS = [
@@ -111,6 +280,7 @@ export default class WIO {
111
280
  private maxReconnectAttempts: number = 5
112
281
  private connectionToken?: string
113
282
  private connectionAttempts: number = 0
283
+ private seenNonces: Map<string, number> = new Map()
114
284
 
115
285
  constructor( options: Options = {} ){
116
286
  if( options && typeof options !== 'object' )
@@ -134,6 +304,89 @@ export default class WIO {
134
304
  if( options.type ) this.peer.type = options.type
135
305
  }
136
306
 
307
+ private cryptoCfg(){
308
+ if( !this.options.cryptoAuth ) return undefined
309
+ return {
310
+ secret: this.options.cryptoAuth.secret,
311
+ requireSigned: !!this.options.cryptoAuth.requireSigned,
312
+ maxSkewMs: this.options.cryptoAuth.maxSkewMs ?? 2 * 60 * 1000,
313
+ replayWindowSize: this.options.cryptoAuth.replayWindowSize ?? 500
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Forget nonces that can no longer be replayed, and only then cap the map.
319
+ *
320
+ * Age is what decides replayability: a captured message is refused once its
321
+ * `ts` falls outside maxSkewMs, so a nonce is only worth keeping that long.
322
+ * Pruning purely by count made the two defaults contradict each other — 500
323
+ * remembered nonces at the default 100 messages a second is five seconds of
324
+ * history guarding a two-minute acceptance window.
325
+ */
326
+ private pruneNonces( maxSize: number ){
327
+ const
328
+ cutoff = Date.now() - ( this.cryptoCfg()?.maxSkewMs ?? 2 * 60 * 1000 ),
329
+ stale: string[] = []
330
+
331
+ this.seenNonces.forEach( ( ts, nonce ) => { ts < cutoff && stale.push( nonce ) })
332
+ stale.forEach( nonce => this.seenNonces.delete( nonce ) )
333
+
334
+ if( this.seenNonces.size <= maxSize ) return
335
+
336
+ this.fire('error', {
337
+ type: 'REPLAY_WINDOW_EXCEEDED',
338
+ remembered: this.seenNonces.size,
339
+ maxSize
340
+ })
341
+
342
+ const
343
+ toRemove = this.seenNonces.size - maxSize,
344
+ keys = Array.from( this.seenNonces.keys() )
345
+
346
+ for( let k = 0; k < toRemove && k < keys.length; k++ )
347
+ this.seenNonces.delete( keys[k] )
348
+ }
349
+
350
+ private async signOutgoing( messageData: Omit<MessageData, 'auth'> ): Promise<MessageData['auth']> {
351
+ const cfg = this.cryptoCfg()
352
+ if( !cfg ) return undefined
353
+
354
+ const
355
+ ts = Date.now(),
356
+ nonce = randomHex( 16 ),
357
+ sig = await hmacSha256Base64Url( cfg.secret, canonicalMessage( messageData, ts, nonce ) )
358
+
359
+ return { alg: 'HMAC-SHA256', ts, nonce, sig }
360
+ }
361
+
362
+ private async verifyIncomingAuth( data: MessageData ): Promise<boolean> {
363
+ const cfg = this.cryptoCfg()
364
+ if( !cfg ) return true
365
+
366
+ if( !data.auth ){
367
+ return !cfg.requireSigned
368
+ }
369
+
370
+ const { alg, ts, nonce, sig } = data.auth
371
+ if( alg !== 'HMAC-SHA256' ) return false
372
+ if( typeof ts !== 'number' || typeof nonce !== 'string' || typeof sig !== 'string' ) return false
373
+
374
+ const now = Date.now()
375
+ if( Math.abs( now - ts ) > cfg.maxSkewMs ) return false
376
+
377
+ // The nonce is recorded only once the signature is known good: burning it
378
+ // here let an unsigned or badly signed message consume the nonce of a
379
+ // legitimate one still in flight.
380
+ if( this.seenNonces.has( nonce ) ) return false
381
+
382
+ const expected = await hmacSha256Base64Url( cfg.secret, canonicalMessage( data, ts, nonce ) )
383
+ if( !constantTimeEqual( expected, sig ) ) return false
384
+
385
+ this.seenNonces.set( nonce, ts )
386
+ this.pruneNonces( cfg.replayWindowSize )
387
+
388
+ return true
389
+ }
137
390
  debug( ...args: any[] ){
138
391
  this.options.debug && console.debug( ...args )
139
392
  }
@@ -431,7 +684,25 @@ export default class WIO {
431
684
  if( typeof data !== 'object' || !data.hasOwnProperty('_event') )
432
685
  return
433
686
 
434
- const { _event, payload, cid, timestamp, token } = data as MessageData
687
+ const { v, _event, payload, cid, timestamp, token } = data as MessageData
688
+
689
+ /**
690
+ * A peer that predates versioning sends no `v`, so absence reads as 1
691
+ * rather than as a refusal. Only a peer speaking a NEWER protocol than
692
+ * this build understands is turned away.
693
+ */
694
+ const messageVersion = v || 1
695
+ if( messageVersion > PROTOCOL_VERSION ){
696
+ this.fire('error', {
697
+ type: 'UNSUPPORTED_VERSION',
698
+ received: messageVersion,
699
+ supported: PROTOCOL_VERSION
700
+ })
701
+ return
702
+ }
703
+
704
+ if( !this.peer.protocolVersion || this.peer.protocolVersion < messageVersion )
705
+ this.peer.protocolVersion = messageVersion
435
706
 
436
707
  // Validate origin if specified
437
708
  if( this.peer.origin && event.nativeEvent && 'origin' in event.nativeEvent ){
@@ -544,6 +815,67 @@ export default class WIO {
544
815
  return
545
816
  }
546
817
 
818
+ // Cryptographic authentication (optional)
819
+ if( this.options.cryptoAuth ){
820
+ this.verifyIncomingAuth( data as MessageData )
821
+ .then( ok => {
822
+ if( !ok ){
823
+ this.fire('error', { type: 'AUTH_FAILED', event: _event })
824
+ return
825
+ }
826
+
827
+ // Optional application-level incoming validation (non-reserved events only)
828
+ if( !RESERVED_EVENTS.includes( _event ) ){
829
+ if( this.options.allowedIncomingEvents
830
+ && !this.options.allowedIncomingEvents.includes( _event ) ){
831
+ this.fire('error', {
832
+ type: 'DISALLOWED_EVENT',
833
+ direction: 'incoming',
834
+ event: _event
835
+ })
836
+ return
837
+ }
838
+
839
+ if( this.options.validateIncoming
840
+ && !this.options.validateIncoming( _event, payload ) ){
841
+ this.fire('error', {
842
+ type: 'INVALID_MESSAGE',
843
+ direction: 'incoming',
844
+ event: _event
845
+ })
846
+ return
847
+ }
848
+ }
849
+
850
+ this.fire( _event, payload, cid )
851
+ })
852
+ .catch( error => this.fire('error', { type: 'AUTH_ERROR', event: _event, error: String(error) }) )
853
+ return
854
+ }
855
+
856
+ // Optional application-level incoming validation (non-reserved events only)
857
+ if( !RESERVED_EVENTS.includes( _event ) ){
858
+ if( this.options.allowedIncomingEvents
859
+ && !this.options.allowedIncomingEvents.includes( _event ) ){
860
+ this.fire('error', {
861
+ type: 'DISALLOWED_EVENT',
862
+ direction: 'incoming',
863
+ event: _event
864
+ })
865
+ return
866
+ }
867
+
868
+ if( this.options.validateIncoming
869
+ && !this.options.validateIncoming( _event, payload ) ){
870
+ this.fire('error', {
871
+ type: 'INVALID_MESSAGE',
872
+ direction: 'incoming',
873
+ event: _event
874
+ })
875
+ return
876
+ }
877
+ }
878
+
547
879
  // Fire available event listeners
548
880
  this.fire( _event, payload, cid )
549
881
  }
@@ -633,6 +965,7 @@ export default class WIO {
633
965
  }
634
966
 
635
967
  const messageData: MessageData = {
968
+ v: PROTOCOL_VERSION,
636
969
  _event,
637
970
  payload: sanitizedPayload,
638
971
  cid,
@@ -659,6 +992,91 @@ export default class WIO {
659
992
  return this
660
993
  }
661
994
 
995
+ /**
996
+ * Send a signed message (HMAC-SHA256) when `options.cryptoAuth` is configured.
997
+ * This is async because WebCrypto signing is async.
998
+ */
999
+ async emitSigned<T = any>( _event: string, payload?: T | AckFunction, fn?: AckFunction ): Promise<this> {
1000
+ if( !this.checkRateLimit() ) return this
1001
+
1002
+ if( !this.options.cryptoAuth ){
1003
+ this.emit( _event as any, payload as any, fn )
1004
+ return this
1005
+ }
1006
+
1007
+ if( !this.isConnected() && !RESERVED_EVENTS.includes(_event) ){
1008
+ this.queueMessage( _event, payload, fn )
1009
+ return this
1010
+ }
1011
+
1012
+ if( !this.peer.webViewRef ){
1013
+ this.fire('error', { type: 'NO_CONNECTION', event: _event })
1014
+ return this
1015
+ }
1016
+
1017
+ if( typeof payload == 'function' ){
1018
+ fn = payload as AckFunction
1019
+ payload = undefined
1020
+ }
1021
+
1022
+ try {
1023
+ const sanitizedPayload = payload
1024
+ ? sanitizePayload( payload, this.options.maxMessageSize! )
1025
+ : payload
1026
+
1027
+ let cid: string | undefined
1028
+ if( typeof fn === 'function' ){
1029
+ const ackFunction = fn
1030
+ cid = ackId()
1031
+ this.once(`${_event}--${cid}--@ack`, ({ error, args }) => ackFunction( error, ...args ))
1032
+ }
1033
+
1034
+ const unsigned: Omit<MessageData, 'auth'> = {
1035
+ v: PROTOCOL_VERSION,
1036
+ _event,
1037
+ payload: sanitizedPayload,
1038
+ cid,
1039
+ timestamp: Date.now(),
1040
+ size: getMessageSize( sanitizedPayload ),
1041
+ token: RESERVED_EVENTS.includes(_event) ? this.connectionToken : undefined
1042
+ }
1043
+
1044
+ const auth = await this.signOutgoing( unsigned )
1045
+ const messageData: MessageData = { ...unsigned, auth }
1046
+
1047
+ this.peer.webViewRef.current?.postMessage( JSON.stringify( newObject( messageData ) ) )
1048
+ }
1049
+ catch( error ){
1050
+ this.debug(`[${this.peer.type}] EmitSigned error:`, error)
1051
+ this.fire('error', {
1052
+ type: 'EMIT_ERROR',
1053
+ event: _event,
1054
+ error: error instanceof Error ? error.message : String(error)
1055
+ })
1056
+
1057
+ typeof fn === 'function'
1058
+ && fn( error instanceof Error ? error.message : String(error) )
1059
+ }
1060
+
1061
+ return this
1062
+ }
1063
+
1064
+ async emitAsyncSigned<T = any, R = any>( _event: string, payload?: T, timeout: number = 5000 ): Promise<R> {
1065
+ return new Promise(( resolve, reject ) => {
1066
+ const timeoutId = setTimeout(() => reject( new Error(`Event '${_event}' acknowledgment timeout after ${timeout}ms`) ), timeout )
1067
+
1068
+ this.emitSigned( _event, payload as any, ( error, ...args ) => {
1069
+ clearTimeout( timeoutId )
1070
+ error
1071
+ ? reject( new Error( typeof error === 'string' ? error : 'Ack error' ) )
1072
+ : resolve( args.length === 0 ? undefined : args.length === 1 ? args[0] : args as any )
1073
+ }).catch( err => {
1074
+ clearTimeout( timeoutId )
1075
+ reject( err )
1076
+ })
1077
+ })
1078
+ }
1079
+
662
1080
  on( _event: string, fn: Listener ){
663
1081
  // Add Event listener
664
1082
  if( !this.Events[_event] ) this.Events[_event] = []
@@ -818,10 +1236,11 @@ export default class WIO {
818
1236
  * NOTE: Does not auto-initialize - page must call window._wio.listen()
819
1237
  */
820
1238
  getInjectedJavaScript(): string {
1239
+ const authSecret = this.options.cryptoAuth?.secret
821
1240
  return `
822
1241
  (function() {
823
1242
  try {
824
- console.log('[EMBEDDED] Initializing WIO bridge...')
1243
+ console.debug('[EMBEDDED] Initializing WIO bridge...')
825
1244
 
826
1245
  const RESERVED_EVENTS = [
827
1246
  'ping',
@@ -840,7 +1259,11 @@ export default class WIO {
840
1259
  Events: {},
841
1260
  messageQueue: [],
842
1261
  connectionToken: null,
1262
+ authSecret: ${authSecret ? JSON.stringify(authSecret) : 'null'},
843
1263
  setupComplete: false,
1264
+ seenNonces: new Map(),
1265
+ maxSkewMs: ${(this.options.cryptoAuth?.maxSkewMs ?? 2 * 60 * 1000)},
1266
+ replayWindowSize: ${(this.options.cryptoAuth?.replayWindowSize ?? 500)},
844
1267
 
845
1268
  listen: function(){
846
1269
  if( this.setupComplete ){
@@ -848,7 +1271,7 @@ export default class WIO {
848
1271
  return this
849
1272
  }
850
1273
 
851
- console.log('[EMBEDDED] Setting up message listeners...')
1274
+ console.debug('[EMBEDDED] Setting up message listeners...')
852
1275
 
853
1276
  // Listen to messages from React Native
854
1277
  window.addEventListener('message', function( event ){
@@ -871,7 +1294,7 @@ export default class WIO {
871
1294
  }
872
1295
 
873
1296
  this.setupComplete = true
874
- console.log('[EMBEDDED] Setup complete, starting ready announcements')
1297
+ console.debug('[EMBEDDED] Setup complete, starting ready announcements')
875
1298
 
876
1299
  // Start announcing readiness
877
1300
  this.announceReady()
@@ -891,7 +1314,7 @@ export default class WIO {
891
1314
 
892
1315
  fire: function( _event, payload, cid ){
893
1316
  if( !window._wio.Events[_event] && !window._wio.Events[_event + '--@once'] ){
894
- console.log('[EMBEDDED] No listener for:', _event)
1317
+ console.debug('[EMBEDDED] No listener for:', _event)
895
1318
  return
896
1319
  }
897
1320
 
@@ -922,7 +1345,7 @@ export default class WIO {
922
1345
 
923
1346
  if( !window._wio.connected && !RESERVED_EVENTS.includes(_event) ){
924
1347
  window._wio.messageQueue.push({ _event, payload, fn, timestamp: Date.now() })
925
- console.log('[EMBEDDED] Queued message:', _event)
1348
+ console.debug('[EMBEDDED] Queued message:', _event)
926
1349
  return
927
1350
  }
928
1351
 
@@ -950,6 +1373,48 @@ export default class WIO {
950
1373
  typeof fn === 'function' && fn( String(error) )
951
1374
  }
952
1375
  },
1376
+
1377
+ emitSigned: async function( _event, payload, fn ){
1378
+ if( typeof payload === 'function' ){
1379
+ fn = payload
1380
+ payload = undefined
1381
+ }
1382
+
1383
+ if( !window._wio.connected && !RESERVED_EVENTS.includes(_event) ){
1384
+ window._wio.messageQueue.push({ _event, payload, fn, timestamp: Date.now(), signed: true })
1385
+ console.debug('[EMBEDDED] Queued signed message:', _event)
1386
+ return
1387
+ }
1388
+
1389
+ try {
1390
+ let cid
1391
+ if( typeof fn === 'function' ){
1392
+ cid = window._wio.ackId()
1393
+ window._wio.once( _event + '--' + cid + '--@ack', ({ error, args }) => fn( error, ...args ) )
1394
+ }
1395
+
1396
+ const unsigned = {
1397
+ v: ${PROTOCOL_VERSION},
1398
+ _event,
1399
+ payload,
1400
+ cid,
1401
+ timestamp: Date.now(),
1402
+ size: (function(){ try { return JSON.stringify( payload ).length } catch(e){ return 0 } })(),
1403
+ token: RESERVED_EVENTS.includes(_event) ? window._wio.connectionToken : undefined
1404
+ }
1405
+
1406
+ const auth = await window._wio.sign(unsigned)
1407
+ const messageData = { ...unsigned, auth }
1408
+
1409
+ if( typeof window.ReactNativeWebView !== 'undefined' )
1410
+ window.ReactNativeWebView.postMessage( JSON.stringify( messageData ) )
1411
+ else console.error('[EMBEDDED] ReactNativeWebView not available')
1412
+ }
1413
+ catch( error ){
1414
+ console.error('[EMBEDDED] EmitSigned error:', error )
1415
+ typeof fn === 'function' && fn( String(error) )
1416
+ }
1417
+ },
953
1418
 
954
1419
  on: function( _event, fn ){
955
1420
  if( !window._wio.Events[_event] ) window._wio.Events[_event] = []
@@ -983,22 +1448,120 @@ export default class WIO {
983
1448
  processMessageQueue: function(){
984
1449
  if( !window._wio.connected || window._wio.messageQueue.length === 0 ) return
985
1450
 
986
- console.log('[EMBEDDED] Processing', window._wio.messageQueue.length, 'queued messages')
1451
+ console.debug('[EMBEDDED] Processing', window._wio.messageQueue.length, 'queued messages')
987
1452
  const queue = [ ...window._wio.messageQueue ]
988
1453
  window._wio.messageQueue = []
989
1454
 
990
1455
  queue.forEach( msg => {
991
- try { window._wio.emit( msg._event, msg.payload, msg.fn ) }
1456
+ try {
1457
+ msg.signed
1458
+ ? window._wio.emitSigned( msg._event, msg.payload, msg.fn )
1459
+ : window._wio.emit( msg._event, msg.payload, msg.fn )
1460
+ }
992
1461
  catch( error ){ console.error('[EMBEDDED] Queue process error:', error ) }
993
1462
  })
994
1463
  },
1464
+
1465
+ canonicalFields: ${JSON.stringify( CANONICAL_FIELDS )},
1466
+
1467
+ // Must produce byte-identical output to canonicalMessage() on the
1468
+ // native side — hence the shared field list above.
1469
+ canonical: function( data, ts, nonce ){
1470
+ const out = {}
1471
+ window._wio.canonicalFields.forEach(function( field ){ out[field] = data[field] })
1472
+ out.ts = ts
1473
+ out.nonce = nonce
1474
+ return JSON.stringify( out )
1475
+ },
1476
+
1477
+ pruneNonces: function(){
1478
+ const cutoff = Date.now() - window._wio.maxSkewMs
1479
+ const stale = []
1480
+ window._wio.seenNonces.forEach(function( ts, nonce ){ if( ts < cutoff ) stale.push( nonce ) })
1481
+ stale.forEach(function( nonce ){ window._wio.seenNonces.delete( nonce ) })
1482
+
1483
+ if( window._wio.seenNonces.size <= window._wio.replayWindowSize ) return
1484
+
1485
+ const toRemove = window._wio.seenNonces.size - window._wio.replayWindowSize
1486
+ const keys = Array.from( window._wio.seenNonces.keys() )
1487
+ for( let k = 0; k < toRemove && k < keys.length; k++ )
1488
+ window._wio.seenNonces.delete( keys[k] )
1489
+ },
1490
+
1491
+ constantTimeEqual: function(a, b){
1492
+ if( a.length !== b.length ) return false
1493
+ let out = 0
1494
+ for( let i = 0; i < a.length; i++ ) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
1495
+ return out === 0
1496
+ },
1497
+
1498
+ hmacSha256Base64Url: async function(secret, message){
1499
+ if( !secret ) throw new Error('Missing auth secret')
1500
+ if( !window.crypto || !window.crypto.subtle ) throw new Error('WebCrypto unavailable')
1501
+
1502
+ const enc = new TextEncoder()
1503
+ const key = await window.crypto.subtle.importKey(
1504
+ 'raw',
1505
+ enc.encode(secret),
1506
+ { name: 'HMAC', hash: 'SHA-256' },
1507
+ false,
1508
+ ['sign']
1509
+ )
1510
+ const sig = await window.crypto.subtle.sign('HMAC', key, enc.encode(message))
1511
+ const bytes = new Uint8Array(sig)
1512
+ let bin = ''
1513
+ for( let i = 0; i < bytes.length; i++ ) bin += String.fromCharCode(bytes[i])
1514
+ const b64 = btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, '')
1515
+ return b64
1516
+ },
1517
+
1518
+ sign: async function(unsigned){
1519
+ if( !window._wio.authSecret ) return null
1520
+ const ts = Date.now()
1521
+ const nonce = (function(){
1522
+ try {
1523
+ if( window.crypto && window.crypto.getRandomValues ){
1524
+ const buf = new Uint8Array(16)
1525
+ window.crypto.getRandomValues(buf)
1526
+ let out = ''
1527
+ for( let i = 0; i < buf.length; i++ ){
1528
+ out += ('0' + buf[i].toString(16)).slice(-2)
1529
+ }
1530
+ return out
1531
+ }
1532
+ } catch(e){}
1533
+ return (Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)).slice(0, 32)
1534
+ })()
1535
+ const sig = await window._wio.hmacSha256Base64Url(window._wio.authSecret, window._wio.canonical(unsigned, ts, nonce))
1536
+ return { alg: 'HMAC-SHA256', ts, nonce, sig }
1537
+ },
1538
+
1539
+ verify: async function(data){
1540
+ if( !window._wio.authSecret ) return true
1541
+ if( !data.auth ) return false
1542
+ const { alg, ts, nonce, sig } = data.auth
1543
+ if( alg !== 'HMAC-SHA256' ) return false
1544
+
1545
+ const now = Date.now()
1546
+ if( Math.abs(now - ts) > window._wio.maxSkewMs ) return false
1547
+
1548
+ if( window._wio.seenNonces.has(nonce) ) return false
1549
+
1550
+ const expected = await window._wio.hmacSha256Base64Url(window._wio.authSecret, window._wio.canonical(data, ts, nonce))
1551
+ if( !window._wio.constantTimeEqual(expected, sig) ) return false
1552
+
1553
+ window._wio.seenNonces.set(nonce, ts)
1554
+ window._wio.pruneNonces()
1555
+
1556
+ return true
1557
+ },
995
1558
 
996
1559
  handleMessage: function( data ){
997
1560
  if( !data || !data._event ) return
998
1561
 
999
1562
  const { _event, payload, cid, token } = data
1000
1563
 
1001
- console.log('[EMBEDDED] Received:', _event )
1564
+ console.debug('[EMBEDDED] Received:', _event )
1002
1565
 
1003
1566
  // Handle heartbeat response
1004
1567
  if( _event === '__heartbeat_response' )
@@ -1012,13 +1575,13 @@ export default class WIO {
1012
1575
 
1013
1576
  // Handle webview ready signal
1014
1577
  if( _event === '__webview_ready' ){
1015
- console.log('[EMBEDDED] WebView ready signal received')
1578
+ console.debug('[EMBEDDED] WebView ready signal received')
1016
1579
  return
1017
1580
  }
1018
1581
 
1019
1582
  // Handle ping from WEBVIEW
1020
1583
  if( _event === 'ping' ){
1021
- console.log('[EMBEDDED] Received ping, sending pong')
1584
+ console.debug('[EMBEDDED] Received ping, sending pong')
1022
1585
  window._wio.connectionToken = token
1023
1586
 
1024
1587
  window._wio.emit('pong', { token: window._wio.connectionToken })
@@ -1032,7 +1595,7 @@ export default class WIO {
1032
1595
  return
1033
1596
  }
1034
1597
 
1035
- console.log('[EMBEDDED] Connection established (received ack)')
1598
+ console.debug('[EMBEDDED] Connection established (received ack)')
1036
1599
 
1037
1600
  window._wio.connected = true
1038
1601
  window._wio.processMessageQueue()
@@ -1040,6 +1603,18 @@ export default class WIO {
1040
1603
 
1041
1604
  return
1042
1605
  }
1606
+
1607
+ // Auth verification (optional, if authSecret is set)
1608
+ if( window._wio.authSecret ){
1609
+ window._wio.verify(data).then(ok => {
1610
+ if( !ok ){
1611
+ console.error('[EMBEDDED] Auth verification failed for', _event)
1612
+ return
1613
+ }
1614
+ window._wio.fire( _event, payload, cid )
1615
+ }).catch(err => console.error('[EMBEDDED] Auth error:', err))
1616
+ return
1617
+ }
1043
1618
 
1044
1619
  // Fire event listeners
1045
1620
  window._wio.fire( _event, payload, cid )
@@ -1050,21 +1625,21 @@ export default class WIO {
1050
1625
  const maxAttempts = 10
1051
1626
  const interval = 1000
1052
1627
 
1053
- console.log('[EMBEDDED] Starting ready announcements')
1628
+ console.debug('[EMBEDDED] Starting ready announcements')
1054
1629
 
1055
1630
  const announce = () => {
1056
1631
  if( window._wio.connected ){
1057
- console.log('[EMBEDDED] Connected, stopping announcements')
1632
+ console.debug('[EMBEDDED] Connected, stopping announcements')
1058
1633
  return
1059
1634
  }
1060
1635
 
1061
1636
  attempts++
1062
1637
  if( attempts > maxAttempts ){
1063
- console.log('[EMBEDDED] Max announcement attempts reached')
1638
+ console.debug('[EMBEDDED] Max announcement attempts reached')
1064
1639
  return
1065
1640
  }
1066
1641
 
1067
- console.log('[EMBEDDED] Announcing ready (attempt', attempts + '/' + maxAttempts + ')')
1642
+ console.debug('[EMBEDDED] Announcing ready (attempt', attempts + '/' + maxAttempts + ')')
1068
1643
  window._wio.emit('__embedded_ready')
1069
1644
 
1070
1645
  setTimeout( announce, interval )
@@ -1074,7 +1649,7 @@ export default class WIO {
1074
1649
  }
1075
1650
  }
1076
1651
 
1077
- console.log('[EMBEDDED] WIO bridge ready. Call window._wio.listen() to connect.')
1652
+ console.debug('[EMBEDDED] WIO bridge ready. Call window._wio.listen() to connect.')
1078
1653
  }
1079
1654
  catch( error ){
1080
1655
  console.error('[EMBEDDED] Setup failed:', error )