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,505 @@
1
+ /**
2
+ * Adversarial tests: Malicious DApp
3
+ *
4
+ * Simulates a malicious dApp attempting to abuse the wallet through
5
+ * protocol violations: request flooding, out-of-state messages,
6
+ * oversized payloads, and capability violations.
7
+ *
8
+ * These tests verify that the WalletSession enforces protocol rules
9
+ * correctly, protecting the wallet user from a compromised or
10
+ * malicious dApp.
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { DAppSession } from '../../dapp-session.js';
15
+ import { WalletSession } from '../../wallet-session.js';
16
+ import { MockTransport, MockRelay, makeJoinBody } from '../../test-helpers.js';
17
+ import {
18
+ generateX25519KeyPair,
19
+ generateChannelId,
20
+ computeSharedSecret,
21
+ deriveSessionKey,
22
+ deriveDirectionalSessionKeys,
23
+ sealPayload,
24
+ unsealPayload,
25
+ b64urlEncode,
26
+ b64urlDecode,
27
+ buildPairingUri,
28
+ } from '../../crypto.js';
29
+ import type { ProtocolMessage, Capabilities } from '../../types.js';
30
+
31
+ function wait(ms = 50): Promise<void> {
32
+ return new Promise((r) => setTimeout(r, ms));
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers: manual wallet setup for adversarial scenarios
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function setupWalletManual(caps?: Capabilities) {
40
+ const transport = new MockTransport();
41
+ const dappKp = generateX25519KeyPair();
42
+ const channelId = generateChannelId();
43
+ const session = new WalletSession({
44
+ transport,
45
+ meta: { name: 'W', description: 'Wallet', url: 'https://wallet.test', icon: 'https://wallet.test/i.png' },
46
+ capabilities: caps ?? {
47
+ methods: ['wallet_getAccounts', 'wallet_signMessage'],
48
+ events: ['accountsChanged'],
49
+ chains: ['eip155:1'],
50
+ },
51
+ });
52
+ return { transport, session, dappKp, channelId };
53
+ }
54
+
55
+ async function connectWalletManual(ctx: ReturnType<typeof setupWalletManual>) {
56
+ const { transport, session, dappKp, channelId } = ctx;
57
+ const uri = buildPairingUri({
58
+ channelId,
59
+ pubkeyB64: dappKp.publicKeyB64,
60
+ relayUrl: 'ws://localhost/v1',
61
+ name: 'Evil dApp', url: 'https://evil.test', icon: 'https://evil.test/i.png',
62
+ });
63
+ await session.joinFromUri(uri);
64
+
65
+ transport.receive({
66
+ v: 1, t: 'ready', ch: channelId,
67
+ ts: Date.now(), from: '_adapter',
68
+ body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
69
+ } as ProtocolMessage);
70
+
71
+ // Derive the dApp's send key (dappToWalletKey) to craft malicious requests.
72
+ // The wallet's public key is in the join message's "from" field.
73
+ const walletPubB64 = transport.sent.find(m => m.t === 'join')!.from!;
74
+ const walletPubKey = b64urlDecode(walletPubB64);
75
+ const shared = computeSharedSecret(dappKp.privateKey, walletPubKey);
76
+ const rootKey = deriveSessionKey(shared, channelId);
77
+ const ctx2 = {
78
+ dappPubKeyB64: dappKp.publicKeyB64,
79
+ walletPubKeyB64: walletPubB64,
80
+ capabilities: (session as any).effectiveCapabilities,
81
+ walletMeta: (session as any).meta,
82
+ dappName: 'Evil dApp',
83
+ };
84
+ const keys = deriveDirectionalSessionKeys(rootKey, channelId, ctx2);
85
+ shared.fill(0);
86
+ rootKey.fill(0);
87
+
88
+ return {
89
+ sendKey: keys.dappToWalletKey, // dApp -> wallet encryption key
90
+ walletPubB64,
91
+ dappPubB64: dappKp.publicKeyB64,
92
+ };
93
+ }
94
+
95
+ function craftReq(
96
+ sendKey: Uint8Array,
97
+ channelId: string,
98
+ dappPubB64: string,
99
+ seq: number,
100
+ id: string,
101
+ method: string,
102
+ params?: Record<string, unknown>,
103
+ ): ProtocolMessage {
104
+ const payload = { _method: method, ...(params ?? {}) };
105
+ const hdr = { type: 'req' as const, from: dappPubB64, id };
106
+ const sealed = sealPayload(sendKey, channelId, seq, payload, hdr);
107
+ return {
108
+ v: 1, t: 'req', ch: channelId,
109
+ ts: Date.now(), from: dappPubB64,
110
+ body: { id, sealed },
111
+ } as ProtocolMessage;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Attack 1: DApp sends >32 pending requests (rate limiting)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('Malicious DApp: Request flooding (>32 pending)', () => {
119
+ it('wallet responds with rate_limited error when >32 pending requests', async () => {
120
+ // ATTACK: A malicious dApp floods the wallet with many concurrent
121
+ // requests to exhaust wallet resources or overwhelm the user with
122
+ // approval dialogs.
123
+ //
124
+ // PREVENTS: Resource exhaustion on the wallet side. Section 15 rule 11
125
+ // limits pending requests to 32 per channel.
126
+
127
+ const ctx = setupWalletManual();
128
+ const { transport, session, channelId } = ctx;
129
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
130
+
131
+ // Do NOT handle requests (let them pile up as pending)
132
+ const requests: Array<{ id: string; method: string }> = [];
133
+ session.on('request', (req) => requests.push(req));
134
+
135
+ // Send 32 requests one at a time, waiting for each to be processed.
136
+ // The transport.receive() is synchronous — it invokes handleMessage
137
+ // directly, which processes the request synchronously.
138
+ for (let i = 0; i < 32; i++) {
139
+ transport.receive(
140
+ craftReq(sendKey, channelId, dappPubB64, i, `req-${i}`, 'wallet_getAccounts'),
141
+ );
142
+ }
143
+ // All 32 should have been emitted as requests
144
+ expect(requests).toHaveLength(32);
145
+
146
+ // 33rd request should be rate-limited
147
+ transport.receive(
148
+ craftReq(sendKey, channelId, dappPubB64, 32, 'req-overflow', 'wallet_getAccounts'),
149
+ );
150
+
151
+ // Wallet should have sent a res with rate_limited error (NOT close the channel)
152
+ const rateLimitedRes = transport.sent.find(m =>
153
+ m.t === 'res' && (m as any).body?.id === 'req-overflow',
154
+ ) as any;
155
+ expect(rateLimitedRes).toBeTruthy();
156
+
157
+ // Verify the session is still alive (do NOT close for rate_limited)
158
+ expect(session.phase).toBe('connected'); // NOT closed!
159
+ expect(requests).toHaveLength(32); // 33rd was NOT emitted
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Attack 2: DApp sends req before ready.connected
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe('Malicious DApp: Request before connected', () => {
168
+ it('wallet ignores req received before ready.connected', async () => {
169
+ // ATTACK: DApp sends encrypted request before the channel reaches
170
+ // connected state. The wallet should not process requests until
171
+ // ready.connected is received.
172
+ //
173
+ // PREVENTS: Out-of-order message processing that could bypass
174
+ // handshake security (Section 15 rule 7).
175
+
176
+ const ctx = setupWalletManual();
177
+ const { transport, session, dappKp, channelId } = ctx;
178
+
179
+ const requestHandler = vi.fn();
180
+ session.on('request', requestHandler);
181
+
182
+ const uri = buildPairingUri({
183
+ channelId,
184
+ pubkeyB64: dappKp.publicKeyB64,
185
+ relayUrl: 'ws://localhost/v1',
186
+ name: 'D', url: 'https://d.test', icon: 'https://d.test/i.png',
187
+ });
188
+ await session.joinFromUri(uri);
189
+ // At this point, wallet is in waiting_accept, NOT connected
190
+
191
+ // DApp sends req before ready.connected
192
+ transport.receive({
193
+ v: 1, t: 'req', ch: channelId,
194
+ ts: Date.now(), from: dappKp.publicKeyB64,
195
+ body: { id: 'premature-req', sealed: 'fake-sealed-data' },
196
+ } as ProtocolMessage);
197
+
198
+ await wait();
199
+
200
+ // Request should NOT have been processed (recvKey is set but phase check
201
+ // happens via from matching — the wallet will try to decrypt and fail
202
+ // because sealed data is invalid, or the request is dropped)
203
+ expect(requestHandler).not.toHaveBeenCalled();
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Attack 3: DApp sends req after close
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('Malicious DApp: Request after close', () => {
212
+ it('wallet does not send responses after session is destroyed', async () => {
213
+ // ATTACK: DApp continues to send requests after the channel has
214
+ // been closed, hoping to get the wallet to process them.
215
+ //
216
+ // PREVENTS: Post-close message processing (Section 15 rule 9).
217
+ // After destroy(), all keys are zeroed and the session is cleaned up.
218
+ // Even if a message somehow reaches the handler, it cannot be
219
+ // decrypted (keys are zeroed) and no response can be sent.
220
+
221
+ const ctx = setupWalletManual();
222
+ const { transport, session, channelId } = ctx;
223
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
224
+
225
+ // Destroy the session (close + key erasure)
226
+ session.destroy();
227
+ expect(session.phase).toBe('closed');
228
+
229
+ const sentBefore = transport.sent.length;
230
+
231
+ // DApp sends req after destroy — keys are zeroed, decryption fails
232
+ transport.receive(
233
+ craftReq(sendKey, channelId, dappPubB64, 0, 'post-close-req', 'wallet_getAccounts'),
234
+ );
235
+
236
+ await wait();
237
+
238
+ // No new messages should have been sent (cannot encrypt response
239
+ // because sendKey was zeroed)
240
+ const newMessages = transport.sent.slice(sentBefore);
241
+ const resMessages = newMessages.filter(m => m.t === 'res');
242
+ expect(resMessages).toHaveLength(0);
243
+ });
244
+
245
+ it('close followed by receive via close message stops further processing', async () => {
246
+ // When the wallet receives a close message from the peer, it
247
+ // transitions to 'closed' and sets intentionalClose. Subsequent
248
+ // messages on the transport should be ignored or fail gracefully.
249
+
250
+ const dappTransport = new MockTransport();
251
+ const walletTransport = new MockTransport();
252
+ const _relay = new MockRelay(dappTransport, walletTransport);
253
+
254
+ const dappSession = new DAppSession({
255
+ transport: dappTransport,
256
+ meta: { name: 'D', description: 'D', url: 'https://d.test', icon: 'https://d.test/i.png' },
257
+ });
258
+ const walletSession = new WalletSession({
259
+ transport: walletTransport,
260
+ capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
261
+ meta: { name: 'W', description: 'W', url: 'https://w.test', icon: 'https://w.test/i.png' },
262
+ });
263
+
264
+ const uri = await dappSession.createPairing();
265
+ await walletSession.joinFromUri(uri);
266
+ await wait();
267
+ await wait();
268
+
269
+ expect(walletSession.phase).toBe('connected');
270
+
271
+ // DApp closes the session
272
+ dappSession.close();
273
+ await wait();
274
+
275
+ // Wallet should now be closed
276
+ expect(walletSession.phase).toBe('closed');
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Attack 4: DApp retries request with same ID but different params
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe('Malicious DApp: Duplicate request ID with different params', () => {
285
+ it('wallet returns invalid_params when same req.id has different payload', async () => {
286
+ // ATTACK: DApp sends a request, then retries with the same request ID
287
+ // but different parameters — hoping to trick the wallet into
288
+ // executing a different operation while reusing the same req.id
289
+ // (e.g., changing the transaction recipient on retry).
290
+ //
291
+ // PREVENTS: Request parameter substitution attacks. Section 9.1
292
+ // requires constant-time params hash comparison.
293
+
294
+ const ctx = setupWalletManual();
295
+ const { transport, session, channelId } = ctx;
296
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
297
+
298
+ session.on('request', ({ id }) => {
299
+ session.approve(id, 'approved');
300
+ });
301
+
302
+ // First request with id "req-1"
303
+ transport.receive(
304
+ craftReq(sendKey, channelId, dappPubB64, 0, 'req-1', 'wallet_signMessage', { message: 'hello' }),
305
+ );
306
+ await wait();
307
+
308
+ // Retry with same id "req-1" but DIFFERENT params
309
+ transport.receive(
310
+ craftReq(sendKey, channelId, dappPubB64, 1, 'req-1', 'wallet_signMessage', { message: 'send_all_funds' }),
311
+ );
312
+ await wait();
313
+
314
+ // Wallet should have sent a rejection response for the second attempt
315
+ // Find the response messages
316
+ const responses = transport.sent.filter(m => m.t === 'res');
317
+ expect(responses.length).toBeGreaterThanOrEqual(2);
318
+
319
+ // The session must still be alive (do NOT close for invalid_params)
320
+ expect(session.phase).toBe('connected');
321
+ });
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Attack 5: DApp sends message >64KB
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe('Malicious DApp: Oversized message', () => {
329
+ it('DAppSession emits error and drops messages exceeding 64KB', async () => {
330
+ // ATTACK: DApp crafts an extremely large request to overwhelm
331
+ // the wallet or relay.
332
+ //
333
+ // PREVENTS: Resource exhaustion via oversized payloads.
334
+ // Section 15 rule 10: max 64 KB on the wire.
335
+ //
336
+ // We test the sendRaw() guard directly: the session emits an error
337
+ // and the message is NOT actually delivered to the transport.
338
+
339
+ const ctx = setupWalletManual();
340
+ const { transport, session, channelId } = ctx;
341
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
342
+
343
+ const requestHandler = vi.fn();
344
+ session.on('request', requestHandler);
345
+
346
+ // Craft a request with a payload that when JSON-serialized
347
+ // would exceed 64 KB (the protocol message envelope adds overhead)
348
+ const hugeData = 'x'.repeat(80_000);
349
+ const payload = { _method: 'wallet_signMessage', data: hugeData };
350
+ const hdr = { type: 'req' as const, from: dappPubB64, id: 'huge-req' };
351
+ const sealed = sealPayload(sendKey, channelId, 0, payload, hdr);
352
+
353
+ // The total message JSON will be > 64KB
354
+ const msg = {
355
+ v: 1, t: 'req', ch: channelId,
356
+ ts: Date.now(), from: dappPubB64,
357
+ body: { id: 'huge-req', sealed },
358
+ } as ProtocolMessage;
359
+
360
+ const msgSize = new TextEncoder().encode(JSON.stringify(msg)).length;
361
+ // Verify our test setup actually exceeds the limit
362
+ expect(msgSize).toBeGreaterThan(65536);
363
+
364
+ // The wallet receives this directly (bypassing its own sendRaw check).
365
+ // The wallet will try to process it. The relay should have blocked it,
366
+ // but if it reaches the wallet, the wallet processes it normally
367
+ // (the 64KB check is a send-side guard, not a receive-side guard).
368
+ // The key security property is that the SENDER enforces the limit.
369
+ transport.receive(msg);
370
+ await wait();
371
+
372
+ // For a send-side test, verify DAppSession's sendRaw blocks oversized messages
373
+ const dappTransport = new MockTransport();
374
+ const dappSession = new DAppSession({
375
+ transport: dappTransport,
376
+ meta: { name: 'D', description: 'D', url: 'https://d.test', icon: 'https://d.test/i.png' },
377
+ });
378
+
379
+ const dappErrors: Error[] = [];
380
+ dappSession.on('error', (e) => dappErrors.push(e));
381
+
382
+ // Simulate connected state
383
+ await dappSession.createPairing();
384
+ const walletKp = generateX25519KeyPair();
385
+ dappTransport.receive({
386
+ v: 1, t: 'join', ch: dappSession.channelId,
387
+ ts: Date.now(), from: walletKp.publicKeyB64,
388
+ body: makeJoinBody(dappSession.channelId, dappTransport.sent[0]!.from!, walletKp),
389
+ } as ProtocolMessage);
390
+ dappTransport.receive({
391
+ v: 1, t: 'ready', ch: dappSession.channelId,
392
+ ts: Date.now(), from: '_adapter',
393
+ body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
394
+ } as ProtocolMessage);
395
+
396
+ const sentBefore = dappTransport.sent.length;
397
+
398
+ // Try to send a huge request — sendRaw should catch the 64KB limit
399
+ // and emit an error instead of sending
400
+ dappSession.request('wallet_signMessage', { data: hugeData });
401
+ await wait();
402
+
403
+ // The error should have been emitted
404
+ expect(dappErrors.some(e => e.message.includes('64 KB'))).toBe(true);
405
+ });
406
+ });
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Attack 6: DApp calls method not in capabilities
410
+ // ---------------------------------------------------------------------------
411
+
412
+ describe('Malicious DApp: Capability violation', () => {
413
+ it('wallet rejects method not in capabilities with unsupported_method', async () => {
414
+ // ATTACK: DApp calls a method that the wallet did not grant in its
415
+ // capabilities, hoping to access restricted functionality.
416
+ //
417
+ // PREVENTS: Unauthorized method access. Section 7.1 runtime
418
+ // enforcement requires wallet to reject with unsupported_method.
419
+
420
+ const ctx = setupWalletManual({
421
+ methods: ['wallet_getAccounts'], // only getAccounts, NOT signMessage
422
+ events: [],
423
+ chains: ['eip155:1'],
424
+ });
425
+ const { transport, session, channelId } = ctx;
426
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
427
+
428
+ const requestHandler = vi.fn();
429
+ session.on('request', requestHandler);
430
+
431
+ // DApp tries to call wallet_signMessage which is NOT in capabilities
432
+ transport.receive(
433
+ craftReq(sendKey, channelId, dappPubB64, 0, 'req-evil', 'wallet_signMessage', { message: 'hack' }),
434
+ );
435
+ await wait();
436
+
437
+ // Request should NOT have been emitted to the application
438
+ expect(requestHandler).not.toHaveBeenCalled();
439
+
440
+ // Wallet should have sent an error response
441
+ const rejectRes = transport.sent.find(m =>
442
+ m.t === 'res' && (m as any).body?.id === 'req-evil',
443
+ );
444
+ expect(rejectRes).toBeTruthy();
445
+
446
+ // Session must remain open (do NOT close for unsupported_method)
447
+ expect(session.phase).toBe('connected');
448
+ });
449
+
450
+ it('wallet rejects completely unknown method', async () => {
451
+ const ctx = setupWalletManual({
452
+ methods: ['wallet_getAccounts'],
453
+ events: [],
454
+ chains: ['eip155:1'],
455
+ });
456
+ const { transport, session, channelId } = ctx;
457
+ const { sendKey, dappPubB64 } = await connectWalletManual(ctx);
458
+
459
+ const requestHandler = vi.fn();
460
+ session.on('request', requestHandler);
461
+
462
+ transport.receive(
463
+ craftReq(sendKey, channelId, dappPubB64, 0, 'req-unknown', 'evil_drainWallet'),
464
+ );
465
+ await wait();
466
+
467
+ expect(requestHandler).not.toHaveBeenCalled();
468
+ expect(session.phase).toBe('connected');
469
+ });
470
+ });
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Attack 7: DApp sends req with _adapter spoofed from
474
+ // ---------------------------------------------------------------------------
475
+
476
+ describe('Malicious DApp: Spoofed _adapter from', () => {
477
+ it('wallet rejects req with from="_adapter"', async () => {
478
+ // ATTACK: DApp (or relay) sends a req with from="_adapter" to
479
+ // confuse the wallet. Section 2 requires peers to reject any
480
+ // peer-sent message where from equals "_adapter".
481
+ //
482
+ // PREVENTS: Adapter impersonation in peer message types.
483
+
484
+ const ctx = setupWalletManual();
485
+ const { transport, session, channelId } = ctx;
486
+ await connectWalletManual(ctx);
487
+
488
+ const errorHandler = vi.fn();
489
+ session.on('error', errorHandler);
490
+ const requestHandler = vi.fn();
491
+ session.on('request', requestHandler);
492
+
493
+ transport.receive({
494
+ v: 1, t: 'req', ch: channelId,
495
+ ts: Date.now(), from: '_adapter', // spoofed!
496
+ body: { id: 'spoofed-req', sealed: 'fake' },
497
+ } as ProtocolMessage);
498
+
499
+ await wait();
500
+
501
+ expect(errorHandler).toHaveBeenCalled();
502
+ expect(errorHandler.mock.calls[0]?.[0]?.message).toContain('spoofed _adapter');
503
+ expect(requestHandler).not.toHaveBeenCalled();
504
+ });
505
+ });