webview.io 1.1.0 → 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
@@ -27,6 +52,12 @@ export type Options = {
27
52
  * Return false to drop a message; an 'error' event will be emitted.
28
53
  */
29
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
30
61
  }
31
62
 
32
63
  export interface RegisteredEvents {
@@ -35,6 +66,7 @@ export interface RegisteredEvents {
35
66
 
36
67
  export type Peer = {
37
68
  type: PeerType
69
+ protocolVersion?: number
38
70
  webViewRef?: RefObject<WebView>
39
71
  origin?: string
40
72
  connected?: boolean
@@ -43,12 +75,19 @@ export type Peer = {
43
75
  }
44
76
 
45
77
  export type MessageData = {
78
+ v?: number // Protocol version
46
79
  _event: string
47
80
  payload: any
48
81
  cid: string | undefined
49
82
  timestamp?: number
50
83
  size?: number
51
84
  token?: string
85
+ auth?: {
86
+ alg: 'HMAC-SHA256'
87
+ ts: number
88
+ nonce: string
89
+ sig: string
90
+ }
52
91
  }
53
92
 
54
93
  export type Message = {
@@ -62,6 +101,36 @@ export type QueuedMessage = {
62
101
  timestamp: number
63
102
  }
64
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
+
65
134
  function newObject( data: object ){
66
135
  return JSON.parse( JSON.stringify( data ) )
67
136
  }
@@ -82,6 +151,77 @@ function sanitizePayload( payload: any, maxSize: number ): any {
82
151
  return JSON.parse( JSON.stringify( payload ) )
83
152
  }
84
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
+ }
85
225
  const ackId = () => {
86
226
  const
87
227
  rmin = 100000,
@@ -140,6 +280,7 @@ export default class WIO {
140
280
  private maxReconnectAttempts: number = 5
141
281
  private connectionToken?: string
142
282
  private connectionAttempts: number = 0
283
+ private seenNonces: Map<string, number> = new Map()
143
284
 
144
285
  constructor( options: Options = {} ){
145
286
  if( options && typeof options !== 'object' )
@@ -163,6 +304,89 @@ export default class WIO {
163
304
  if( options.type ) this.peer.type = options.type
164
305
  }
165
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
+ }
166
390
  debug( ...args: any[] ){
167
391
  this.options.debug && console.debug( ...args )
168
392
  }
@@ -460,7 +684,25 @@ export default class WIO {
460
684
  if( typeof data !== 'object' || !data.hasOwnProperty('_event') )
461
685
  return
462
686
 
463
- 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
464
706
 
465
707
  // Validate origin if specified
466
708
  if( this.peer.origin && event.nativeEvent && 'origin' in event.nativeEvent ){
@@ -573,6 +815,44 @@ export default class WIO {
573
815
  return
574
816
  }
575
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
+
576
856
  // Optional application-level incoming validation (non-reserved events only)
577
857
  if( !RESERVED_EVENTS.includes( _event ) ){
578
858
  if( this.options.allowedIncomingEvents
@@ -685,6 +965,7 @@ export default class WIO {
685
965
  }
686
966
 
687
967
  const messageData: MessageData = {
968
+ v: PROTOCOL_VERSION,
688
969
  _event,
689
970
  payload: sanitizedPayload,
690
971
  cid,
@@ -711,6 +992,91 @@ export default class WIO {
711
992
  return this
712
993
  }
713
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
+
714
1080
  on( _event: string, fn: Listener ){
715
1081
  // Add Event listener
716
1082
  if( !this.Events[_event] ) this.Events[_event] = []
@@ -870,6 +1236,7 @@ export default class WIO {
870
1236
  * NOTE: Does not auto-initialize - page must call window._wio.listen()
871
1237
  */
872
1238
  getInjectedJavaScript(): string {
1239
+ const authSecret = this.options.cryptoAuth?.secret
873
1240
  return `
874
1241
  (function() {
875
1242
  try {
@@ -892,7 +1259,11 @@ export default class WIO {
892
1259
  Events: {},
893
1260
  messageQueue: [],
894
1261
  connectionToken: null,
1262
+ authSecret: ${authSecret ? JSON.stringify(authSecret) : 'null'},
895
1263
  setupComplete: false,
1264
+ seenNonces: new Map(),
1265
+ maxSkewMs: ${(this.options.cryptoAuth?.maxSkewMs ?? 2 * 60 * 1000)},
1266
+ replayWindowSize: ${(this.options.cryptoAuth?.replayWindowSize ?? 500)},
896
1267
 
897
1268
  listen: function(){
898
1269
  if( this.setupComplete ){
@@ -1002,6 +1373,48 @@ export default class WIO {
1002
1373
  typeof fn === 'function' && fn( String(error) )
1003
1374
  }
1004
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
+ },
1005
1418
 
1006
1419
  on: function( _event, fn ){
1007
1420
  if( !window._wio.Events[_event] ) window._wio.Events[_event] = []
@@ -1040,10 +1453,108 @@ export default class WIO {
1040
1453
  window._wio.messageQueue = []
1041
1454
 
1042
1455
  queue.forEach( msg => {
1043
- 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
+ }
1044
1461
  catch( error ){ console.error('[EMBEDDED] Queue process error:', error ) }
1045
1462
  })
1046
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
+ },
1047
1558
 
1048
1559
  handleMessage: function( data ){
1049
1560
  if( !data || !data._event ) return
@@ -1092,6 +1603,18 @@ export default class WIO {
1092
1603
 
1093
1604
  return
1094
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
+ }
1095
1618
 
1096
1619
  // Fire event listeners
1097
1620
  window._wio.fire( _event, payload, cid )