walletpair-sdk 1.0.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/dist/ble/framing.d.ts +23 -0
  4. package/dist/ble/framing.d.ts.map +1 -0
  5. package/dist/ble/framing.js +83 -0
  6. package/dist/ble/framing.js.map +1 -0
  7. package/dist/ble/index.d.ts +9 -0
  8. package/dist/ble/index.d.ts.map +1 -0
  9. package/dist/ble/index.js +9 -0
  10. package/dist/ble/index.js.map +1 -0
  11. package/dist/ble/web-ble-transport.d.ts +29 -0
  12. package/dist/ble/web-ble-transport.d.ts.map +1 -0
  13. package/dist/ble/web-ble-transport.js +93 -0
  14. package/dist/ble/web-ble-transport.js.map +1 -0
  15. package/dist/crypto.d.ts +102 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +279 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dapp-session.d.ts +106 -0
  20. package/dist/dapp-session.d.ts.map +1 -0
  21. package/dist/dapp-session.js +918 -0
  22. package/dist/dapp-session.js.map +1 -0
  23. package/dist/emitter.d.ts +16 -0
  24. package/dist/emitter.d.ts.map +1 -0
  25. package/dist/emitter.js +41 -0
  26. package/dist/emitter.js.map +1 -0
  27. package/dist/evm/eip1193.d.ts +83 -0
  28. package/dist/evm/eip1193.d.ts.map +1 -0
  29. package/dist/evm/eip1193.js +270 -0
  30. package/dist/evm/eip1193.js.map +1 -0
  31. package/dist/evm/index.d.ts +8 -0
  32. package/dist/evm/index.d.ts.map +1 -0
  33. package/dist/evm/index.js +8 -0
  34. package/dist/evm/index.js.map +1 -0
  35. package/dist/evm/wagmi.d.ts +118 -0
  36. package/dist/evm/wagmi.d.ts.map +1 -0
  37. package/dist/evm/wagmi.js +205 -0
  38. package/dist/evm/wagmi.js.map +1 -0
  39. package/dist/index.d.ts +22 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +24 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/types.d.ts +225 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +31 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/wallet-session.d.ts +107 -0
  48. package/dist/wallet-session.d.ts.map +1 -0
  49. package/dist/wallet-session.js +794 -0
  50. package/dist/wallet-session.js.map +1 -0
  51. package/dist/ws-transport.d.ts +29 -0
  52. package/dist/ws-transport.d.ts.map +1 -0
  53. package/dist/ws-transport.js +79 -0
  54. package/dist/ws-transport.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
  57. package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
  58. package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
  59. package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
  60. package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
  61. package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
  62. package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
  63. package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
  64. package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
  65. package/src/ble/framing.test.ts +196 -0
  66. package/src/ble/framing.ts +100 -0
  67. package/src/ble/index.ts +18 -0
  68. package/src/ble/web-ble-transport.test.ts +192 -0
  69. package/src/ble/web-ble-transport.ts +116 -0
  70. package/src/ble/web-bluetooth.d.ts +47 -0
  71. package/src/canonical-json.test.ts +612 -0
  72. package/src/crypto-directional.test.ts +263 -0
  73. package/src/crypto-hardening.test.ts +529 -0
  74. package/src/crypto.test.ts +635 -0
  75. package/src/crypto.ts +405 -0
  76. package/src/dapp-session.test.ts +647 -0
  77. package/src/dapp-session.ts +1004 -0
  78. package/src/emitter.test.ts +169 -0
  79. package/src/emitter.ts +45 -0
  80. package/src/evm/eip1193.test.ts +365 -0
  81. package/src/evm/eip1193.ts +346 -0
  82. package/src/evm/index.ts +19 -0
  83. package/src/evm/wagmi.test.ts +396 -0
  84. package/src/evm/wagmi.ts +321 -0
  85. package/src/index.ts +86 -0
  86. package/src/integration.test.ts +385 -0
  87. package/src/security.test.ts +430 -0
  88. package/src/sequence-validation.test.ts +1185 -0
  89. package/src/test-helpers.ts +216 -0
  90. package/src/types.test.ts +82 -0
  91. package/src/types.ts +305 -0
  92. package/src/wallet-session.test.ts +683 -0
  93. package/src/wallet-session.ts +922 -0
  94. package/src/ws-transport.test.ts +231 -0
  95. package/src/ws-transport.ts +92 -0
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * DApp-side WalletPair session.
3
+ *
4
+ * Manages the full lifecycle: create channel → QR → wallet joins → accept →
5
+ * encrypted request/response + events.
6
+ */
7
+
8
+ import type { SessionCryptoContext } from './crypto.js'
9
+ import {
10
+ b64urlDecode,
11
+ b64urlEncode,
12
+ buildPairingUri,
13
+ bytesToHex,
14
+ canonicalJson,
15
+ computeSessionFingerprint,
16
+ computeSharedSecret,
17
+ deriveDirectionalSessionKeys,
18
+ deriveJoinEncryptionKey,
19
+ deriveSessionKey,
20
+ generateChannelId,
21
+ generateX25519KeyPair,
22
+ hexToBytes,
23
+ sealPayload,
24
+ signSnapshot,
25
+ unsealJoin,
26
+ unsealPayload,
27
+ verifySnapshot,
28
+ } from './crypto.js'
29
+ import { Emitter } from './emitter.js'
30
+ import type {
31
+ Capabilities,
32
+ DAppMeta,
33
+ DAppPhase,
34
+ DAppSessionEvents,
35
+ DAppSessionOptions,
36
+ PendingRequest,
37
+ ProtocolMessage,
38
+ SessionPersistence,
39
+ Transport,
40
+ WalletMeta,
41
+ } from './types.js'
42
+
43
+ const BACKOFF = [1000, 2000, 5000, 10000, 30000]
44
+ const DEFAULT_REQUEST_TIMEOUT = 120_000
45
+ const PENDING_ACCEPT_TIMEOUT = 60_000
46
+ const MAX_SEND_SEQ = 2 ** 31
47
+ const MAX_PENDING_REQUESTS = 32 // §15 rule 11
48
+ const MAX_MESSAGE_BYTES = 65536 // §15 rule 10: 64 KB
49
+ const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000 // 24 hours (§16 rule 16)
50
+
51
+ function isPromiseLike<T = unknown>(value: unknown): value is Promise<T> {
52
+ return !!value && typeof (value as Promise<T>).then === 'function'
53
+ }
54
+
55
+ function validateCapabilities(cap: unknown): cap is Capabilities {
56
+ if (cap == null || typeof cap !== 'object') return false
57
+ const c = cap as Record<string, unknown>
58
+ return Array.isArray(c.methods) && Array.isArray(c.events) && Array.isArray(c.chains)
59
+ }
60
+
61
+ export class DAppSession extends Emitter<DAppSessionEvents> {
62
+ phase: DAppPhase = 'idle'
63
+
64
+ /** Channel ID (hex). Available after createPairing(). */
65
+ channelId = ''
66
+ /** Pairing URI. Available after createPairing(). */
67
+ pairingUri = ''
68
+ /** 4-digit session fingerprint. Available after createPairing(). */
69
+ sessionFingerprint = ''
70
+ /** Remote wallet capabilities. Available after wallet joins. */
71
+ walletCapabilities: Capabilities | undefined = undefined
72
+ /** Remote wallet metadata. Available after wallet joins. */
73
+ walletMeta: WalletMeta | undefined = undefined
74
+ private approvedCapabilities: Capabilities | undefined = undefined
75
+ private approvedWalletMeta: WalletMeta | undefined = undefined
76
+ private approvedWalletPubKeyB64: string | undefined = undefined
77
+ private approvedScopeRecorded = false
78
+
79
+ private transport: Transport
80
+ private meta: DAppMeta
81
+ private declaredMethods: string[] | undefined
82
+ private declaredChains: string[] | undefined
83
+ private requestTimeout: number
84
+ private autoAccept: boolean
85
+ /** Session lifetime in ms (§16 rule 16). */
86
+ private sessionTtl: number
87
+ private sessionTtlTimer: ReturnType<typeof setTimeout> | null = null
88
+ private sessionStartTime: number | null = null
89
+
90
+ private privKey!: Uint8Array
91
+ private pubKeyB64 = ''
92
+ private remotePubKey: Uint8Array | null = null
93
+ private sessionKey: Uint8Array | null = null
94
+ private sendKey: Uint8Array | null = null
95
+ private recvKey: Uint8Array | null = null
96
+ private sendSeq = 0
97
+ private recvSeq = -1
98
+ private paired = false
99
+ private intentionalClose = false
100
+ private reqCounter = 0
101
+ private pendingRequests = new Map<string, PendingRequest>()
102
+ private persistence: SessionPersistence | undefined
103
+
104
+ private pendingAcceptTimer: ReturnType<typeof setTimeout> | null = null
105
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
106
+ private reconnectAttempt = 0
107
+
108
+ constructor(options: DAppSessionOptions) {
109
+ super()
110
+ this.transport = options.transport
111
+ this.meta = options.meta
112
+ this.declaredMethods = options.methods
113
+ this.declaredChains = options.chains
114
+ this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT
115
+ this.autoAccept = options.autoAccept ?? true
116
+ this.sessionTtl = options.sessionTtl ?? DEFAULT_SESSION_TTL
117
+ this.persistence = options.persistence
118
+
119
+ this.transport.onMessage((msg) => this.handleMessage(msg))
120
+ this.transport.onClose(() => this.handleTransportClose())
121
+ }
122
+
123
+ // -------------------------------------------------------------------------
124
+ // Public API
125
+ // -------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Create a new pairing channel and return the pairing URI for QR display.
129
+ *
130
+ * @param options.deferTransport - If true, don't connect the transport yet.
131
+ * Call `connectTransport()` later (e.g. after user scans QR in BLE mode).
132
+ */
133
+ async createPairing(options?: { deferTransport?: boolean | undefined }): Promise<string> {
134
+ this.intentionalClose = false
135
+ const kp = generateX25519KeyPair()
136
+ this.privKey = kp.privateKey
137
+ this.pubKeyB64 = kp.publicKeyB64
138
+ this.channelId = generateChannelId()
139
+ this.sendSeq = 0
140
+ this.recvSeq = -1
141
+ this.remotePubKey = null
142
+ this.sessionKey = null
143
+ this.sendKey = null
144
+ this.recvKey = null
145
+ this.sessionStartTime = null
146
+ this.clearSessionTtl()
147
+ this.approvedCapabilities = undefined
148
+ this.approvedWalletMeta = undefined
149
+ this.approvedWalletPubKeyB64 = undefined
150
+ this.approvedScopeRecorded = false
151
+ this.reqCounter = 0
152
+ this.paired = false
153
+ // Build pairing URI first (before transport connect, so BLE can show QR first)
154
+ let relayUrl: string | undefined
155
+ const transportWithUrl = this.transport as Transport & { url?: unknown }
156
+ if (typeof transportWithUrl.url === 'string') {
157
+ relayUrl = transportWithUrl.url
158
+ }
159
+ this.pairingUri = buildPairingUri({
160
+ channelId: this.channelId,
161
+ pubkeyB64: this.pubKeyB64,
162
+ relayUrl,
163
+ name: this.meta.name,
164
+ url: this.meta.url,
165
+ icon: this.meta.icon,
166
+ methods: this.declaredMethods,
167
+ chains: this.declaredChains,
168
+ })
169
+ this.sessionFingerprint = computeSessionFingerprint(this.channelId, this.pubKeyB64)
170
+ this.emit('pairingUri', this.pairingUri)
171
+ this.emit('sessionFingerprint', this.sessionFingerprint)
172
+
173
+ if (!options?.deferTransport) {
174
+ await this.connectTransport()
175
+ } else {
176
+ this.setPhase('waiting')
177
+ }
178
+
179
+ return this.pairingUri
180
+ }
181
+
182
+ /**
183
+ * Connect the transport and send the `create` message.
184
+ * Call this after `createPairing({ deferTransport: true })` when the user
185
+ * is ready (e.g. after showing QR and before BLE scan).
186
+ */
187
+ async connectTransport(): Promise<void> {
188
+ this.setTransportChannelHint()
189
+ await this.transport.connect()
190
+ this.setPhase('waiting')
191
+ this.sendRaw({
192
+ v: 1,
193
+ t: 'create',
194
+ ch: this.channelId,
195
+ ts: Date.now(),
196
+ from: this.pubKeyB64,
197
+ body: { meta: this.meta },
198
+ } as ProtocolMessage)
199
+ }
200
+
201
+ /** Accept the wallet after sealed_join verification. */
202
+ acceptWallet(): void {
203
+ if (this.phase !== 'pending_accept' || !this.remotePubKey) return
204
+ this.clearPendingAcceptTimer()
205
+ this.doAccept()
206
+ }
207
+
208
+ /** Reject the wallet. */
209
+ rejectWallet(): void {
210
+ if (!this.remotePubKey) return
211
+ this.sendRaw({
212
+ v: 1,
213
+ t: 'close',
214
+ ch: this.channelId,
215
+ ts: Date.now(),
216
+ from: this.pubKeyB64,
217
+ body: { reason: 'user_rejected' },
218
+ } as ProtocolMessage)
219
+ this.close()
220
+ }
221
+
222
+ /** Send an encrypted request to the wallet. Returns the decrypted response. */
223
+ request<T = unknown>(method: string, params?: unknown): Promise<T> {
224
+ if (this.phase !== 'connected' || !this.sendKey) {
225
+ return Promise.reject(new Error('Not connected'))
226
+ }
227
+
228
+ // §15 rule 11: max 32 pending requests
229
+ if (this.pendingRequests.size >= MAX_PENDING_REQUESTS) {
230
+ return Promise.reject(new Error('Too many pending requests'))
231
+ }
232
+
233
+ const id = `req-${++this.reqCounter}`
234
+
235
+ // Always seal: even parameterless requests must be authenticated via AEAD
236
+ // to prevent method injection by a malicious relay.
237
+ const send = (seq: number): Promise<T> => {
238
+ // AAD: no method field — real method goes inside sealed payload
239
+ const hdr = { type: 'req' as const, from: this.pubKeyB64, id }
240
+ const sealedParams = {
241
+ _method: method,
242
+ ...(params && typeof params === 'object'
243
+ ? (params as Record<string, unknown>)
244
+ : { _params: params ?? {} }),
245
+ }
246
+ const sealed = sealPayload(this.sendKey!, this.channelId, seq, sealedParams, hdr)
247
+
248
+ const msg: ProtocolMessage = {
249
+ v: 1,
250
+ t: 'req',
251
+ ch: this.channelId,
252
+ ts: Date.now(),
253
+ from: this.pubKeyB64,
254
+ body: { id, sealed },
255
+ } as ProtocolMessage
256
+
257
+ return new Promise<T>((resolve, reject) => {
258
+ const timer = setTimeout(() => {
259
+ this.pendingRequests.delete(id)
260
+ reject(new Error(`Request ${method} timed out`))
261
+ }, this.requestTimeout)
262
+
263
+ this.pendingRequests.set(id, { id, method, resolve: resolve as any, reject, timer })
264
+ this.sendRaw(msg)
265
+ })
266
+ }
267
+
268
+ try {
269
+ const seqOrPromise = this.nextSendSeq()
270
+ return isPromiseLike<number>(seqOrPromise) ? seqOrPromise.then(send) : send(seqOrPromise)
271
+ } catch (error) {
272
+ return Promise.reject(error as Error)
273
+ }
274
+ }
275
+
276
+ /** Send ping. */
277
+ ping(): void {
278
+ if (this.phase !== 'connected') return
279
+ this.sendRaw({
280
+ v: 1,
281
+ t: 'ping',
282
+ ch: this.channelId,
283
+ ts: Date.now(),
284
+ from: this.pubKeyB64,
285
+ body: {},
286
+ } as ProtocolMessage)
287
+ }
288
+
289
+ /** Gracefully close the session. */
290
+ close(reason: string = 'normal'): void {
291
+ this.intentionalClose = true
292
+ this.clearPendingAcceptTimer()
293
+ this.clearSessionTtl()
294
+ this.stopReconnect()
295
+ for (const [, req] of this.pendingRequests) {
296
+ if (req.timer) clearTimeout(req.timer)
297
+ req.reject(new Error('Session closed'))
298
+ }
299
+ this.pendingRequests.clear()
300
+ if (this.channelId) {
301
+ this.sendRaw({
302
+ v: 1,
303
+ t: 'close',
304
+ ch: this.channelId,
305
+ ts: Date.now(),
306
+ from: this.pubKeyB64,
307
+ body: { reason },
308
+ } as ProtocolMessage)
309
+ }
310
+ this.clearPersistence()
311
+ this.transport.disconnect()
312
+ this.setPhase('closed')
313
+ }
314
+
315
+ /** Destroy the session and release all resources. */
316
+ destroy(): void {
317
+ this.close()
318
+ this.removeAll()
319
+ // Wipe sensitive key material (§20.7)
320
+ if (this.privKey) this.privKey.fill(0)
321
+ if (this.sessionKey) this.sessionKey.fill(0)
322
+ if (this.sendKey) this.sendKey.fill(0)
323
+ if (this.recvKey) this.recvKey.fill(0)
324
+ this.sessionKey = null
325
+ this.sendKey = null
326
+ this.recvKey = null
327
+ }
328
+
329
+ // -------------------------------------------------------------------------
330
+ // State serialization (for persistence across page reloads)
331
+ // -------------------------------------------------------------------------
332
+
333
+ serialize(): string {
334
+ const json = JSON.stringify({
335
+ channelId: this.channelId,
336
+ privKey: bytesToHex(this.privKey),
337
+ pubKeyB64: this.pubKeyB64,
338
+ remotePubKeyB64: this.remotePubKey ? b64urlEncode(this.remotePubKey) : null,
339
+ sendKey: this.sendKey ? bytesToHex(this.sendKey) : null,
340
+ recvKey: this.recvKey ? bytesToHex(this.recvKey) : null,
341
+ sendSeq: this.sendSeq,
342
+ recvSeq: this.recvSeq,
343
+ reqCounter: this.reqCounter,
344
+ paired: this.paired,
345
+ dappMeta: this.meta,
346
+ approvedScopeRecorded: this.approvedScopeRecorded,
347
+ approvedCapabilities: this.approvedCapabilities ?? null,
348
+ approvedWalletMeta: this.approvedWalletMeta ?? null,
349
+ approvedWalletPubKeyB64: this.approvedWalletPubKeyB64 ?? null,
350
+ sessionStartTime: this.sessionStartTime,
351
+ })
352
+ // HMAC-sign the snapshot so tampered storage (e.g. via XSS) is detected on restore
353
+ return this.sendKey ? signSnapshot(this.sendKey, json) : json
354
+ }
355
+
356
+ async restoreFromPersistence(): Promise<boolean> {
357
+ if (!this.persistence?.load) return false
358
+ const json = await this.persistence.load()
359
+ return json ? this.restore(json) : false
360
+ }
361
+
362
+ restore(signed: string): boolean {
363
+ try {
364
+ // Try HMAC-verified format first: "<hex-mac>.<json>"
365
+ // Fall back to plain JSON for backward compatibility with unsigned snapshots
366
+ let json: string
367
+ if (signed.length > 65 && signed[64] === '.') {
368
+ // Extract sendKey from the JSON to verify HMAC
369
+ const candidateJson = signed.slice(65)
370
+ const d0 = JSON.parse(candidateJson)
371
+ if (!d0.sendKey) return false
372
+ const sendKey = hexToBytes(d0.sendKey)
373
+ const verified = verifySnapshot(sendKey, signed)
374
+ if (!verified) return false // tampered
375
+ json = verified
376
+ } else {
377
+ json = signed
378
+ }
379
+
380
+ const d = JSON.parse(json)
381
+ if (!d.channelId || !d.privKey) return false
382
+ this.channelId = d.channelId
383
+ this.privKey = hexToBytes(d.privKey)
384
+ this.pubKeyB64 = d.pubKeyB64
385
+ this.remotePubKey = d.remotePubKeyB64 ? b64urlDecode(d.remotePubKeyB64) : null
386
+ this.sendKey = d.sendKey ? hexToBytes(d.sendKey) : null
387
+ this.recvKey = d.recvKey ? hexToBytes(d.recvKey) : null
388
+ if (!this.sendKey || !this.recvKey) return false
389
+ this.sendSeq = d.sendSeq ?? 0
390
+ this.recvSeq = d.recvSeq ?? -1
391
+ this.reqCounter = d.reqCounter || 0
392
+ this.paired = d.paired || false
393
+ this.approvedScopeRecorded = d.approvedScopeRecorded === true
394
+ this.approvedCapabilities = d.approvedCapabilities ?? undefined
395
+ this.approvedWalletMeta = d.approvedWalletMeta ?? undefined
396
+ this.approvedWalletPubKeyB64 = d.approvedWalletPubKeyB64 ?? d.remotePubKeyB64 ?? undefined
397
+ this.walletCapabilities = this.approvedCapabilities
398
+ this.walletMeta = this.approvedWalletMeta
399
+ this.meta =
400
+ d.dappMeta ??
401
+ (d.dappName ? { name: d.dappName, description: '', url: '', icon: '' } : this.meta)
402
+ this.sessionStartTime = d.sessionStartTime ?? null
403
+ return true
404
+ } catch {
405
+ return false
406
+ }
407
+ }
408
+
409
+ /** Reconnect after restoring state. */
410
+ async reconnect(): Promise<void> {
411
+ this.intentionalClose = false
412
+ this.stopReconnect()
413
+ this.setPhase('disconnected')
414
+ this.reconnectAttempt = 0
415
+ await this.doReconnectAttempt()
416
+ }
417
+
418
+ // -------------------------------------------------------------------------
419
+ // Internal: message handling
420
+ // -------------------------------------------------------------------------
421
+
422
+ private handleMessage(msg: ProtocolMessage): void {
423
+ // §2: Peers MUST reject any peer-sent message where from equals "_adapter"
424
+ if (msg.t !== 'ready' && msg.t !== 'terminate' && msg.from === '_adapter') {
425
+ this.emit('error', new Error('Rejected message with spoofed _adapter from'))
426
+ return
427
+ }
428
+
429
+ // §15 rule 12: reject unsupported protocol version
430
+ if (msg.v !== 1) {
431
+ this.close('unsupported_version')
432
+ return
433
+ }
434
+
435
+ switch (msg.t) {
436
+ case 'ready': {
437
+ const readyBody = msg.body as {
438
+ state?: string
439
+ reconnect?: boolean
440
+ remote?: string | null
441
+ }
442
+ this.stopReconnect()
443
+ if (readyBody.state === 'connected') {
444
+ const expectedRemote = this.remotePubKey ? b64urlEncode(this.remotePubKey) : null
445
+ if (!expectedRemote || readyBody.remote !== expectedRemote) {
446
+ this.emit('error', new Error('Connected remote does not match paired wallet'))
447
+ this.close()
448
+ break
449
+ }
450
+ }
451
+ if (readyBody.state === 'waiting') {
452
+ this.setPhase('waiting')
453
+ } else if (readyBody.state === 'connected') {
454
+ this.setPhase('connected')
455
+ this.startSessionTtl()
456
+ this.persistSnapshotAsync()
457
+ }
458
+ break
459
+ }
460
+
461
+ case 'join': {
462
+ const joinBody = msg.body as { sealed_join?: string | null }
463
+ const joinPubKey = msg.from
464
+ if (!joinPubKey) {
465
+ this.emit('error', new Error('Malformed wallet join: missing from'))
466
+ break
467
+ }
468
+ let remoteBytes: Uint8Array
469
+ try {
470
+ remoteBytes = b64urlDecode(joinPubKey)
471
+ if (remoteBytes.length !== 32) throw new Error('Invalid wallet public key length')
472
+ } catch {
473
+ this.sendRaw({
474
+ v: 1,
475
+ t: 'close',
476
+ ch: this.channelId,
477
+ ts: Date.now(),
478
+ from: this.pubKeyB64,
479
+ body: { reason: 'protocol_error' },
480
+ } as ProtocolMessage)
481
+ this.emit('error', new Error('Malformed wallet public key'))
482
+ break
483
+ }
484
+
485
+ this.remotePubKey = remoteBytes
486
+ const shared = computeSharedSecret(this.privKey, this.remotePubKey)
487
+ const rootKey = deriveSessionKey(shared, this.channelId)
488
+ // §20.7: erase shared_secret immediately
489
+ shared.fill(0)
490
+
491
+ // For reconnect (sealed_join is null), skip sealed_join decryption
492
+ let joinCapabilities: Capabilities | undefined
493
+ let joinMeta: WalletMeta | undefined
494
+ if (joinBody.sealed_join === null && this.paired) {
495
+ // Reconnect — capabilities/meta come from previously approved scope
496
+ joinCapabilities = this.approvedCapabilities
497
+ joinMeta = this.approvedWalletMeta
498
+ } else if (joinBody.sealed_join && typeof joinBody.sealed_join === 'string') {
499
+ // Decrypt sealed_join (private handshake §7.5)
500
+ let joinKey: Uint8Array | null = null
501
+ try {
502
+ joinKey = deriveJoinEncryptionKey(rootKey, this.channelId)
503
+ const decrypted = unsealJoin(joinKey, this.channelId, joinBody.sealed_join)
504
+ joinCapabilities = decrypted.capabilities as Capabilities | undefined
505
+ joinMeta = decrypted.meta as WalletMeta | undefined
506
+ } catch {
507
+ this.sendRaw({
508
+ v: 1,
509
+ t: 'close',
510
+ ch: this.channelId,
511
+ ts: Date.now(),
512
+ from: this.pubKeyB64,
513
+ body: { reason: 'decryption_failed' },
514
+ } as ProtocolMessage)
515
+ this.emit('error', new Error('Failed to decrypt sealed_join'))
516
+ rootKey.fill(0)
517
+ break
518
+ } finally {
519
+ // §20.7: erase join_encryption_key after one-shot use.
520
+ if (joinKey) joinKey.fill(0)
521
+ }
522
+ } else {
523
+ this.sendRaw({
524
+ v: 1,
525
+ t: 'close',
526
+ ch: this.channelId,
527
+ ts: Date.now(),
528
+ from: this.pubKeyB64,
529
+ body: { reason: 'protocol_error' },
530
+ } as ProtocolMessage)
531
+ this.emit('error', new Error('Initial wallet join missing sealed_join'))
532
+ rootKey.fill(0)
533
+ break
534
+ }
535
+
536
+ // Validate capabilities shape
537
+ if (joinCapabilities != null && !validateCapabilities(joinCapabilities)) {
538
+ this.sendRaw({
539
+ v: 1,
540
+ t: 'close',
541
+ ch: this.channelId,
542
+ ts: Date.now(),
543
+ from: this.pubKeyB64,
544
+ body: { reason: 'protocol_error' },
545
+ } as ProtocolMessage)
546
+ this.emit('error', new Error('Malformed wallet capabilities'))
547
+ rootKey.fill(0)
548
+ break
549
+ }
550
+
551
+ // Warn if wallet omits any of the 5 MUST-support methods (backwards-compatible)
552
+ if (joinCapabilities) {
553
+ const requiredMethods = [
554
+ 'wallet_getAccounts',
555
+ 'wallet_signTransaction',
556
+ 'wallet_signMessage',
557
+ 'wallet_signTypedData',
558
+ 'wallet_switchChain',
559
+ ]
560
+ const declared = new Set(joinCapabilities.methods)
561
+ const absent = requiredMethods.filter((m) => !declared.has(m))
562
+ if (absent.length > 0) {
563
+ console.warn(
564
+ `[WalletPair] Wallet missing MUST-support methods: ${absent.join(', ')}`,
565
+ )
566
+ }
567
+ }
568
+
569
+ const knownWallet = this.isSameApprovedWallet(joinPubKey, joinCapabilities, joinMeta)
570
+ this.walletCapabilities = joinCapabilities
571
+ this.walletMeta = joinMeta
572
+ const context = this.sessionContext(joinPubKey, joinCapabilities, joinMeta)
573
+ const keys = deriveDirectionalSessionKeys(rootKey, this.channelId, context)
574
+ this.sendKey = keys.dappToWalletKey
575
+ this.recvKey = keys.walletToDappKey
576
+
577
+ // §20.7: erase root_key and transcript_hash after all derivations
578
+ rootKey.fill(0)
579
+ keys.rootKey.fill(0)
580
+ keys.transcriptHash.fill(0)
581
+
582
+ // §7.1 DApp-side scope enforcement: check granted capabilities
583
+ if (joinCapabilities && this.declaredMethods?.length) {
584
+ const granted = new Set(joinCapabilities.methods)
585
+ const missing = this.declaredMethods.filter((m) => !granted.has(m))
586
+ if (missing.length > 0) {
587
+ this.sendRaw({
588
+ v: 1,
589
+ t: 'close',
590
+ ch: this.channelId,
591
+ ts: Date.now(),
592
+ from: this.pubKeyB64,
593
+ body: { reason: 'unsupported_capability' },
594
+ } as ProtocolMessage)
595
+ this.emit('error', new Error(`Wallet missing required methods: ${missing.join(', ')}`))
596
+ break
597
+ }
598
+ }
599
+ if (joinCapabilities && this.declaredChains?.length) {
600
+ const granted = new Set(joinCapabilities.chains)
601
+ const missing = this.declaredChains.filter((c) => !granted.has(c))
602
+ if (missing.length > 0) {
603
+ this.sendRaw({
604
+ v: 1,
605
+ t: 'close',
606
+ ch: this.channelId,
607
+ ts: Date.now(),
608
+ from: this.pubKeyB64,
609
+ body: { reason: 'unsupported_capability' },
610
+ } as ProtocolMessage)
611
+ this.emit('error', new Error(`Wallet missing required chains: ${missing.join(', ')}`))
612
+ break
613
+ }
614
+ }
615
+
616
+ this.emit('walletJoined', {
617
+ capabilities: joinCapabilities,
618
+ meta: joinMeta,
619
+ })
620
+
621
+ this.setPhase('pending_accept')
622
+
623
+ // Auto-accept for known wallets on reconnect, or when autoAccept is enabled
624
+ if (this.autoAccept || knownWallet) {
625
+ this.doAccept()
626
+ } else {
627
+ // Application must call acceptWallet() or rejectWallet()
628
+ this.pendingAcceptTimer = setTimeout(() => {
629
+ this.emit('error', new Error('Pending accept timed out'))
630
+ this.close('timeout')
631
+ }, PENDING_ACCEPT_TIMEOUT)
632
+ }
633
+ break
634
+ }
635
+
636
+ case 'res': {
637
+ const resBody = msg.body as { id?: string; sealed?: string }
638
+ if (this.remotePubKey && msg.from !== b64urlEncode(this.remotePubKey)) break
639
+ if (!resBody.id) break
640
+ const responseId = resBody.id
641
+ const pending = this.pendingRequests.get(responseId)
642
+ if (!pending) break
643
+ this.pendingRequests.delete(responseId)
644
+ if (pending.timer) clearTimeout(pending.timer)
645
+
646
+ // All responses MUST be sealed — reject unsealed to prevent forgery.
647
+ if (!resBody.sealed || !this.recvKey) {
648
+ pending.reject(new Error('Response must be encrypted'))
649
+ break
650
+ }
651
+
652
+ try {
653
+ const resHdr = { type: 'res' as const, from: msg.from, id: responseId }
654
+ const { seq, data } = unsealPayload(this.recvKey, this.channelId, resBody.sealed, resHdr)
655
+ if (seq <= this.recvSeq) {
656
+ pending.reject(new Error('Replay detected'))
657
+ break
658
+ }
659
+ const prevRecvSeq = this.recvSeq
660
+ this.recvSeq = seq
661
+ const afterPersist = () => {
662
+ // Per protocol §5.3: _ok is inside the decrypted sealed payload
663
+ const envelope = data as {
664
+ _ok?: boolean
665
+ _result?: unknown
666
+ code?: string
667
+ message?: string
668
+ }
669
+ if (typeof envelope._ok !== 'boolean') {
670
+ pending.reject(new Error('Response missing _ok field'))
671
+ return
672
+ }
673
+ if (envelope._ok) {
674
+ pending.resolve(envelope._result)
675
+ this.emit('response', { id: responseId, ok: true, result: envelope._result })
676
+ } else {
677
+ const error = new Error(envelope.message ?? 'Request rejected')
678
+ ;(error as any).code = envelope.code
679
+ pending.reject(error)
680
+ this.emit('response', {
681
+ id: responseId,
682
+ ok: false,
683
+ result: { code: envelope.code, message: envelope.message },
684
+ })
685
+ }
686
+ }
687
+ const persisted = this.persistSnapshot()
688
+ if (isPromiseLike(persisted)) {
689
+ void persisted.then(afterPersist).catch((e) => {
690
+ this.recvSeq = prevRecvSeq // rollback on persist failure
691
+ pending.reject(this.persistenceError(e))
692
+ })
693
+ } else {
694
+ afterPersist()
695
+ }
696
+ } catch {
697
+ pending.reject(new Error('Decryption failed'))
698
+ }
699
+ break
700
+ }
701
+
702
+ case 'evt': {
703
+ const evtBody = msg.body as { id?: string; sealed?: string }
704
+ if (this.remotePubKey && msg.from !== b64urlEncode(this.remotePubKey)) break
705
+ // Events MUST be sealed — drop unsealed events to prevent forgery.
706
+ if (!evtBody.sealed || !evtBody.id || !this.recvKey) break
707
+ try {
708
+ const evtHdr = { type: 'evt' as const, from: msg.from, id: evtBody.id }
709
+ const { seq, data } = unsealPayload(this.recvKey, this.channelId, evtBody.sealed, evtHdr)
710
+ if (seq <= this.recvSeq) break // replay — silently drop
711
+ const prevRecvSeqEvt = this.recvSeq
712
+ this.recvSeq = seq
713
+
714
+ const afterPersist = () => {
715
+ // Privacy mode (§7.4): real event name is inside the encrypted payload
716
+ let event: string | undefined
717
+ let eventData: unknown = data
718
+ if (data && typeof data === 'object' && '_event' in (data as any)) {
719
+ event = (data as any)._event
720
+ const { _event: _, ...rest } = data as Record<string, unknown>
721
+ eventData = Object.keys(rest).length === 1 && '_data' in rest ? rest._data : rest
722
+ }
723
+
724
+ if (event) {
725
+ this.emit('event', { event, data: eventData })
726
+ } else {
727
+ this.emit('error', new Error('Event payload missing _event'))
728
+ }
729
+ }
730
+ const persisted = this.persistSnapshot()
731
+ if (isPromiseLike(persisted)) {
732
+ void persisted
733
+ .then(afterPersist)
734
+ .catch((e) => {
735
+ this.recvSeq = prevRecvSeqEvt // rollback on persist failure
736
+ this.emit('error', this.persistenceError(e))
737
+ })
738
+ } else {
739
+ afterPersist()
740
+ }
741
+ } catch {
742
+ /* drop events that fail decryption */
743
+ }
744
+ break
745
+ }
746
+
747
+ case 'ping':
748
+ this.sendRaw({
749
+ v: 1,
750
+ t: 'pong',
751
+ ch: this.channelId,
752
+ ts: Date.now(),
753
+ from: this.pubKeyB64,
754
+ body: {},
755
+ } as ProtocolMessage)
756
+ break
757
+
758
+ case 'pong':
759
+ break
760
+
761
+ case 'close': {
762
+ if (this.phase !== 'disconnected') {
763
+ this.clearPersistence()
764
+ this.setPhase('closed')
765
+ this.intentionalClose = true
766
+ }
767
+ break
768
+ }
769
+
770
+ case 'terminate': {
771
+ const termBody = msg.body as { reason?: string }
772
+ // Race condition: relay sends channel_exists when we re-create during reconnect
773
+ if (termBody.reason === 'channel_exists' && this.phase === 'disconnected') {
774
+ this.startReconnect()
775
+ break
776
+ }
777
+ // Adapter-sent termination — treat like close
778
+ if (this.phase !== 'disconnected') {
779
+ this.clearPersistence()
780
+ this.setPhase('closed')
781
+ this.intentionalClose = true
782
+ }
783
+ break
784
+ }
785
+ }
786
+ }
787
+
788
+ private doAccept(): void {
789
+ this.paired = true
790
+ this.approvedCapabilities = this.walletCapabilities
791
+ this.approvedWalletMeta = this.walletMeta
792
+ this.approvedWalletPubKeyB64 = b64urlEncode(this.remotePubKey!)
793
+ this.approvedScopeRecorded = true
794
+ const walletPubB64 = b64urlEncode(this.remotePubKey!)
795
+ const sendAccept = () => {
796
+ this.sendRaw({
797
+ v: 1,
798
+ t: 'accept',
799
+ ch: this.channelId,
800
+ ts: Date.now(),
801
+ from: this.pubKeyB64,
802
+ body: { target: walletPubB64 },
803
+ } as ProtocolMessage)
804
+ }
805
+ const persisted = this.persistSnapshot()
806
+ if (isPromiseLike(persisted)) {
807
+ void persisted.then(sendAccept).catch((e) => this.persistenceError(e))
808
+ } else {
809
+ sendAccept()
810
+ }
811
+ }
812
+
813
+ private sessionContext(
814
+ walletPubKeyB64: string,
815
+ capabilities?: Capabilities | undefined,
816
+ walletMeta?: WalletMeta | undefined,
817
+ ): SessionCryptoContext {
818
+ return {
819
+ dappPubKeyB64: this.pubKeyB64,
820
+ walletPubKeyB64,
821
+ capabilities: capabilities ?? null,
822
+ walletMeta: walletMeta ?? null,
823
+ dappName: this.meta.name,
824
+ }
825
+ }
826
+
827
+ private isSameApprovedWallet(
828
+ walletPubKeyB64: string,
829
+ capabilities?: Capabilities | undefined,
830
+ walletMeta?: WalletMeta | undefined,
831
+ ): boolean {
832
+ return (
833
+ this.paired &&
834
+ this.approvedWalletPubKeyB64 === walletPubKeyB64 &&
835
+ this.approvedScopeRecorded &&
836
+ canonicalJson(this.approvedCapabilities) === canonicalJson(capabilities ?? null) &&
837
+ canonicalJson(this.approvedWalletMeta ?? null) === canonicalJson(walletMeta ?? null)
838
+ )
839
+ }
840
+
841
+ private nextSendSeq(): number | Promise<number> {
842
+ if (this.sendSeq >= MAX_SEND_SEQ) {
843
+ const error = new Error('Send sequence overflow/limit reached — session invalidated')
844
+ this.emit('error', error)
845
+ this.close()
846
+ throw error
847
+ }
848
+ const seq = this.sendSeq
849
+ this.sendSeq += 1
850
+ const persisted = this.persistSnapshot()
851
+ if (isPromiseLike(persisted)) {
852
+ return persisted
853
+ .then(() => seq)
854
+ .catch((e) => {
855
+ throw this.persistenceError(e)
856
+ })
857
+ }
858
+ return seq
859
+ }
860
+
861
+ private persistSnapshot(): void | Promise<void> {
862
+ if (!this.persistence) return
863
+ return this.persistence.save(this.serialize())
864
+ }
865
+
866
+ private persistSnapshotAsync(): void {
867
+ const persisted = this.persistSnapshot()
868
+ if (isPromiseLike(persisted)) {
869
+ void persisted.catch((e) => this.persistenceError(e))
870
+ }
871
+ }
872
+
873
+ private clearPersistence(): void {
874
+ if (!this.persistence?.clear) return
875
+ const cleared = this.persistence.clear()
876
+ if (isPromiseLike(cleared)) {
877
+ void cleared.catch((e) => {
878
+ const err = e instanceof Error ? e : new Error(String(e))
879
+ this.emit('error', err)
880
+ })
881
+ }
882
+ }
883
+
884
+ private persistenceError(error: unknown): Error {
885
+ const err = error instanceof Error ? error : new Error(String(error))
886
+ const wrapped = new Error(`Session persistence failed: ${err.message}`)
887
+ this.emit('error', wrapped)
888
+ this.close('protocol_error')
889
+ return wrapped
890
+ }
891
+
892
+ // -------------------------------------------------------------------------
893
+ // Internal: transport
894
+ // -------------------------------------------------------------------------
895
+
896
+ /** Append ?ch= to transport URL for CF Worker relay routing. Rust relay ignores it. */
897
+ private setTransportChannelHint(): void {
898
+ const t = this.transport as { setUrl?: (url: string) => void; url?: string }
899
+ if (typeof t.setUrl === 'function') {
900
+ const base = (t as any).url ?? ''
901
+ if (base && !base.includes('?ch=')) {
902
+ const sep = base.includes('?') ? '&' : '?'
903
+ t.setUrl(`${base}${sep}ch=${this.channelId}`)
904
+ }
905
+ }
906
+ }
907
+
908
+ private sendRaw(msg: ProtocolMessage): void {
909
+ // §15 rule 10: max 64 KB on the wire
910
+ const json = JSON.stringify(msg)
911
+ if (new TextEncoder().encode(json).length > MAX_MESSAGE_BYTES) {
912
+ this.emit('error', new Error('Message exceeds 64 KB limit'))
913
+ return
914
+ }
915
+ this.transport.send(msg)
916
+ }
917
+
918
+ private handleTransportClose(): void {
919
+ if (this.intentionalClose || this.phase === 'closed') return
920
+ this.startReconnect()
921
+ }
922
+
923
+ // -------------------------------------------------------------------------
924
+ // Internal: reconnect
925
+ // -------------------------------------------------------------------------
926
+
927
+ private startReconnect(): void {
928
+ this.setPhase('disconnected')
929
+ this.reconnectAttempt = 0
930
+ this.scheduleReconnect()
931
+ }
932
+
933
+ private scheduleReconnect(): void {
934
+ if (this.intentionalClose || this.phase === 'closed') return
935
+ const base = BACKOFF[Math.min(this.reconnectAttempt, BACKOFF.length - 1)] ?? 1000
936
+ const delay = base + Math.floor(Math.random() * base * 0.3) // ±30% jitter
937
+ this.reconnectTimer = setTimeout(() => {
938
+ this.doReconnectAttempt()
939
+ this.reconnectAttempt++
940
+ }, delay)
941
+ }
942
+
943
+ private async doReconnectAttempt(): Promise<void> {
944
+ if (this.intentionalClose || this.phase === 'closed') return
945
+ try {
946
+ this.setTransportChannelHint()
947
+ await this.transport.connect()
948
+ this.sendRaw({
949
+ v: 1,
950
+ t: 'create',
951
+ ch: this.channelId,
952
+ ts: Date.now(),
953
+ from: this.pubKeyB64,
954
+ body: { meta: this.meta },
955
+ } as ProtocolMessage)
956
+ } catch {
957
+ this.scheduleReconnect()
958
+ }
959
+ }
960
+
961
+ private stopReconnect(): void {
962
+ if (this.reconnectTimer) {
963
+ clearTimeout(this.reconnectTimer)
964
+ this.reconnectTimer = null
965
+ }
966
+ }
967
+
968
+ private clearPendingAcceptTimer(): void {
969
+ if (this.pendingAcceptTimer) {
970
+ clearTimeout(this.pendingAcceptTimer)
971
+ this.pendingAcceptTimer = null
972
+ }
973
+ }
974
+
975
+ // -------------------------------------------------------------------------
976
+ // Internal: session TTL (§16 rule 16)
977
+ // -------------------------------------------------------------------------
978
+
979
+ private startSessionTtl(): void {
980
+ this.clearSessionTtl()
981
+ if (this.sessionStartTime == null) {
982
+ this.sessionStartTime = Date.now()
983
+ }
984
+ const elapsed = Date.now() - this.sessionStartTime
985
+ const remaining = Math.max(0, this.sessionTtl - elapsed)
986
+ this.sessionTtlTimer = setTimeout(() => {
987
+ this.emit('error', new Error('Session lifetime expired'))
988
+ this.close('timeout')
989
+ }, remaining)
990
+ }
991
+
992
+ private clearSessionTtl(): void {
993
+ if (this.sessionTtlTimer) {
994
+ clearTimeout(this.sessionTtlTimer)
995
+ this.sessionTtlTimer = null
996
+ }
997
+ }
998
+
999
+ private setPhase(phase: DAppPhase): void {
1000
+ if (this.phase === phase) return
1001
+ this.phase = phase
1002
+ this.emit('phase', phase)
1003
+ }
1004
+ }