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/README.md +25 -1
- package/dist/index.d.ts +58 -0
- package/dist/index.js +403 -16
- package/package.json +3 -5
- package/src/index.ts +525 -2
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 {
|
|
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 )
|