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.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequence number validation tests for DAppSession and WalletSession.
|
|
3
|
+
*
|
|
4
|
+
* Covers: replay rejection, sequence gaps, persistence of recvSeq,
|
|
5
|
+
* send sequence overflow, pending accept timeout, and capabilities validation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import type { AadHeader } from './crypto.js'
|
|
10
|
+
import {
|
|
11
|
+
b64urlDecode,
|
|
12
|
+
b64urlEncode,
|
|
13
|
+
buildPairingUri,
|
|
14
|
+
computeSharedSecret,
|
|
15
|
+
deriveSessionKey,
|
|
16
|
+
generateChannelId,
|
|
17
|
+
generateX25519KeyPair,
|
|
18
|
+
sealPayload,
|
|
19
|
+
unsealPayload,
|
|
20
|
+
} from './crypto.js'
|
|
21
|
+
import { DAppSession } from './dapp-session.js'
|
|
22
|
+
import { MockRelay, MockTransport, makeJoinBody, parseSnapshot } from './test-helpers.js'
|
|
23
|
+
import type { ProtocolMessage, SessionPersistence } from './types.js'
|
|
24
|
+
import { WalletSession } from './wallet-session.js'
|
|
25
|
+
|
|
26
|
+
function wait(ms = 50): Promise<void> {
|
|
27
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ControlledPersistence implements SessionPersistence {
|
|
31
|
+
snapshots: string[] = []
|
|
32
|
+
hold = false
|
|
33
|
+
private pending: Array<() => void> = []
|
|
34
|
+
|
|
35
|
+
save(snapshot: string): void | Promise<void> {
|
|
36
|
+
this.snapshots.push(snapshot)
|
|
37
|
+
if (!this.hold) return
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
this.pending.push(resolve)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
load(): string | null {
|
|
44
|
+
return this.snapshots.length ? this.snapshots[this.snapshots.length - 1]! : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
resolveNext(): void {
|
|
48
|
+
this.pending.shift()?.()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
latest(): Record<string, unknown> {
|
|
52
|
+
return parseSnapshot(this.load() ?? '{}') as Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers to set up connected DApp + Wallet sessions
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
interface ConnectedPair {
|
|
61
|
+
dappSession: DAppSession
|
|
62
|
+
walletSession: WalletSession
|
|
63
|
+
dappTransport: MockTransport
|
|
64
|
+
walletTransport: MockTransport
|
|
65
|
+
/** Session key derived from the dApp side (same key both sides share). */
|
|
66
|
+
sessionKey: Uint8Array
|
|
67
|
+
/** The wallet's key pair (for crafting raw messages from wallet side). */
|
|
68
|
+
walletKp: ReturnType<typeof generateX25519KeyPair>
|
|
69
|
+
/** The dApp's key pair (for crafting raw messages from dApp side). */
|
|
70
|
+
dappKp: { publicKeyB64: string }
|
|
71
|
+
channelId: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function setupConnectedPair(): Promise<ConnectedPair> {
|
|
75
|
+
const dappTransport = new MockTransport()
|
|
76
|
+
const walletTransport = new MockTransport()
|
|
77
|
+
const _relay = new MockRelay(dappTransport, walletTransport)
|
|
78
|
+
|
|
79
|
+
const dappSession = new DAppSession({
|
|
80
|
+
transport: dappTransport,
|
|
81
|
+
meta: {
|
|
82
|
+
name: 'Test dApp',
|
|
83
|
+
description: 'Test',
|
|
84
|
+
url: 'https://test.com',
|
|
85
|
+
icon: 'https://test.com/icon.png',
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
const walletSession = new WalletSession({
|
|
89
|
+
transport: walletTransport,
|
|
90
|
+
capabilities: {
|
|
91
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
92
|
+
events: ['accountsChanged'],
|
|
93
|
+
chains: ['eip155:1'],
|
|
94
|
+
},
|
|
95
|
+
meta: {
|
|
96
|
+
name: 'Test Wallet',
|
|
97
|
+
description: 'Test',
|
|
98
|
+
url: 'https://test.com',
|
|
99
|
+
icon: 'https://test.com/icon.png',
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const uri = await dappSession.createPairing()
|
|
104
|
+
await walletSession.joinFromUri(uri)
|
|
105
|
+
await wait()
|
|
106
|
+
await wait()
|
|
107
|
+
|
|
108
|
+
// Derive session key from the dApp side perspective.
|
|
109
|
+
// We need the wallet's pubkey and dApp's private key — but those are internal.
|
|
110
|
+
// Instead, we extract from what the relay forwarded.
|
|
111
|
+
// The walletTransport.sent has the join message with from = wallet pubkey.
|
|
112
|
+
const walletJoinMsg = walletTransport.sent.find((m) => m.t === 'join')!
|
|
113
|
+
const walletPubB64 = walletJoinMsg.from!
|
|
114
|
+
// The dappTransport.sent has the create message with from = dApp pubkey.
|
|
115
|
+
const dappCreateMsg = dappTransport.sent.find((m) => m.t === 'create')!
|
|
116
|
+
const dappPubB64 = dappCreateMsg.from!
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
dappSession,
|
|
120
|
+
walletSession,
|
|
121
|
+
dappTransport,
|
|
122
|
+
walletTransport,
|
|
123
|
+
sessionKey: null as any, // We use sessions directly; raw key only needed for manual message tests
|
|
124
|
+
walletKp: null as any,
|
|
125
|
+
dappKp: { publicKeyB64: dappPubB64 },
|
|
126
|
+
channelId: dappSession.channelId,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set up a DAppSession with a direct MockTransport (no relay), manually
|
|
132
|
+
* driving the handshake so we can craft raw messages with specific seq numbers.
|
|
133
|
+
*/
|
|
134
|
+
function setupDAppWithManualWallet(persistence?: SessionPersistence) {
|
|
135
|
+
const transport = new MockTransport()
|
|
136
|
+
const session = new DAppSession({
|
|
137
|
+
transport,
|
|
138
|
+
meta: {
|
|
139
|
+
name: 'Test dApp',
|
|
140
|
+
description: 'Test',
|
|
141
|
+
url: 'https://test.com',
|
|
142
|
+
icon: 'https://test.com/icon.png',
|
|
143
|
+
},
|
|
144
|
+
persistence,
|
|
145
|
+
})
|
|
146
|
+
const walletKp = generateX25519KeyPair()
|
|
147
|
+
|
|
148
|
+
return { transport, session, walletKp }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function connectDAppManually(ctx: ReturnType<typeof setupDAppWithManualWallet>) {
|
|
152
|
+
const { transport, session, walletKp } = ctx
|
|
153
|
+
await session.createPairing()
|
|
154
|
+
|
|
155
|
+
// Simulate wallet join
|
|
156
|
+
transport.receive({
|
|
157
|
+
v: 1,
|
|
158
|
+
t: 'join',
|
|
159
|
+
ch: session.channelId,
|
|
160
|
+
ts: Date.now(),
|
|
161
|
+
from: walletKp.publicKeyB64,
|
|
162
|
+
body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
|
|
163
|
+
} as ProtocolMessage)
|
|
164
|
+
|
|
165
|
+
// Derive root key from wallet side. Responses/events use wallet->dApp key,
|
|
166
|
+
// which is DAppSession.recvKey after the join transcript is processed.
|
|
167
|
+
const dappPubB64 = transport.sent[0]!.from!
|
|
168
|
+
const dappPub = b64urlDecode(dappPubB64)
|
|
169
|
+
const shared = computeSharedSecret(walletKp.privateKey, dappPub)
|
|
170
|
+
deriveSessionKey(shared, session.channelId)
|
|
171
|
+
|
|
172
|
+
// Auto-accepted; simulate relay ready.connected
|
|
173
|
+
transport.receive({
|
|
174
|
+
v: 1,
|
|
175
|
+
t: 'ready',
|
|
176
|
+
ch: session.channelId,
|
|
177
|
+
ts: Date.now(),
|
|
178
|
+
from: '_adapter',
|
|
179
|
+
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
180
|
+
} as ProtocolMessage)
|
|
181
|
+
|
|
182
|
+
return { sessionKey: (session as any).recvKey as Uint8Array, dappPubB64 }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Set up a WalletSession with a direct MockTransport, manually driving the handshake.
|
|
187
|
+
*/
|
|
188
|
+
function setupWalletWithManualDApp(persistence?: SessionPersistence) {
|
|
189
|
+
const transport = new MockTransport()
|
|
190
|
+
const dappKp = generateX25519KeyPair()
|
|
191
|
+
const channelId = generateChannelId()
|
|
192
|
+
|
|
193
|
+
const session = new WalletSession({
|
|
194
|
+
transport,
|
|
195
|
+
capabilities: {
|
|
196
|
+
methods: ['wallet_getAccounts'],
|
|
197
|
+
events: [],
|
|
198
|
+
chains: ['eip155:1'],
|
|
199
|
+
},
|
|
200
|
+
meta: {
|
|
201
|
+
name: 'Test Wallet',
|
|
202
|
+
description: 'Test',
|
|
203
|
+
url: 'https://test.com',
|
|
204
|
+
icon: 'https://test.com/icon.png',
|
|
205
|
+
},
|
|
206
|
+
persistence,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
return { transport, session, dappKp, channelId }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function connectWalletManually(ctx: ReturnType<typeof setupWalletWithManualDApp>) {
|
|
213
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
214
|
+
|
|
215
|
+
const uri = buildPairingUri({
|
|
216
|
+
channelId,
|
|
217
|
+
pubkeyB64: dappKp.publicKeyB64,
|
|
218
|
+
relayUrl: 'ws://localhost:8080/v1',
|
|
219
|
+
name: 'Test dApp',
|
|
220
|
+
url: 'https://test.com',
|
|
221
|
+
icon: 'https://test.com/icon.png',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
await session.joinFromUri(uri)
|
|
225
|
+
|
|
226
|
+
// Derive root key from dApp side. Requests use dApp->wallet key,
|
|
227
|
+
// which is WalletSession.recvKey after prepareJoin().
|
|
228
|
+
const walletPubB64 = transport.sent.find((m) => m.t === 'join')!.from!
|
|
229
|
+
const walletPub = b64urlDecode(walletPubB64)
|
|
230
|
+
const shared = computeSharedSecret(dappKp.privateKey, walletPub)
|
|
231
|
+
deriveSessionKey(shared, channelId)
|
|
232
|
+
|
|
233
|
+
// Connect
|
|
234
|
+
transport.receive({
|
|
235
|
+
v: 1,
|
|
236
|
+
t: 'ready',
|
|
237
|
+
ch: channelId,
|
|
238
|
+
ts: Date.now(),
|
|
239
|
+
from: '_adapter',
|
|
240
|
+
body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
|
|
241
|
+
} as ProtocolMessage)
|
|
242
|
+
|
|
243
|
+
return { sessionKey: (session as any).recvKey as Uint8Array, walletPubB64 }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Tests
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe('Sequence validation', () => {
|
|
251
|
+
// -----------------------------------------------------------------------
|
|
252
|
+
// 1. Replay rejection on DAppSession
|
|
253
|
+
// -----------------------------------------------------------------------
|
|
254
|
+
describe('replay rejection on DAppSession', () => {
|
|
255
|
+
it('accepts seq=0, rejects replay seq=0, accepts seq=1', async () => {
|
|
256
|
+
const ctx = setupDAppWithManualWallet()
|
|
257
|
+
const { transport, session, walletKp } = ctx
|
|
258
|
+
const { sessionKey } = await connectDAppManually(ctx)
|
|
259
|
+
|
|
260
|
+
// Send request so we have a pending request for seq=0 response
|
|
261
|
+
const p0 = session.request('wallet_getAccounts')
|
|
262
|
+
await wait(20)
|
|
263
|
+
const req0 = transport.sent.find((m) => m.t === 'req') as any
|
|
264
|
+
|
|
265
|
+
// Wallet responds with seq=0 -> should be accepted
|
|
266
|
+
transport.receive({
|
|
267
|
+
v: 1,
|
|
268
|
+
t: 'res',
|
|
269
|
+
ch: session.channelId,
|
|
270
|
+
ts: Date.now(),
|
|
271
|
+
from: walletKp.publicKeyB64,
|
|
272
|
+
body: {
|
|
273
|
+
id: req0.body.id,
|
|
274
|
+
sealed: sealPayload(
|
|
275
|
+
sessionKey,
|
|
276
|
+
session.channelId,
|
|
277
|
+
0,
|
|
278
|
+
{ _ok: true, _result: ['0xabc'] },
|
|
279
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req0.body.id },
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
} as ProtocolMessage)
|
|
283
|
+
|
|
284
|
+
const result0 = await p0
|
|
285
|
+
expect(result0).toEqual(['0xabc'])
|
|
286
|
+
|
|
287
|
+
// Send another request for seq=0 replay test
|
|
288
|
+
const p1 = session.request('wallet_getAccounts')
|
|
289
|
+
await wait(20)
|
|
290
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
|
|
291
|
+
|
|
292
|
+
// Wallet responds with seq=0 again (replay) -> should be rejected
|
|
293
|
+
transport.receive({
|
|
294
|
+
v: 1,
|
|
295
|
+
t: 'res',
|
|
296
|
+
ch: session.channelId,
|
|
297
|
+
ts: Date.now(),
|
|
298
|
+
from: walletKp.publicKeyB64,
|
|
299
|
+
body: {
|
|
300
|
+
id: req1.body.id,
|
|
301
|
+
sealed: sealPayload(
|
|
302
|
+
sessionKey,
|
|
303
|
+
session.channelId,
|
|
304
|
+
0,
|
|
305
|
+
{ _ok: true, _result: ['0xreplay'] },
|
|
306
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req1.body.id },
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
} as ProtocolMessage)
|
|
310
|
+
|
|
311
|
+
await expect(p1).rejects.toThrow('Replay detected')
|
|
312
|
+
|
|
313
|
+
// Send another request for seq=1 test
|
|
314
|
+
const p2 = session.request('wallet_getAccounts')
|
|
315
|
+
await wait(20)
|
|
316
|
+
const req2 = transport.sent.filter((m) => m.t === 'req')[2] as any
|
|
317
|
+
|
|
318
|
+
// Wallet responds with seq=1 -> should be accepted
|
|
319
|
+
transport.receive({
|
|
320
|
+
v: 1,
|
|
321
|
+
t: 'res',
|
|
322
|
+
ch: session.channelId,
|
|
323
|
+
ts: Date.now(),
|
|
324
|
+
from: walletKp.publicKeyB64,
|
|
325
|
+
body: {
|
|
326
|
+
id: req2.body.id,
|
|
327
|
+
sealed: sealPayload(
|
|
328
|
+
sessionKey,
|
|
329
|
+
session.channelId,
|
|
330
|
+
1,
|
|
331
|
+
{ _ok: true, _result: ['0xdef'] },
|
|
332
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req2.body.id },
|
|
333
|
+
),
|
|
334
|
+
},
|
|
335
|
+
} as ProtocolMessage)
|
|
336
|
+
|
|
337
|
+
const result2 = await p2
|
|
338
|
+
expect(result2).toEqual(['0xdef'])
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('silently drops replayed events', async () => {
|
|
342
|
+
const ctx = setupDAppWithManualWallet()
|
|
343
|
+
const { transport, session, walletKp } = ctx
|
|
344
|
+
const { sessionKey } = await connectDAppManually(ctx)
|
|
345
|
+
|
|
346
|
+
const eventHandler = vi.fn()
|
|
347
|
+
session.on('event', eventHandler)
|
|
348
|
+
|
|
349
|
+
// First event with seq=0 -> accepted
|
|
350
|
+
transport.receive({
|
|
351
|
+
v: 1,
|
|
352
|
+
t: 'evt',
|
|
353
|
+
ch: session.channelId,
|
|
354
|
+
ts: Date.now(),
|
|
355
|
+
from: walletKp.publicKeyB64,
|
|
356
|
+
body: {
|
|
357
|
+
id: 'evt-test',
|
|
358
|
+
sealed: sealPayload(
|
|
359
|
+
sessionKey,
|
|
360
|
+
session.channelId,
|
|
361
|
+
0,
|
|
362
|
+
{ _event: 'accountsChanged', accounts: ['0xa'] },
|
|
363
|
+
{ type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
} as ProtocolMessage)
|
|
367
|
+
|
|
368
|
+
expect(eventHandler).toHaveBeenCalledTimes(1)
|
|
369
|
+
|
|
370
|
+
// Replay same event with seq=0 -> silently dropped
|
|
371
|
+
transport.receive({
|
|
372
|
+
v: 1,
|
|
373
|
+
t: 'evt',
|
|
374
|
+
ch: session.channelId,
|
|
375
|
+
ts: Date.now(),
|
|
376
|
+
from: walletKp.publicKeyB64,
|
|
377
|
+
body: {
|
|
378
|
+
id: 'evt-test',
|
|
379
|
+
sealed: sealPayload(
|
|
380
|
+
sessionKey,
|
|
381
|
+
session.channelId,
|
|
382
|
+
0,
|
|
383
|
+
{ _event: 'accountsChanged', accounts: ['0xa'] },
|
|
384
|
+
{ type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
|
|
385
|
+
),
|
|
386
|
+
},
|
|
387
|
+
} as ProtocolMessage)
|
|
388
|
+
|
|
389
|
+
expect(eventHandler).toHaveBeenCalledTimes(1) // still 1
|
|
390
|
+
|
|
391
|
+
// New event with seq=1 -> accepted
|
|
392
|
+
transport.receive({
|
|
393
|
+
v: 1,
|
|
394
|
+
t: 'evt',
|
|
395
|
+
ch: session.channelId,
|
|
396
|
+
ts: Date.now(),
|
|
397
|
+
from: walletKp.publicKeyB64,
|
|
398
|
+
body: {
|
|
399
|
+
id: 'evt-test',
|
|
400
|
+
sealed: sealPayload(
|
|
401
|
+
sessionKey,
|
|
402
|
+
session.channelId,
|
|
403
|
+
1,
|
|
404
|
+
{ _event: 'accountsChanged', accounts: ['0xb'] },
|
|
405
|
+
{ type: 'evt', from: walletKp.publicKeyB64, id: 'evt-test' },
|
|
406
|
+
),
|
|
407
|
+
},
|
|
408
|
+
} as ProtocolMessage)
|
|
409
|
+
|
|
410
|
+
expect(eventHandler).toHaveBeenCalledTimes(2)
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// -----------------------------------------------------------------------
|
|
415
|
+
// 2. Replay rejection on WalletSession
|
|
416
|
+
// -----------------------------------------------------------------------
|
|
417
|
+
describe('replay rejection on WalletSession', () => {
|
|
418
|
+
it('accepts seq=0, drops replay seq=0, accepts seq=1', async () => {
|
|
419
|
+
const ctx = setupWalletWithManualDApp()
|
|
420
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
421
|
+
const { sessionKey } = await connectWalletManually(ctx)
|
|
422
|
+
|
|
423
|
+
const requestHandler = vi.fn()
|
|
424
|
+
session.on('request', requestHandler)
|
|
425
|
+
|
|
426
|
+
// Request with seq=0 -> accepted
|
|
427
|
+
transport.receive({
|
|
428
|
+
v: 1,
|
|
429
|
+
t: 'req',
|
|
430
|
+
ch: channelId,
|
|
431
|
+
ts: Date.now(),
|
|
432
|
+
from: dappKp.publicKeyB64,
|
|
433
|
+
body: {
|
|
434
|
+
id: 'req-1',
|
|
435
|
+
sealed: sealPayload(
|
|
436
|
+
sessionKey,
|
|
437
|
+
channelId,
|
|
438
|
+
0,
|
|
439
|
+
{ _method: 'wallet_getAccounts', foo: 'bar' },
|
|
440
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'req-1' },
|
|
441
|
+
),
|
|
442
|
+
},
|
|
443
|
+
} as ProtocolMessage)
|
|
444
|
+
|
|
445
|
+
expect(requestHandler).toHaveBeenCalledTimes(1)
|
|
446
|
+
expect(requestHandler).toHaveBeenCalledWith({
|
|
447
|
+
id: 'req-1',
|
|
448
|
+
method: 'wallet_getAccounts',
|
|
449
|
+
params: { foo: 'bar' },
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// Replay same request with seq=0 -> silently dropped
|
|
453
|
+
transport.receive({
|
|
454
|
+
v: 1,
|
|
455
|
+
t: 'req',
|
|
456
|
+
ch: channelId,
|
|
457
|
+
ts: Date.now(),
|
|
458
|
+
from: dappKp.publicKeyB64,
|
|
459
|
+
body: {
|
|
460
|
+
id: 'req-1-replay',
|
|
461
|
+
sealed: sealPayload(
|
|
462
|
+
sessionKey,
|
|
463
|
+
channelId,
|
|
464
|
+
0,
|
|
465
|
+
{ _method: 'wallet_getAccounts', foo: 'bar' },
|
|
466
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'req-1-replay' },
|
|
467
|
+
),
|
|
468
|
+
},
|
|
469
|
+
} as ProtocolMessage)
|
|
470
|
+
|
|
471
|
+
expect(requestHandler).toHaveBeenCalledTimes(1) // still 1
|
|
472
|
+
|
|
473
|
+
// Request with seq=1 -> accepted
|
|
474
|
+
transport.receive({
|
|
475
|
+
v: 1,
|
|
476
|
+
t: 'req',
|
|
477
|
+
ch: channelId,
|
|
478
|
+
ts: Date.now(),
|
|
479
|
+
from: dappKp.publicKeyB64,
|
|
480
|
+
body: {
|
|
481
|
+
id: 'req-2',
|
|
482
|
+
sealed: sealPayload(
|
|
483
|
+
sessionKey,
|
|
484
|
+
channelId,
|
|
485
|
+
1,
|
|
486
|
+
{ _method: 'wallet_getAccounts', message: 'hello' },
|
|
487
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'req-2' },
|
|
488
|
+
),
|
|
489
|
+
},
|
|
490
|
+
} as ProtocolMessage)
|
|
491
|
+
|
|
492
|
+
expect(requestHandler).toHaveBeenCalledTimes(2)
|
|
493
|
+
expect(requestHandler).toHaveBeenLastCalledWith({
|
|
494
|
+
id: 'req-2',
|
|
495
|
+
method: 'wallet_getAccounts',
|
|
496
|
+
params: { message: 'hello' },
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// -----------------------------------------------------------------------
|
|
502
|
+
// 3. Sequence gaps accepted
|
|
503
|
+
// -----------------------------------------------------------------------
|
|
504
|
+
describe('sequence gaps accepted', () => {
|
|
505
|
+
it('accepts seq=0 then seq=5 (gap), rejects seq=3 (below high watermark)', async () => {
|
|
506
|
+
const ctx = setupDAppWithManualWallet()
|
|
507
|
+
const { transport, session, walletKp } = ctx
|
|
508
|
+
const { sessionKey } = await connectDAppManually(ctx)
|
|
509
|
+
|
|
510
|
+
// Request 1: seq=0
|
|
511
|
+
const p0 = session.request('wallet_getAccounts')
|
|
512
|
+
await wait(20)
|
|
513
|
+
const req0 = transport.sent.find((m) => m.t === 'req') as any
|
|
514
|
+
|
|
515
|
+
transport.receive({
|
|
516
|
+
v: 1,
|
|
517
|
+
t: 'res',
|
|
518
|
+
ch: session.channelId,
|
|
519
|
+
ts: Date.now(),
|
|
520
|
+
from: walletKp.publicKeyB64,
|
|
521
|
+
body: {
|
|
522
|
+
id: req0.body.id,
|
|
523
|
+
sealed: sealPayload(
|
|
524
|
+
sessionKey,
|
|
525
|
+
session.channelId,
|
|
526
|
+
0,
|
|
527
|
+
{ _ok: true, _result: 'first' },
|
|
528
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req0.body.id },
|
|
529
|
+
),
|
|
530
|
+
},
|
|
531
|
+
} as ProtocolMessage)
|
|
532
|
+
|
|
533
|
+
expect(await p0).toBe('first')
|
|
534
|
+
|
|
535
|
+
// Request 2: seq=5 (gap of 4) -> should be accepted
|
|
536
|
+
const p1 = session.request('wallet_getAccounts')
|
|
537
|
+
await wait(20)
|
|
538
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
|
|
539
|
+
|
|
540
|
+
transport.receive({
|
|
541
|
+
v: 1,
|
|
542
|
+
t: 'res',
|
|
543
|
+
ch: session.channelId,
|
|
544
|
+
ts: Date.now(),
|
|
545
|
+
from: walletKp.publicKeyB64,
|
|
546
|
+
body: {
|
|
547
|
+
id: req1.body.id,
|
|
548
|
+
sealed: sealPayload(
|
|
549
|
+
sessionKey,
|
|
550
|
+
session.channelId,
|
|
551
|
+
5,
|
|
552
|
+
{ _ok: true, _result: 'second' },
|
|
553
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req1.body.id },
|
|
554
|
+
),
|
|
555
|
+
},
|
|
556
|
+
} as ProtocolMessage)
|
|
557
|
+
|
|
558
|
+
expect(await p1).toBe('second')
|
|
559
|
+
|
|
560
|
+
// Request 3: seq=3 (less than current high watermark of 5) -> rejected
|
|
561
|
+
const p2 = session.request('wallet_getAccounts')
|
|
562
|
+
await wait(20)
|
|
563
|
+
const req2 = transport.sent.filter((m) => m.t === 'req')[2] as any
|
|
564
|
+
|
|
565
|
+
transport.receive({
|
|
566
|
+
v: 1,
|
|
567
|
+
t: 'res',
|
|
568
|
+
ch: session.channelId,
|
|
569
|
+
ts: Date.now(),
|
|
570
|
+
from: walletKp.publicKeyB64,
|
|
571
|
+
body: {
|
|
572
|
+
id: req2.body.id,
|
|
573
|
+
sealed: sealPayload(
|
|
574
|
+
sessionKey,
|
|
575
|
+
session.channelId,
|
|
576
|
+
3,
|
|
577
|
+
{ _ok: true, _result: 'replay-attempt' },
|
|
578
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req2.body.id },
|
|
579
|
+
),
|
|
580
|
+
},
|
|
581
|
+
} as ProtocolMessage)
|
|
582
|
+
|
|
583
|
+
await expect(p2).rejects.toThrow('Replay detected')
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('wallet session also accepts gaps and rejects below watermark', async () => {
|
|
587
|
+
const ctx = setupWalletWithManualDApp()
|
|
588
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
589
|
+
const { sessionKey } = await connectWalletManually(ctx)
|
|
590
|
+
|
|
591
|
+
const requestHandler = vi.fn()
|
|
592
|
+
session.on('request', requestHandler)
|
|
593
|
+
|
|
594
|
+
// seq=0 -> accepted
|
|
595
|
+
transport.receive({
|
|
596
|
+
v: 1,
|
|
597
|
+
t: 'req',
|
|
598
|
+
ch: channelId,
|
|
599
|
+
ts: Date.now(),
|
|
600
|
+
from: dappKp.publicKeyB64,
|
|
601
|
+
body: {
|
|
602
|
+
id: 'r1',
|
|
603
|
+
sealed: sealPayload(
|
|
604
|
+
sessionKey,
|
|
605
|
+
channelId,
|
|
606
|
+
0,
|
|
607
|
+
{ _method: 'wallet_getAccounts' },
|
|
608
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
|
|
609
|
+
),
|
|
610
|
+
},
|
|
611
|
+
} as ProtocolMessage)
|
|
612
|
+
expect(requestHandler).toHaveBeenCalledTimes(1)
|
|
613
|
+
|
|
614
|
+
// seq=5 (gap) -> accepted
|
|
615
|
+
transport.receive({
|
|
616
|
+
v: 1,
|
|
617
|
+
t: 'req',
|
|
618
|
+
ch: channelId,
|
|
619
|
+
ts: Date.now(),
|
|
620
|
+
from: dappKp.publicKeyB64,
|
|
621
|
+
body: {
|
|
622
|
+
id: 'r2',
|
|
623
|
+
sealed: sealPayload(
|
|
624
|
+
sessionKey,
|
|
625
|
+
channelId,
|
|
626
|
+
5,
|
|
627
|
+
{ _method: 'wallet_getAccounts' },
|
|
628
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'r2' },
|
|
629
|
+
),
|
|
630
|
+
},
|
|
631
|
+
} as ProtocolMessage)
|
|
632
|
+
expect(requestHandler).toHaveBeenCalledTimes(2)
|
|
633
|
+
|
|
634
|
+
// seq=3 (below 5) -> dropped
|
|
635
|
+
transport.receive({
|
|
636
|
+
v: 1,
|
|
637
|
+
t: 'req',
|
|
638
|
+
ch: channelId,
|
|
639
|
+
ts: Date.now(),
|
|
640
|
+
from: dappKp.publicKeyB64,
|
|
641
|
+
body: {
|
|
642
|
+
id: 'r3',
|
|
643
|
+
sealed: sealPayload(
|
|
644
|
+
sessionKey,
|
|
645
|
+
channelId,
|
|
646
|
+
3,
|
|
647
|
+
{ _method: 'wallet_getAccounts' },
|
|
648
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'r3' },
|
|
649
|
+
),
|
|
650
|
+
},
|
|
651
|
+
} as ProtocolMessage)
|
|
652
|
+
expect(requestHandler).toHaveBeenCalledTimes(2) // still 2
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
// -----------------------------------------------------------------------
|
|
657
|
+
// 4. Sequence persistence through serialize/restore
|
|
658
|
+
// -----------------------------------------------------------------------
|
|
659
|
+
describe('sequence persistence', () => {
|
|
660
|
+
it('DAppSession: restored session rejects replayed seq numbers', async () => {
|
|
661
|
+
const ctx = setupDAppWithManualWallet()
|
|
662
|
+
const { transport, session, walletKp } = ctx
|
|
663
|
+
const { sessionKey } = await connectDAppManually(ctx)
|
|
664
|
+
|
|
665
|
+
// Exchange messages to advance recvSeq to 2
|
|
666
|
+
for (let seq = 0; seq <= 2; seq++) {
|
|
667
|
+
const p = session.request('wallet_getAccounts')
|
|
668
|
+
await wait(20)
|
|
669
|
+
const reqs = transport.sent.filter((m) => m.t === 'req')
|
|
670
|
+
const req = reqs[reqs.length - 1] as any
|
|
671
|
+
|
|
672
|
+
transport.receive({
|
|
673
|
+
v: 1,
|
|
674
|
+
t: 'res',
|
|
675
|
+
ch: session.channelId,
|
|
676
|
+
ts: Date.now(),
|
|
677
|
+
from: walletKp.publicKeyB64,
|
|
678
|
+
body: {
|
|
679
|
+
id: req.body.id,
|
|
680
|
+
sealed: sealPayload(
|
|
681
|
+
sessionKey,
|
|
682
|
+
session.channelId,
|
|
683
|
+
seq,
|
|
684
|
+
{ _ok: true, _result: `result-${seq}` },
|
|
685
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req.body.id },
|
|
686
|
+
),
|
|
687
|
+
},
|
|
688
|
+
} as ProtocolMessage)
|
|
689
|
+
|
|
690
|
+
await p
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Serialize and restore
|
|
694
|
+
const json = session.serialize()
|
|
695
|
+
const newTransport = new MockTransport()
|
|
696
|
+
const restored = new DAppSession({
|
|
697
|
+
transport: newTransport,
|
|
698
|
+
meta: {
|
|
699
|
+
name: 'Test dApp',
|
|
700
|
+
description: 'Test',
|
|
701
|
+
url: 'https://test.com',
|
|
702
|
+
icon: 'https://test.com/icon.png',
|
|
703
|
+
},
|
|
704
|
+
})
|
|
705
|
+
expect(restored.restore(json)).toBe(true)
|
|
706
|
+
|
|
707
|
+
// Manually set phase to connected so we can send requests
|
|
708
|
+
;(restored as any).phase = 'connected'
|
|
709
|
+
|
|
710
|
+
// Try sending a request and responding with old seq=1 -> should be rejected
|
|
711
|
+
const p = restored.request('wallet_getAccounts')
|
|
712
|
+
await wait(20)
|
|
713
|
+
const reqMsg = newTransport.sent.find((m) => m.t === 'req') as any
|
|
714
|
+
|
|
715
|
+
newTransport.receive({
|
|
716
|
+
v: 1,
|
|
717
|
+
t: 'res',
|
|
718
|
+
ch: restored.channelId,
|
|
719
|
+
ts: Date.now(),
|
|
720
|
+
from: walletKp.publicKeyB64,
|
|
721
|
+
body: {
|
|
722
|
+
id: reqMsg.body.id,
|
|
723
|
+
sealed: sealPayload(
|
|
724
|
+
sessionKey,
|
|
725
|
+
restored.channelId,
|
|
726
|
+
1,
|
|
727
|
+
{ _ok: true, _result: 'stale' },
|
|
728
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: reqMsg.body.id },
|
|
729
|
+
),
|
|
730
|
+
},
|
|
731
|
+
} as ProtocolMessage)
|
|
732
|
+
|
|
733
|
+
await expect(p).rejects.toThrow('Replay detected')
|
|
734
|
+
|
|
735
|
+
// seq=3 should be accepted
|
|
736
|
+
const p2 = restored.request('wallet_getAccounts')
|
|
737
|
+
await wait(20)
|
|
738
|
+
const reqMsg2 = newTransport.sent.filter((m) => m.t === 'req')[1] as any
|
|
739
|
+
|
|
740
|
+
newTransport.receive({
|
|
741
|
+
v: 1,
|
|
742
|
+
t: 'res',
|
|
743
|
+
ch: restored.channelId,
|
|
744
|
+
ts: Date.now(),
|
|
745
|
+
from: walletKp.publicKeyB64,
|
|
746
|
+
body: {
|
|
747
|
+
id: reqMsg2.body.id,
|
|
748
|
+
sealed: sealPayload(
|
|
749
|
+
sessionKey,
|
|
750
|
+
restored.channelId,
|
|
751
|
+
3,
|
|
752
|
+
{ _ok: true, _result: 'fresh' },
|
|
753
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: reqMsg2.body.id },
|
|
754
|
+
),
|
|
755
|
+
},
|
|
756
|
+
} as ProtocolMessage)
|
|
757
|
+
|
|
758
|
+
expect(await p2).toBe('fresh')
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('WalletSession: restored session rejects replayed seq numbers', async () => {
|
|
762
|
+
const ctx = setupWalletWithManualDApp()
|
|
763
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
764
|
+
const { sessionKey } = await connectWalletManually(ctx)
|
|
765
|
+
|
|
766
|
+
const handler = vi.fn()
|
|
767
|
+
session.on('request', handler)
|
|
768
|
+
|
|
769
|
+
// Advance recvSeq to 2
|
|
770
|
+
for (let seq = 0; seq <= 2; seq++) {
|
|
771
|
+
transport.receive({
|
|
772
|
+
v: 1,
|
|
773
|
+
t: 'req',
|
|
774
|
+
ch: channelId,
|
|
775
|
+
ts: Date.now(),
|
|
776
|
+
from: dappKp.publicKeyB64,
|
|
777
|
+
body: {
|
|
778
|
+
id: `req-${seq}`,
|
|
779
|
+
sealed: sealPayload(
|
|
780
|
+
sessionKey,
|
|
781
|
+
channelId,
|
|
782
|
+
seq,
|
|
783
|
+
{ _method: 'wallet_getAccounts' },
|
|
784
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: `req-${seq}` },
|
|
785
|
+
),
|
|
786
|
+
},
|
|
787
|
+
} as ProtocolMessage)
|
|
788
|
+
}
|
|
789
|
+
expect(handler).toHaveBeenCalledTimes(3)
|
|
790
|
+
|
|
791
|
+
// Serialize and restore
|
|
792
|
+
const json = session.serialize()
|
|
793
|
+
const newTransport = new MockTransport()
|
|
794
|
+
const restored = new WalletSession({
|
|
795
|
+
transport: newTransport,
|
|
796
|
+
capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
|
|
797
|
+
meta: {
|
|
798
|
+
name: 'Test Wallet',
|
|
799
|
+
description: 'Test',
|
|
800
|
+
url: 'https://test.com',
|
|
801
|
+
icon: 'https://test.com/icon.png',
|
|
802
|
+
},
|
|
803
|
+
})
|
|
804
|
+
expect(restored.restore(json)).toBe(true)
|
|
805
|
+
|
|
806
|
+
const handler2 = vi.fn()
|
|
807
|
+
restored.on('request', handler2)
|
|
808
|
+
|
|
809
|
+
// Old seq=1 -> dropped
|
|
810
|
+
newTransport.receive({
|
|
811
|
+
v: 1,
|
|
812
|
+
t: 'req',
|
|
813
|
+
ch: channelId,
|
|
814
|
+
ts: Date.now(),
|
|
815
|
+
from: dappKp.publicKeyB64,
|
|
816
|
+
body: {
|
|
817
|
+
id: 'replay-1',
|
|
818
|
+
sealed: sealPayload(
|
|
819
|
+
sessionKey,
|
|
820
|
+
channelId,
|
|
821
|
+
1,
|
|
822
|
+
{ _method: 'wallet_getAccounts' },
|
|
823
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'replay-1' },
|
|
824
|
+
),
|
|
825
|
+
},
|
|
826
|
+
} as ProtocolMessage)
|
|
827
|
+
expect(handler2).toHaveBeenCalledTimes(0)
|
|
828
|
+
|
|
829
|
+
// seq=3 -> accepted
|
|
830
|
+
newTransport.receive({
|
|
831
|
+
v: 1,
|
|
832
|
+
t: 'req',
|
|
833
|
+
ch: channelId,
|
|
834
|
+
ts: Date.now(),
|
|
835
|
+
from: dappKp.publicKeyB64,
|
|
836
|
+
body: {
|
|
837
|
+
id: 'fresh-3',
|
|
838
|
+
sealed: sealPayload(
|
|
839
|
+
sessionKey,
|
|
840
|
+
channelId,
|
|
841
|
+
3,
|
|
842
|
+
{ _method: 'wallet_getAccounts' },
|
|
843
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'fresh-3' },
|
|
844
|
+
),
|
|
845
|
+
},
|
|
846
|
+
} as ProtocolMessage)
|
|
847
|
+
expect(handler2).toHaveBeenCalledTimes(1)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('DAppSession persists next sendSeq before sending encrypted requests', async () => {
|
|
851
|
+
const persistence = new ControlledPersistence()
|
|
852
|
+
const ctx = setupDAppWithManualWallet(persistence)
|
|
853
|
+
const { transport, session, walletKp } = ctx
|
|
854
|
+
const { sessionKey } = await connectDAppManually(ctx)
|
|
855
|
+
await wait(20)
|
|
856
|
+
|
|
857
|
+
persistence.hold = true
|
|
858
|
+
const result = session.request('wallet_getAccounts')
|
|
859
|
+
await wait(20)
|
|
860
|
+
|
|
861
|
+
expect(transport.sent.filter((m) => m.t === 'req')).toHaveLength(0)
|
|
862
|
+
expect(persistence.latest().sendSeq).toBe(1)
|
|
863
|
+
|
|
864
|
+
persistence.resolveNext()
|
|
865
|
+
await wait(20)
|
|
866
|
+
|
|
867
|
+
const reqMsg = transport.sent.find((m) => m.t === 'req') as any
|
|
868
|
+
expect(reqMsg).toBeTruthy()
|
|
869
|
+
|
|
870
|
+
persistence.hold = false
|
|
871
|
+
transport.receive({
|
|
872
|
+
v: 1,
|
|
873
|
+
t: 'res',
|
|
874
|
+
ch: session.channelId,
|
|
875
|
+
ts: Date.now(),
|
|
876
|
+
from: walletKp.publicKeyB64,
|
|
877
|
+
body: {
|
|
878
|
+
id: reqMsg.body.id,
|
|
879
|
+
sealed: sealPayload(
|
|
880
|
+
sessionKey,
|
|
881
|
+
session.channelId,
|
|
882
|
+
0,
|
|
883
|
+
{ _ok: true, _result: 'ok' },
|
|
884
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: reqMsg.body.id },
|
|
885
|
+
),
|
|
886
|
+
},
|
|
887
|
+
} as ProtocolMessage)
|
|
888
|
+
|
|
889
|
+
expect(await result).toBe('ok')
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('WalletSession persists recvSeq before emitting decrypted requests', async () => {
|
|
893
|
+
const persistence = new ControlledPersistence()
|
|
894
|
+
const ctx = setupWalletWithManualDApp(persistence)
|
|
895
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
896
|
+
const { sessionKey } = await connectWalletManually(ctx)
|
|
897
|
+
await wait(20)
|
|
898
|
+
|
|
899
|
+
const handler = vi.fn()
|
|
900
|
+
session.on('request', handler)
|
|
901
|
+
|
|
902
|
+
persistence.hold = true
|
|
903
|
+
transport.receive({
|
|
904
|
+
v: 1,
|
|
905
|
+
t: 'req',
|
|
906
|
+
ch: channelId,
|
|
907
|
+
ts: Date.now(),
|
|
908
|
+
from: dappKp.publicKeyB64,
|
|
909
|
+
body: {
|
|
910
|
+
id: 'r1',
|
|
911
|
+
sealed: sealPayload(
|
|
912
|
+
sessionKey,
|
|
913
|
+
channelId,
|
|
914
|
+
0,
|
|
915
|
+
{ _method: 'wallet_getAccounts' },
|
|
916
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
|
|
917
|
+
),
|
|
918
|
+
},
|
|
919
|
+
} as ProtocolMessage)
|
|
920
|
+
await wait(20)
|
|
921
|
+
|
|
922
|
+
expect(handler).not.toHaveBeenCalled()
|
|
923
|
+
expect(persistence.latest().recvSeq).toBe(0)
|
|
924
|
+
|
|
925
|
+
persistence.resolveNext()
|
|
926
|
+
await wait(20)
|
|
927
|
+
|
|
928
|
+
expect(handler).toHaveBeenCalledWith({
|
|
929
|
+
id: 'r1',
|
|
930
|
+
method: 'wallet_getAccounts',
|
|
931
|
+
params: {},
|
|
932
|
+
})
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
it('WalletSession persists next sendSeq before sending encrypted responses', async () => {
|
|
936
|
+
const persistence = new ControlledPersistence()
|
|
937
|
+
const ctx = setupWalletWithManualDApp(persistence)
|
|
938
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
939
|
+
const { sessionKey } = await connectWalletManually(ctx)
|
|
940
|
+
await wait(20)
|
|
941
|
+
|
|
942
|
+
transport.receive({
|
|
943
|
+
v: 1,
|
|
944
|
+
t: 'req',
|
|
945
|
+
ch: channelId,
|
|
946
|
+
ts: Date.now(),
|
|
947
|
+
from: dappKp.publicKeyB64,
|
|
948
|
+
body: {
|
|
949
|
+
id: 'r1',
|
|
950
|
+
sealed: sealPayload(
|
|
951
|
+
sessionKey,
|
|
952
|
+
channelId,
|
|
953
|
+
0,
|
|
954
|
+
{ _method: 'wallet_getAccounts' },
|
|
955
|
+
{ type: 'req', from: dappKp.publicKeyB64, id: 'r1' },
|
|
956
|
+
),
|
|
957
|
+
},
|
|
958
|
+
} as ProtocolMessage)
|
|
959
|
+
await wait(20)
|
|
960
|
+
|
|
961
|
+
persistence.hold = true
|
|
962
|
+
const sent = session.approve('r1', ['0x123'])
|
|
963
|
+
await wait(20)
|
|
964
|
+
|
|
965
|
+
expect(transport.sent.filter((m) => m.t === 'res')).toHaveLength(0)
|
|
966
|
+
expect(persistence.latest().sendSeq).toBe(1)
|
|
967
|
+
|
|
968
|
+
persistence.resolveNext()
|
|
969
|
+
expect(await sent).toBe(true)
|
|
970
|
+
await wait(20)
|
|
971
|
+
|
|
972
|
+
expect(transport.sent.find((m) => m.t === 'res')).toBeTruthy()
|
|
973
|
+
})
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// -----------------------------------------------------------------------
|
|
977
|
+
// 5. Send sequence overflow
|
|
978
|
+
// -----------------------------------------------------------------------
|
|
979
|
+
describe('send sequence overflow', () => {
|
|
980
|
+
it('DAppSession closes on send sequence overflow', async () => {
|
|
981
|
+
const ctx = setupDAppWithManualWallet()
|
|
982
|
+
const { transport, session } = ctx
|
|
983
|
+
await connectDAppManually(ctx)
|
|
984
|
+
|
|
985
|
+
// Set sendSeq to the last allowed sealed message.
|
|
986
|
+
;(session as any).sendSeq = 2 ** 31 - 1
|
|
987
|
+
|
|
988
|
+
const errorHandler = vi.fn()
|
|
989
|
+
session.on('error', errorHandler)
|
|
990
|
+
|
|
991
|
+
// First request with params uses the last allowed sequence number.
|
|
992
|
+
// This will be left pending and rejected when session closes, so catch it.
|
|
993
|
+
const p1 = session.request('wallet_getAccounts', { test: true }).catch(() => {})
|
|
994
|
+
await wait(20)
|
|
995
|
+
// p1 was sent successfully; sendSeq is now at the protocol limit.
|
|
996
|
+
|
|
997
|
+
// Second request would exceed the protocol limit.
|
|
998
|
+
const p2 = session.request('wallet_getAccounts', { test: true })
|
|
999
|
+
await expect(p2).rejects.toThrow('Send sequence overflow')
|
|
1000
|
+
expect(errorHandler).toHaveBeenCalled()
|
|
1001
|
+
expect(session.phase).toBe('closed')
|
|
1002
|
+
await p1 // ensure the suppressed rejection is settled
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it('WalletSession closes on send sequence overflow via approve', async () => {
|
|
1006
|
+
const ctx = setupWalletWithManualDApp()
|
|
1007
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
1008
|
+
await connectWalletManually(ctx)
|
|
1009
|
+
|
|
1010
|
+
// Set sendSeq to the last allowed sealed message.
|
|
1011
|
+
;(session as any).sendSeq = 2 ** 31 - 1
|
|
1012
|
+
|
|
1013
|
+
const errorHandler = vi.fn()
|
|
1014
|
+
session.on('error', errorHandler)
|
|
1015
|
+
|
|
1016
|
+
// First approve uses the last allowed sequence number.
|
|
1017
|
+
session.approve('r1', ['0x123'])
|
|
1018
|
+
|
|
1019
|
+
// Second approve exceeds the protocol limit and closes the session.
|
|
1020
|
+
session.approve('r2', ['0x456'])
|
|
1021
|
+
|
|
1022
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
1023
|
+
expect.objectContaining({
|
|
1024
|
+
message: expect.stringContaining('Send sequence overflow'),
|
|
1025
|
+
}),
|
|
1026
|
+
)
|
|
1027
|
+
expect(session.phase).toBe('closed')
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
it('WalletSession closes on send sequence overflow via pushEvent', async () => {
|
|
1031
|
+
const ctx = setupWalletWithManualDApp()
|
|
1032
|
+
const { session } = ctx
|
|
1033
|
+
await connectWalletManually(ctx)
|
|
1034
|
+
|
|
1035
|
+
;(session as any).sendSeq = 2 ** 31 - 1
|
|
1036
|
+
|
|
1037
|
+
const errorHandler = vi.fn()
|
|
1038
|
+
session.on('error', errorHandler)
|
|
1039
|
+
|
|
1040
|
+
// First push: ok
|
|
1041
|
+
session.pushEvent('accountsChanged', { accounts: ['0xa'] })
|
|
1042
|
+
expect(session.phase).toBe('connected')
|
|
1043
|
+
|
|
1044
|
+
// Second push: overflow
|
|
1045
|
+
session.pushEvent('accountsChanged', { accounts: ['0xb'] })
|
|
1046
|
+
expect(errorHandler).toHaveBeenCalled()
|
|
1047
|
+
expect(session.phase).toBe('closed')
|
|
1048
|
+
})
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
// -----------------------------------------------------------------------
|
|
1052
|
+
// 6. Pending accept timeout
|
|
1053
|
+
// -----------------------------------------------------------------------
|
|
1054
|
+
describe('pending accept timeout', () => {
|
|
1055
|
+
beforeEach(() => {
|
|
1056
|
+
vi.useFakeTimers()
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
afterEach(() => {
|
|
1060
|
+
vi.useRealTimers()
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
it('first-time join is auto-accepted (pending_accept phase is brief)', async () => {
|
|
1064
|
+
const transport = new MockTransport()
|
|
1065
|
+
const session = new DAppSession({
|
|
1066
|
+
transport,
|
|
1067
|
+
meta: {
|
|
1068
|
+
name: 'Test dApp',
|
|
1069
|
+
description: 'Test',
|
|
1070
|
+
url: 'https://test.com',
|
|
1071
|
+
icon: 'https://test.com/icon.png',
|
|
1072
|
+
},
|
|
1073
|
+
})
|
|
1074
|
+
const walletKp = generateX25519KeyPair()
|
|
1075
|
+
|
|
1076
|
+
await session.createPairing()
|
|
1077
|
+
|
|
1078
|
+
const phases: string[] = []
|
|
1079
|
+
session.on('phase', (p) => phases.push(p))
|
|
1080
|
+
|
|
1081
|
+
// Simulate wallet join with valid sealed_join
|
|
1082
|
+
transport.receive({
|
|
1083
|
+
v: 1,
|
|
1084
|
+
t: 'join',
|
|
1085
|
+
ch: session.channelId,
|
|
1086
|
+
ts: Date.now(),
|
|
1087
|
+
from: walletKp.publicKeyB64,
|
|
1088
|
+
body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
|
|
1089
|
+
} as ProtocolMessage)
|
|
1090
|
+
|
|
1091
|
+
// Session enters pending_accept briefly, then auto-accept sends accept
|
|
1092
|
+
expect(phases).toContain('pending_accept')
|
|
1093
|
+
|
|
1094
|
+
// Should have sent accept immediately
|
|
1095
|
+
const acceptMsg = transport.sent.find((m) => m.t === 'accept')
|
|
1096
|
+
expect(acceptMsg).toBeTruthy()
|
|
1097
|
+
|
|
1098
|
+
// Advance time — should not timeout or close
|
|
1099
|
+
vi.advanceTimersByTime(61_000)
|
|
1100
|
+
expect(session.phase).not.toBe('closed')
|
|
1101
|
+
})
|
|
1102
|
+
})
|
|
1103
|
+
|
|
1104
|
+
// -----------------------------------------------------------------------
|
|
1105
|
+
// 7. Capabilities validation
|
|
1106
|
+
// -----------------------------------------------------------------------
|
|
1107
|
+
describe('capabilities validation', () => {
|
|
1108
|
+
it('rejects initial join with no sealed_join', async () => {
|
|
1109
|
+
const transport = new MockTransport()
|
|
1110
|
+
const session = new DAppSession({
|
|
1111
|
+
transport,
|
|
1112
|
+
meta: {
|
|
1113
|
+
name: 'Test dApp',
|
|
1114
|
+
description: 'Test',
|
|
1115
|
+
url: 'https://test.com',
|
|
1116
|
+
icon: 'https://test.com/icon.png',
|
|
1117
|
+
},
|
|
1118
|
+
})
|
|
1119
|
+
const walletKp = generateX25519KeyPair()
|
|
1120
|
+
|
|
1121
|
+
await session.createPairing()
|
|
1122
|
+
const errorHandler = vi.fn()
|
|
1123
|
+
session.on('error', errorHandler)
|
|
1124
|
+
|
|
1125
|
+
transport.receive({
|
|
1126
|
+
v: 1,
|
|
1127
|
+
t: 'join',
|
|
1128
|
+
ch: session.channelId,
|
|
1129
|
+
ts: Date.now(),
|
|
1130
|
+
from: walletKp.publicKeyB64,
|
|
1131
|
+
body: { sealed_join: null },
|
|
1132
|
+
} as ProtocolMessage)
|
|
1133
|
+
|
|
1134
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
1135
|
+
expect.objectContaining({
|
|
1136
|
+
message: expect.stringContaining('missing sealed_join'),
|
|
1137
|
+
}),
|
|
1138
|
+
)
|
|
1139
|
+
const closeMsg = transport.sent.find((m) => m.t === 'close')
|
|
1140
|
+
expect((closeMsg as any).body.reason).toBe('protocol_error')
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
it('rejects join with invalid sealed_join (decryption failure)', async () => {
|
|
1144
|
+
const transport = new MockTransport()
|
|
1145
|
+
const session = new DAppSession({
|
|
1146
|
+
transport,
|
|
1147
|
+
meta: {
|
|
1148
|
+
name: 'Test dApp',
|
|
1149
|
+
description: 'Test',
|
|
1150
|
+
url: 'https://test.com',
|
|
1151
|
+
icon: 'https://test.com/icon.png',
|
|
1152
|
+
},
|
|
1153
|
+
})
|
|
1154
|
+
const walletKp = generateX25519KeyPair()
|
|
1155
|
+
|
|
1156
|
+
await session.createPairing()
|
|
1157
|
+
|
|
1158
|
+
const errorHandler = vi.fn()
|
|
1159
|
+
session.on('error', errorHandler)
|
|
1160
|
+
|
|
1161
|
+
transport.receive({
|
|
1162
|
+
v: 1,
|
|
1163
|
+
t: 'join',
|
|
1164
|
+
ch: session.channelId,
|
|
1165
|
+
ts: Date.now(),
|
|
1166
|
+
from: walletKp.publicKeyB64,
|
|
1167
|
+
body: { sealed_join: 'invalid-ciphertext' },
|
|
1168
|
+
} as ProtocolMessage)
|
|
1169
|
+
|
|
1170
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
1171
|
+
expect.objectContaining({
|
|
1172
|
+
message: expect.stringContaining('Failed to decrypt sealed_join'),
|
|
1173
|
+
}),
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
// Should have sent a close with decryption_failed
|
|
1177
|
+
const closeMsg = transport.sent.find((m) => m.t === 'close')
|
|
1178
|
+
expect(closeMsg).toBeTruthy()
|
|
1179
|
+
expect((closeMsg as any).body.reason).toBe('decryption_failed')
|
|
1180
|
+
|
|
1181
|
+
// Should NOT have transitioned to pending_accept
|
|
1182
|
+
expect(session.phase).not.toBe('pending_accept')
|
|
1183
|
+
})
|
|
1184
|
+
})
|
|
1185
|
+
})
|