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,528 @@
1
+ /**
2
+ * Adversarial tests: Malicious Relay
3
+ *
4
+ * Simulates a compromised relay attempting to break WalletPair protocol
5
+ * security guarantees. The relay sees routing metadata (ch, from, t, ts)
6
+ * but MUST NOT be able to read, forge, or replay application data.
7
+ *
8
+ * Threat model reference: Protocol spec Section 19.
9
+ */
10
+
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { DAppSession } from '../../dapp-session.js';
13
+ import { WalletSession } from '../../wallet-session.js';
14
+ import { MockTransport, MockRelay, makeJoinBody, makeSealedJoin } from '../../test-helpers.js';
15
+ import {
16
+ generateX25519KeyPair,
17
+ generateChannelId,
18
+ computeSharedSecret,
19
+ deriveSessionKey,
20
+ deriveJoinEncryptionKey,
21
+ deriveDirectionalSessionKeys,
22
+ sealPayload,
23
+ unsealPayload,
24
+ sealJoin,
25
+ b64urlEncode,
26
+ b64urlDecode,
27
+ buildPairingUri,
28
+ parsePairingUri,
29
+ } from '../../crypto.js';
30
+ import type { ProtocolMessage } from '../../types.js';
31
+
32
+ function wait(ms = 50): Promise<void> {
33
+ return new Promise((r) => setTimeout(r, ms));
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers for manual session setup (from security.test.ts patterns)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function setupDAppManual() {
41
+ const transport = new MockTransport();
42
+ const session = new DAppSession({
43
+ transport,
44
+ meta: { name: 'Test', description: 'Test dApp', url: 'https://test.com', icon: 'https://test.com/icon.png' },
45
+ });
46
+ const walletKp = generateX25519KeyPair();
47
+ return { transport, session, walletKp };
48
+ }
49
+
50
+ async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
51
+ const { transport, session, walletKp } = ctx;
52
+ await session.createPairing();
53
+
54
+ transport.receive({
55
+ v: 1, t: 'join', ch: session.channelId,
56
+ ts: Date.now(), from: walletKp.publicKeyB64,
57
+ body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
58
+ } as ProtocolMessage);
59
+
60
+ transport.receive({
61
+ v: 1, t: 'ready', ch: session.channelId,
62
+ ts: Date.now(), from: '_adapter',
63
+ body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
64
+ } as ProtocolMessage);
65
+
66
+ const recvKey = (session as any).recvKey as Uint8Array;
67
+ const dappPubB64 = transport.sent[0]!.from!;
68
+ return { recvKey, dappPubB64 };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Attack 1: Relay substitutes wallet public key in forwarded join
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('Malicious Relay: Public key substitution in join', () => {
76
+ it('sealed_join decryption fails when relay substitutes a different wallet key', async () => {
77
+ // ATTACK: A compromised relay intercepts the wallet's join message and
78
+ // replaces the "from" field with the relay's own key pair. The relay
79
+ // hopes the dApp will derive keys with the relay's key instead of the
80
+ // real wallet's key. However, the sealed_join was encrypted using the
81
+ // wallet's private key + dApp's public key, so the dApp will fail to
82
+ // decrypt it when using the relay's substituted key.
83
+ //
84
+ // PREVENTS: Man-in-the-middle — relay cannot impersonate the wallet
85
+ // because sealed_join is bound to the wallet's key pair.
86
+
87
+ const transport = new MockTransport();
88
+ const session = new DAppSession({
89
+ transport,
90
+ meta: { name: 'Test', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' },
91
+ autoAccept: true,
92
+ });
93
+
94
+ const errorHandler = vi.fn();
95
+ session.on('error', errorHandler);
96
+
97
+ await session.createPairing();
98
+ const dappPubB64 = transport.sent[0]!.from!;
99
+
100
+ // Real wallet generates sealed_join with its own key pair
101
+ const realWalletKp = generateX25519KeyPair();
102
+ const sealedJoin = makeSealedJoin(session.channelId, dappPubB64, realWalletKp);
103
+
104
+ // Relay substitutes a DIFFERENT key in the "from" field
105
+ const relayFakeKp = generateX25519KeyPair();
106
+
107
+ transport.receive({
108
+ v: 1, t: 'join', ch: session.channelId,
109
+ ts: Date.now(), from: relayFakeKp.publicKeyB64, // substituted key!
110
+ body: { sealed_join: sealedJoin }, // sealed with real wallet's key
111
+ } as ProtocolMessage);
112
+
113
+ await wait();
114
+
115
+ // DApp should fail to decrypt sealed_join because it derives keys
116
+ // using the fake relay key, which doesn't match the sealed_join
117
+ expect(errorHandler).toHaveBeenCalled();
118
+ const errorMsg = errorHandler.mock.calls[0]?.[0]?.message;
119
+ expect(errorMsg).toContain('decrypt');
120
+
121
+ // DApp should have sent close with decryption_failed
122
+ const closeMsg = transport.sent.find(m => m.t === 'close') as any;
123
+ expect(closeMsg).toBeTruthy();
124
+ expect(closeMsg.body.reason).toBe('decryption_failed');
125
+ });
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Attack 2: Relay replays an old sealed message
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe('Malicious Relay: Message replay', () => {
133
+ it('replayed sealed response with old seq is rejected', async () => {
134
+ // ATTACK: Relay captures a valid encrypted response and replays it
135
+ // later, hoping to cause duplicate action processing. The sequence
136
+ // number in the sealed envelope prevents this — once a sequence
137
+ // number is accepted, any message with equal or lower seq is rejected.
138
+ //
139
+ // PREVENTS: Replay attacks that could cause duplicate signing or
140
+ // duplicate transaction submission.
141
+
142
+ const ctx = setupDAppManual();
143
+ const { transport, session, walletKp } = ctx;
144
+ const { recvKey } = await connectDAppManual(ctx);
145
+
146
+ // First request succeeds with seq=0
147
+ const p0 = session.request('wallet_getAccounts');
148
+ await wait(20);
149
+ const req0 = transport.sent.find(m => m.t === 'req') as any;
150
+ const req0Id = req0.body.id;
151
+
152
+ const validSealed = sealPayload(
153
+ recvKey, session.channelId, 0,
154
+ { _ok: true, _result: ['0xAddr'] },
155
+ { type: 'res', from: walletKp.publicKeyB64, id: req0Id },
156
+ );
157
+
158
+ transport.receive({
159
+ v: 1, t: 'res', ch: session.channelId,
160
+ ts: Date.now(), from: walletKp.publicKeyB64,
161
+ body: { id: req0Id, sealed: validSealed },
162
+ } as ProtocolMessage);
163
+ expect(await p0).toEqual(['0xAddr']);
164
+
165
+ // Second request: relay replays the SAME sealed payload (seq=0)
166
+ const p1 = session.request('wallet_getAccounts');
167
+ await wait(20);
168
+ const req1 = transport.sent.filter(m => m.t === 'req')[1] as any;
169
+ const req1Id = req1.body.id;
170
+
171
+ // Relay creates a new envelope but copies the old sealed (seq=0)
172
+ // Note: the AAD won't match because id differs, so it fails at AEAD level.
173
+ // For a more precise replay, the relay would need the same req.id which
174
+ // the idempotency cache would handle. Either way, the attack fails.
175
+ transport.receive({
176
+ v: 1, t: 'res', ch: session.channelId,
177
+ ts: Date.now(), from: walletKp.publicKeyB64,
178
+ body: { id: req1Id, sealed: sealPayload(
179
+ recvKey, session.channelId, 0, // replayed seq=0
180
+ { _ok: true, _result: ['0xAddr'] },
181
+ { type: 'res', from: walletKp.publicKeyB64, id: req1Id },
182
+ )},
183
+ } as ProtocolMessage);
184
+ await expect(p1).rejects.toThrow('Replay detected');
185
+ });
186
+
187
+ it('replayed event with old seq is silently dropped', async () => {
188
+ // ATTACK: Relay replays a previously captured event to confuse the dApp.
189
+ //
190
+ // PREVENTS: Stale event injection.
191
+
192
+ const ctx = setupDAppManual();
193
+ const { transport, session, walletKp } = ctx;
194
+ const { recvKey } = await connectDAppManual(ctx);
195
+
196
+ const eventHandler = vi.fn();
197
+ session.on('event', eventHandler);
198
+
199
+ // First event at seq=0 accepted
200
+ transport.receive({
201
+ v: 1, t: 'evt', ch: session.channelId,
202
+ ts: Date.now(), from: walletKp.publicKeyB64,
203
+ body: {
204
+ id: 'evt-1',
205
+ sealed: sealPayload(
206
+ recvKey, session.channelId, 0,
207
+ { _event: 'accountsChanged', accounts: ['0xA'] },
208
+ { type: 'evt', from: walletKp.publicKeyB64, id: 'evt-1' },
209
+ ),
210
+ },
211
+ } as ProtocolMessage);
212
+ await wait();
213
+ expect(eventHandler).toHaveBeenCalledTimes(1);
214
+
215
+ // Replay same seq=0 — should be silently dropped
216
+ transport.receive({
217
+ v: 1, t: 'evt', ch: session.channelId,
218
+ ts: Date.now(), from: walletKp.publicKeyB64,
219
+ body: {
220
+ id: 'evt-replay',
221
+ sealed: sealPayload(
222
+ recvKey, session.channelId, 0,
223
+ { _event: 'accountsChanged', accounts: ['0xEvil'] },
224
+ { type: 'evt', from: walletKp.publicKeyB64, id: 'evt-replay' },
225
+ ),
226
+ },
227
+ } as ProtocolMessage);
228
+ await wait();
229
+ expect(eventHandler).toHaveBeenCalledTimes(1); // still 1, replay dropped
230
+ });
231
+ });
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Attack 3: Relay reflects dApp's own message back as wallet message
235
+ // ---------------------------------------------------------------------------
236
+
237
+ describe('Malicious Relay: Reflection attack', () => {
238
+ it('reflecting req back as res fails because directional keys differ', async () => {
239
+ // ATTACK: Relay captures a dApp req (encrypted with dappToWalletKey)
240
+ // and sends it back to the dApp as if it were a res from the wallet.
241
+ // Because dApp decrypts responses with walletToDappKey (different from
242
+ // dappToWalletKey), AEAD decryption will fail.
243
+ //
244
+ // PREVENTS: Reflection attacks where the relay bounces a peer's own
245
+ // messages back — directional keys ensure each direction uses a
246
+ // unique key (Section 6.2).
247
+
248
+ const ctx = setupDAppManual();
249
+ const { transport, session, walletKp } = ctx;
250
+ await connectDAppManual(ctx);
251
+
252
+ const p = session.request('wallet_getAccounts');
253
+ await wait(20);
254
+
255
+ // Capture the outbound req
256
+ const reqMsg = transport.sent.find(m => m.t === 'req') as any;
257
+ const reqId = reqMsg.body.id;
258
+ const reqSealed = reqMsg.body.sealed;
259
+
260
+ // Relay reflects the req's sealed payload back as a res
261
+ transport.receive({
262
+ v: 1, t: 'res', ch: session.channelId,
263
+ ts: Date.now(), from: walletKp.publicKeyB64,
264
+ body: { id: reqId, sealed: reqSealed }, // reflected sealed!
265
+ } as ProtocolMessage);
266
+
267
+ // Decryption must fail — dApp uses walletToDappKey to decrypt res,
268
+ // but the sealed payload was encrypted with dappToWalletKey
269
+ await expect(p).rejects.toThrow('Decryption failed');
270
+ });
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Attack 4: Relay sends terminate with fake reasons (DoS)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe('Malicious Relay: Fake terminate', () => {
278
+ it('terminate from relay closes session but does not compromise data', async () => {
279
+ // ATTACK: A malicious relay sends terminate messages to disrupt
280
+ // the session. This is a DoS attack — the relay can always do this
281
+ // since it controls transport. The protocol acknowledges this
282
+ // (Section 19.5) and ensures no data compromise occurs.
283
+ //
284
+ // PREVENTS: Nothing — this is an inherent DoS vector. But we verify
285
+ // the session transitions cleanly to closed state without leaking
286
+ // any key material or corrupting state.
287
+
288
+ const ctx = setupDAppManual();
289
+ const { transport, session } = ctx;
290
+ await connectDAppManual(ctx);
291
+
292
+ expect(session.phase).toBe('connected');
293
+
294
+ // Relay sends a fake terminate
295
+ transport.receive({
296
+ v: 1, t: 'terminate', ch: session.channelId,
297
+ ts: Date.now(), from: '_adapter',
298
+ body: { reason: 'rate_limited' },
299
+ } as ProtocolMessage);
300
+
301
+ expect(session.phase).toBe('closed');
302
+ });
303
+
304
+ it('terminate does not prevent session from being properly destroyed', async () => {
305
+ const ctx = setupDAppManual();
306
+ const { transport, session } = ctx;
307
+ await connectDAppManual(ctx);
308
+
309
+ transport.receive({
310
+ v: 1, t: 'terminate', ch: session.channelId,
311
+ ts: Date.now(), from: '_adapter',
312
+ body: { reason: 'timeout' },
313
+ } as ProtocolMessage);
314
+
315
+ // Should not throw — session should be cleanly closeable
316
+ expect(() => session.destroy()).not.toThrow();
317
+ });
318
+ });
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Attack 5: Relay drops accept -> dApp times out
322
+ // ---------------------------------------------------------------------------
323
+
324
+ describe('Malicious Relay: Dropped accept', () => {
325
+ it('dApp does not hang indefinitely when relay drops accept (relay never sends ready.connected)', async () => {
326
+ // ATTACK: Relay receives accept from dApp but never forwards
327
+ // ready.connected to either peer. The dApp must not hang forever.
328
+ //
329
+ // PREVENTS: Session hang from a relay that silently drops messages.
330
+ // The dApp should time out from pending_accept phase.
331
+
332
+ const transport = new MockTransport();
333
+ const session = new DAppSession({
334
+ transport,
335
+ meta: { name: 'Test', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' },
336
+ autoAccept: true,
337
+ requestTimeout: 200, // short timeout for test
338
+ });
339
+
340
+ await session.createPairing();
341
+ const dappPubB64 = transport.sent[0]!.from!;
342
+
343
+ const walletKp = generateX25519KeyPair();
344
+
345
+ // Wallet joins
346
+ transport.receive({
347
+ v: 1, t: 'join', ch: session.channelId,
348
+ ts: Date.now(), from: walletKp.publicKeyB64,
349
+ body: makeJoinBody(session.channelId, dappPubB64, walletKp),
350
+ } as ProtocolMessage);
351
+
352
+ await wait();
353
+ // dApp auto-accepts and sends accept message
354
+ const acceptMsg = transport.sent.find(m => m.t === 'accept');
355
+ expect(acceptMsg).toBeTruthy();
356
+
357
+ // But relay NEVER sends ready.connected
358
+ // Session should still be in pending_accept, not connected
359
+ // Trying to send a request while not connected should fail immediately
360
+ await expect(session.request('wallet_getAccounts')).rejects.toThrow('Not connected');
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Attack 6: Relay sends ready.connected with wrong remote key
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe('Malicious Relay: Wrong remote key in ready.connected', () => {
369
+ it('dApp rejects ready.connected when remote does not match paired wallet', async () => {
370
+ // ATTACK: Relay sends ready.connected but with a different key in
371
+ // the "remote" field, trying to trick the dApp into thinking a
372
+ // different wallet connected. Section 15 rule 15 requires peers
373
+ // to reject ready.connected if remote doesn't match the handshake.
374
+ //
375
+ // PREVENTS: Relay routing a different peer into an established
376
+ // handshake after the key exchange has already occurred.
377
+
378
+ const ctx = setupDAppManual();
379
+ const { transport, session, walletKp } = ctx;
380
+
381
+ await session.createPairing();
382
+ const dappPubB64 = transport.sent[0]!.from!;
383
+
384
+ // Wallet joins — dApp derives keys using walletKp
385
+ transport.receive({
386
+ v: 1, t: 'join', ch: session.channelId,
387
+ ts: Date.now(), from: walletKp.publicKeyB64,
388
+ body: makeJoinBody(session.channelId, dappPubB64, walletKp),
389
+ } as ProtocolMessage);
390
+
391
+ await wait();
392
+
393
+ const errorHandler = vi.fn();
394
+ session.on('error', errorHandler);
395
+
396
+ // Relay sends ready.connected with a DIFFERENT remote key
397
+ const fakeRemoteKp = generateX25519KeyPair();
398
+ transport.receive({
399
+ v: 1, t: 'ready', ch: session.channelId,
400
+ ts: Date.now(), from: '_adapter',
401
+ body: { state: 'connected', reconnect: false, remote: fakeRemoteKp.publicKeyB64 },
402
+ } as ProtocolMessage);
403
+
404
+ await wait();
405
+
406
+ // DApp should reject and close
407
+ expect(errorHandler).toHaveBeenCalled();
408
+ expect(errorHandler.mock.calls[0]?.[0]?.message).toContain('remote does not match');
409
+ expect(session.phase).toBe('closed');
410
+ });
411
+
412
+ it('wallet rejects ready.connected when remote does not match paired dApp', async () => {
413
+ // Same attack from the wallet side: relay lies about who the remote is.
414
+
415
+ const transport = new MockTransport();
416
+ const dappKp = generateX25519KeyPair();
417
+ const channelId = generateChannelId();
418
+
419
+ const session = new WalletSession({
420
+ transport,
421
+ meta: { name: 'W', description: 'W', url: 'https://w.test', icon: 'https://w.test/i.png' },
422
+ capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
423
+ });
424
+
425
+ const uri = buildPairingUri({
426
+ channelId,
427
+ pubkeyB64: dappKp.publicKeyB64,
428
+ relayUrl: 'ws://localhost/v1',
429
+ name: 'D', url: 'https://d.test', icon: 'https://d.test/i.png',
430
+ });
431
+ await session.joinFromUri(uri);
432
+
433
+ const errorHandler = vi.fn();
434
+ session.on('error', errorHandler);
435
+
436
+ // Relay sends ready.connected with wrong remote
437
+ const fakeKp = generateX25519KeyPair();
438
+ transport.receive({
439
+ v: 1, t: 'ready', ch: channelId,
440
+ ts: Date.now(), from: '_adapter',
441
+ body: { state: 'connected', reconnect: false, remote: fakeKp.publicKeyB64 },
442
+ } as ProtocolMessage);
443
+
444
+ await wait();
445
+
446
+ expect(errorHandler).toHaveBeenCalled();
447
+ expect(errorHandler.mock.calls[0]?.[0]?.message).toContain('remote does not match');
448
+ expect(session.phase).toBe('closed');
449
+ });
450
+ });
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Attack 7: Relay forges ping/pong with fake from
454
+ // ---------------------------------------------------------------------------
455
+
456
+ describe('Malicious Relay: Forged ping/pong', () => {
457
+ it('forged ping with _adapter from is rejected', async () => {
458
+ // ATTACK: Relay sends a ping with from="_adapter". Section 2 states
459
+ // peers MUST reject any peer-sent message where from equals "_adapter"
460
+ // (except ready and terminate which are adapter messages).
461
+ //
462
+ // PREVENTS: Adapter impersonation in non-adapter message types.
463
+
464
+ const ctx = setupDAppManual();
465
+ const { transport, session } = ctx;
466
+ await connectDAppManual(ctx);
467
+
468
+ const errorHandler = vi.fn();
469
+ session.on('error', errorHandler);
470
+
471
+ transport.receive({
472
+ v: 1, t: 'ping', ch: session.channelId,
473
+ ts: Date.now(), from: '_adapter', // spoofed!
474
+ body: {},
475
+ } as ProtocolMessage);
476
+
477
+ await wait();
478
+
479
+ expect(errorHandler).toHaveBeenCalled();
480
+ expect(errorHandler.mock.calls[0]?.[0]?.message).toContain('spoofed _adapter');
481
+ });
482
+
483
+ it('ping/pong from unknown peer does not compromise encrypted state', async () => {
484
+ // ATTACK: Relay injects a ping from a random key. Since ping/pong
485
+ // are unencrypted and do not consume sequence numbers, the worst case
486
+ // is a pong reply (no data compromise).
487
+ //
488
+ // PREVENTS: Verifies that heartbeat messages cannot leak secrets.
489
+
490
+ const ctx = setupDAppManual();
491
+ const { transport, session, walletKp } = ctx;
492
+ const { recvKey } = await connectDAppManual(ctx);
493
+
494
+ const unknownKp = generateX25519KeyPair();
495
+
496
+ transport.receive({
497
+ v: 1, t: 'ping', ch: session.channelId,
498
+ ts: Date.now(), from: unknownKp.publicKeyB64,
499
+ body: {},
500
+ } as ProtocolMessage);
501
+
502
+ await wait();
503
+
504
+ // DApp may reply with pong (no security issue) — verify session still works
505
+ expect(session.phase).toBe('connected');
506
+
507
+ // Verify encrypted communication still works after the forged ping
508
+ const p = session.request('wallet_getAccounts');
509
+ await wait(20);
510
+ const req = transport.sent.filter(m => m.t === 'req').pop() as any;
511
+ const reqId = req.body.id;
512
+
513
+ transport.receive({
514
+ v: 1, t: 'res', ch: session.channelId,
515
+ ts: Date.now(), from: walletKp.publicKeyB64,
516
+ body: {
517
+ id: reqId,
518
+ sealed: sealPayload(
519
+ recvKey, session.channelId, 0,
520
+ { _ok: true, _result: 'still-works' },
521
+ { type: 'res', from: walletKp.publicKeyB64, id: reqId },
522
+ ),
523
+ },
524
+ } as ProtocolMessage);
525
+
526
+ expect(await p).toBe('still-works');
527
+ });
528
+ });