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/README.md +95 -18
- package/dist/index.d.ts +68 -0
- package/dist/index.js +435 -11
- package/package.json +3 -5
- package/src/index.ts +593 -18
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1628
|
+
console.debug('[EMBEDDED] Starting ready announcements')
|
|
1054
1629
|
|
|
1055
1630
|
const announce = () => {
|
|
1056
1631
|
if( window._wio.connected ){
|
|
1057
|
-
console.
|
|
1632
|
+
console.debug('[EMBEDDED] Connected, stopping announcements')
|
|
1058
1633
|
return
|
|
1059
1634
|
}
|
|
1060
1635
|
|
|
1061
1636
|
attempts++
|
|
1062
1637
|
if( attempts > maxAttempts ){
|
|
1063
|
-
console.
|
|
1638
|
+
console.debug('[EMBEDDED] Max announcement attempts reached')
|
|
1064
1639
|
return
|
|
1065
1640
|
}
|
|
1066
1641
|
|
|
1067
|
-
console.
|
|
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.
|
|
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 )
|