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