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,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
|
+
});
|