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,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial tests: Malicious Wallet
|
|
3
|
+
*
|
|
4
|
+
* Simulates a malicious wallet attempting to abuse the dApp through
|
|
5
|
+
* protocol violations: wrong response IDs, role violations, sequence
|
|
6
|
+
* manipulation, and out-of-state messages.
|
|
7
|
+
*
|
|
8
|
+
* These tests verify that the DAppSession enforces protocol rules
|
|
9
|
+
* correctly, protecting the dApp from a compromised or malicious wallet.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
13
|
+
import { DAppSession } from '../../dapp-session.js';
|
|
14
|
+
import { MockTransport, makeJoinBody } from '../../test-helpers.js';
|
|
15
|
+
import {
|
|
16
|
+
generateX25519KeyPair,
|
|
17
|
+
sealPayload,
|
|
18
|
+
b64urlEncode,
|
|
19
|
+
} from '../../crypto.js';
|
|
20
|
+
import type { ProtocolMessage } from '../../types.js';
|
|
21
|
+
|
|
22
|
+
function wait(ms = 50): Promise<void> {
|
|
23
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function setupDAppManual() {
|
|
31
|
+
const transport = new MockTransport();
|
|
32
|
+
const session = new DAppSession({
|
|
33
|
+
transport,
|
|
34
|
+
meta: { name: 'Test', description: 'Test dApp', url: 'https://test.com', icon: 'https://test.com/icon.png' },
|
|
35
|
+
});
|
|
36
|
+
const walletKp = generateX25519KeyPair();
|
|
37
|
+
return { transport, session, walletKp };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
|
|
41
|
+
const { transport, session, walletKp } = ctx;
|
|
42
|
+
await session.createPairing();
|
|
43
|
+
|
|
44
|
+
transport.receive({
|
|
45
|
+
v: 1, t: 'join', ch: session.channelId,
|
|
46
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
47
|
+
body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
|
|
48
|
+
} as ProtocolMessage);
|
|
49
|
+
|
|
50
|
+
transport.receive({
|
|
51
|
+
v: 1, t: 'ready', ch: session.channelId,
|
|
52
|
+
ts: Date.now(), from: '_adapter',
|
|
53
|
+
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
54
|
+
} as ProtocolMessage);
|
|
55
|
+
|
|
56
|
+
const recvKey = (session as any).recvKey as Uint8Array;
|
|
57
|
+
const dappPubB64 = transport.sent[0]!.from!;
|
|
58
|
+
return { recvKey, dappPubB64 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Attack 1: Wallet sends res with wrong req.id
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('Malicious Wallet: Wrong response ID', () => {
|
|
66
|
+
it('response with unknown req.id is ignored by dApp', async () => {
|
|
67
|
+
// ATTACK: A malicious wallet sends a response with an ID that does
|
|
68
|
+
// not match any pending request. This could be an attempt to inject
|
|
69
|
+
// fake results or confuse request/response matching.
|
|
70
|
+
//
|
|
71
|
+
// PREVENTS: Response injection for non-existent requests.
|
|
72
|
+
// The dApp should silently ignore responses with unknown IDs.
|
|
73
|
+
|
|
74
|
+
const ctx = setupDAppManual();
|
|
75
|
+
const { transport, session, walletKp } = ctx;
|
|
76
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
77
|
+
|
|
78
|
+
const responseHandler = vi.fn();
|
|
79
|
+
session.on('response', responseHandler);
|
|
80
|
+
|
|
81
|
+
// Wallet sends a response with a fabricated request ID
|
|
82
|
+
transport.receive({
|
|
83
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
84
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
85
|
+
body: {
|
|
86
|
+
id: 'fabricated-req-id',
|
|
87
|
+
sealed: sealPayload(
|
|
88
|
+
recvKey, session.channelId, 0,
|
|
89
|
+
{ _ok: true, _result: 'injected-data' },
|
|
90
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: 'fabricated-req-id' },
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
} as ProtocolMessage);
|
|
94
|
+
|
|
95
|
+
await wait();
|
|
96
|
+
|
|
97
|
+
// Response handler should NOT have been called (no matching pending request)
|
|
98
|
+
expect(responseHandler).not.toHaveBeenCalled();
|
|
99
|
+
// Session should remain healthy
|
|
100
|
+
expect(session.phase).toBe('connected');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('response to wrong req.id does not resolve a different pending request', async () => {
|
|
104
|
+
// ATTACK: Wallet sends a response with a different request's ID,
|
|
105
|
+
// trying to resolve the wrong request with attacker-chosen data.
|
|
106
|
+
//
|
|
107
|
+
// PREVENTS: Cross-request response substitution.
|
|
108
|
+
|
|
109
|
+
const ctx = setupDAppManual();
|
|
110
|
+
const { transport, session, walletKp } = ctx;
|
|
111
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
112
|
+
|
|
113
|
+
// Send two requests
|
|
114
|
+
const p1 = session.request('wallet_getAccounts');
|
|
115
|
+
const p2 = session.request('wallet_signMessage', { message: 'test' });
|
|
116
|
+
await wait(20);
|
|
117
|
+
|
|
118
|
+
const reqs = transport.sent.filter(m => m.t === 'req') as any[];
|
|
119
|
+
const req1Id = reqs[0]!.body.id;
|
|
120
|
+
const req2Id = reqs[1]!.body.id;
|
|
121
|
+
|
|
122
|
+
// Wallet responds to req2 with req1's ID (cross-wired)
|
|
123
|
+
// The AAD includes the id field, so if the id doesn't match,
|
|
124
|
+
// AEAD decryption will fail (or the wrong request will be resolved
|
|
125
|
+
// with potentially confusing data).
|
|
126
|
+
transport.receive({
|
|
127
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
128
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
129
|
+
body: {
|
|
130
|
+
id: req1Id,
|
|
131
|
+
sealed: sealPayload(
|
|
132
|
+
recvKey, session.channelId, 0,
|
|
133
|
+
{ _ok: true, _result: 'correct-for-req1' },
|
|
134
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req1Id },
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
} as ProtocolMessage);
|
|
138
|
+
|
|
139
|
+
// req1 should resolve correctly
|
|
140
|
+
expect(await p1).toBe('correct-for-req1');
|
|
141
|
+
|
|
142
|
+
// Now respond to req2 normally
|
|
143
|
+
transport.receive({
|
|
144
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
145
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
146
|
+
body: {
|
|
147
|
+
id: req2Id,
|
|
148
|
+
sealed: sealPayload(
|
|
149
|
+
recvKey, session.channelId, 1,
|
|
150
|
+
{ _ok: true, _result: 'correct-for-req2' },
|
|
151
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req2Id },
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
} as ProtocolMessage);
|
|
155
|
+
|
|
156
|
+
expect(await p2).toBe('correct-for-req2');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Attack 2: Wallet sends req (role violation)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('Malicious Wallet: Sends req (role violation)', () => {
|
|
165
|
+
it('dApp ignores req from wallet because only dApp sends req', async () => {
|
|
166
|
+
// ATTACK: Wallet sends a req message to the dApp. Per Section 5,
|
|
167
|
+
// only the dApp sends req. The wallet should never do this.
|
|
168
|
+
// The dApp should either ignore it or treat it as a protocol error.
|
|
169
|
+
//
|
|
170
|
+
// PREVENTS: Role reversal attack where wallet tries to command the dApp.
|
|
171
|
+
|
|
172
|
+
const ctx = setupDAppManual();
|
|
173
|
+
const { transport, session, walletKp } = ctx;
|
|
174
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
175
|
+
|
|
176
|
+
const errorHandler = vi.fn();
|
|
177
|
+
session.on('error', errorHandler);
|
|
178
|
+
|
|
179
|
+
// Wallet sends a req (which it should never do)
|
|
180
|
+
transport.receive({
|
|
181
|
+
v: 1, t: 'req', ch: session.channelId,
|
|
182
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
183
|
+
body: {
|
|
184
|
+
id: 'evil-req-1',
|
|
185
|
+
sealed: sealPayload(
|
|
186
|
+
recvKey, session.channelId, 0,
|
|
187
|
+
{ _method: 'dapp_executeTransaction' },
|
|
188
|
+
{ type: 'req', from: walletKp.publicKeyB64, id: 'evil-req-1' },
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
} as ProtocolMessage);
|
|
192
|
+
|
|
193
|
+
await wait();
|
|
194
|
+
|
|
195
|
+
// DApp does not have a request handler (it only sends requests).
|
|
196
|
+
// The message should be silently ignored or cause no state change.
|
|
197
|
+
// Key insight: DAppSession.handleMessage() has no case for 'req'
|
|
198
|
+
// messages from the wallet, so it falls through to the default
|
|
199
|
+
// case (no-op).
|
|
200
|
+
expect(session.phase).toBe('connected');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Attack 3: Wallet manipulates sequence numbers
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe('Malicious Wallet: Sequence number manipulation', () => {
|
|
209
|
+
it('skipped sequence numbers are accepted (gaps are valid per spec)', async () => {
|
|
210
|
+
// Per Section 6.6.1: "Gaps are valid (expected after reconnect)."
|
|
211
|
+
// This is NOT an attack — verifying correct behavior.
|
|
212
|
+
|
|
213
|
+
const ctx = setupDAppManual();
|
|
214
|
+
const { transport, session, walletKp } = ctx;
|
|
215
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
216
|
+
|
|
217
|
+
const p = session.request('wallet_getAccounts');
|
|
218
|
+
await wait(20);
|
|
219
|
+
const req = transport.sent.find(m => m.t === 'req') as any;
|
|
220
|
+
const reqId = req.body.id;
|
|
221
|
+
|
|
222
|
+
// Wallet responds with seq=5 (skipping 0-4) — should be accepted
|
|
223
|
+
transport.receive({
|
|
224
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
225
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
226
|
+
body: {
|
|
227
|
+
id: reqId,
|
|
228
|
+
sealed: sealPayload(
|
|
229
|
+
recvKey, session.channelId, 5,
|
|
230
|
+
{ _ok: true, _result: 'gap-ok' },
|
|
231
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: reqId },
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
} as ProtocolMessage);
|
|
235
|
+
|
|
236
|
+
expect(await p).toBe('gap-ok');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('reset sequence to 0 after receiving higher seq is rejected', async () => {
|
|
240
|
+
// ATTACK: Wallet sends seq=10, then tries to reset to seq=0.
|
|
241
|
+
// This is a replay/reset attack. Section 6.6.1: "A message MUST
|
|
242
|
+
// be rejected if its sequence number is not strictly greater than
|
|
243
|
+
// the last accepted value."
|
|
244
|
+
//
|
|
245
|
+
// PREVENTS: Sequence counter reset allowing message replay.
|
|
246
|
+
|
|
247
|
+
const ctx = setupDAppManual();
|
|
248
|
+
const { transport, session, walletKp } = ctx;
|
|
249
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
250
|
+
|
|
251
|
+
// First response at seq=10 (accepted)
|
|
252
|
+
const p0 = session.request('wallet_getAccounts');
|
|
253
|
+
await wait(20);
|
|
254
|
+
const req0 = transport.sent.find(m => m.t === 'req') as any;
|
|
255
|
+
const r0id = req0.body.id;
|
|
256
|
+
transport.receive({
|
|
257
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
258
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
259
|
+
body: {
|
|
260
|
+
id: r0id,
|
|
261
|
+
sealed: sealPayload(
|
|
262
|
+
recvKey, session.channelId, 10,
|
|
263
|
+
{ _ok: true, _result: 'first' },
|
|
264
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
265
|
+
),
|
|
266
|
+
},
|
|
267
|
+
} as ProtocolMessage);
|
|
268
|
+
expect(await p0).toBe('first');
|
|
269
|
+
|
|
270
|
+
// Second response at seq=0 (reset attempt — MUST be rejected)
|
|
271
|
+
const p1 = session.request('wallet_getAccounts');
|
|
272
|
+
await wait(20);
|
|
273
|
+
const req1 = transport.sent.filter(m => m.t === 'req')[1] as any;
|
|
274
|
+
const r1id = req1.body.id;
|
|
275
|
+
transport.receive({
|
|
276
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
277
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
278
|
+
body: {
|
|
279
|
+
id: r1id,
|
|
280
|
+
sealed: sealPayload(
|
|
281
|
+
recvKey, session.channelId, 0, // reset to 0!
|
|
282
|
+
{ _ok: true, _result: 'replayed' },
|
|
283
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
284
|
+
),
|
|
285
|
+
},
|
|
286
|
+
} as ProtocolMessage);
|
|
287
|
+
await expect(p1).rejects.toThrow('Replay detected');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('reused sequence number is rejected', async () => {
|
|
291
|
+
// ATTACK: Wallet sends the same sequence number twice.
|
|
292
|
+
//
|
|
293
|
+
// PREVENTS: Nonce reuse in AEAD encryption.
|
|
294
|
+
|
|
295
|
+
const ctx = setupDAppManual();
|
|
296
|
+
const { transport, session, walletKp } = ctx;
|
|
297
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
298
|
+
|
|
299
|
+
// First at seq=3 (accepted)
|
|
300
|
+
const p0 = session.request('wallet_getAccounts');
|
|
301
|
+
await wait(20);
|
|
302
|
+
const req0 = transport.sent.find(m => m.t === 'req') as any;
|
|
303
|
+
const r0id = req0.body.id;
|
|
304
|
+
transport.receive({
|
|
305
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
306
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
307
|
+
body: {
|
|
308
|
+
id: r0id,
|
|
309
|
+
sealed: sealPayload(
|
|
310
|
+
recvKey, session.channelId, 3,
|
|
311
|
+
{ _ok: true, _result: 'ok' },
|
|
312
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
313
|
+
),
|
|
314
|
+
},
|
|
315
|
+
} as ProtocolMessage);
|
|
316
|
+
expect(await p0).toBe('ok');
|
|
317
|
+
|
|
318
|
+
// Second at seq=3 (reuse — MUST be rejected)
|
|
319
|
+
const p1 = session.request('wallet_getAccounts');
|
|
320
|
+
await wait(20);
|
|
321
|
+
const req1 = transport.sent.filter(m => m.t === 'req')[1] as any;
|
|
322
|
+
const r1id = req1.body.id;
|
|
323
|
+
transport.receive({
|
|
324
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
325
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
326
|
+
body: {
|
|
327
|
+
id: r1id,
|
|
328
|
+
sealed: sealPayload(
|
|
329
|
+
recvKey, session.channelId, 3, // same seq!
|
|
330
|
+
{ _ok: true, _result: 'reused' },
|
|
331
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
332
|
+
),
|
|
333
|
+
},
|
|
334
|
+
} as ProtocolMessage);
|
|
335
|
+
await expect(p1).rejects.toThrow('Replay detected');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Attack 4: Wallet sends evt before ready.connected
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
describe('Malicious Wallet: Event before connected', () => {
|
|
344
|
+
it('dApp ignores evt received before ready.connected', async () => {
|
|
345
|
+
// ATTACK: Wallet sends an event before the channel reaches
|
|
346
|
+
// connected state. The dApp should not process events until
|
|
347
|
+
// ready.connected is received (Section 15 rule 7).
|
|
348
|
+
//
|
|
349
|
+
// PREVENTS: Pre-connection event injection.
|
|
350
|
+
|
|
351
|
+
const transport = new MockTransport();
|
|
352
|
+
const session = new DAppSession({
|
|
353
|
+
transport,
|
|
354
|
+
meta: { name: 'T', description: 'T', url: 'https://t.test', icon: 'https://t.test/i.png' },
|
|
355
|
+
autoAccept: false, // manual accept to control timing
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const eventHandler = vi.fn();
|
|
359
|
+
session.on('event', eventHandler);
|
|
360
|
+
|
|
361
|
+
await session.createPairing();
|
|
362
|
+
const dappPubB64 = transport.sent[0]!.from!;
|
|
363
|
+
|
|
364
|
+
const walletKp = generateX25519KeyPair();
|
|
365
|
+
|
|
366
|
+
// Wallet joins
|
|
367
|
+
transport.receive({
|
|
368
|
+
v: 1, t: 'join', ch: session.channelId,
|
|
369
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
370
|
+
body: makeJoinBody(session.channelId, dappPubB64, walletKp),
|
|
371
|
+
} as ProtocolMessage);
|
|
372
|
+
|
|
373
|
+
await wait();
|
|
374
|
+
// Session is now in pending_accept, NOT connected
|
|
375
|
+
|
|
376
|
+
// Wallet tries to send an event before connected
|
|
377
|
+
transport.receive({
|
|
378
|
+
v: 1, t: 'evt', ch: session.channelId,
|
|
379
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
380
|
+
body: { id: 'premature-evt', sealed: 'fake-sealed' },
|
|
381
|
+
} as ProtocolMessage);
|
|
382
|
+
|
|
383
|
+
await wait();
|
|
384
|
+
|
|
385
|
+
// Event should NOT have been processed (recvKey exists but
|
|
386
|
+
// the sealed data is invalid, or the event is silently dropped
|
|
387
|
+
// due to decryption failure)
|
|
388
|
+
expect(eventHandler).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Attack 5: Wallet sends response from a different key
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
describe('Malicious Wallet: Response from wrong peer', () => {
|
|
397
|
+
it('dApp ignores response from a key that does not match paired wallet', async () => {
|
|
398
|
+
// ATTACK: A different entity (or the relay itself) sends a response
|
|
399
|
+
// with a different "from" key. The dApp checks that from matches
|
|
400
|
+
// the paired wallet's public key.
|
|
401
|
+
//
|
|
402
|
+
// PREVENTS: Third-party response injection.
|
|
403
|
+
|
|
404
|
+
const ctx = setupDAppManual();
|
|
405
|
+
const { transport, session, walletKp } = ctx;
|
|
406
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
407
|
+
|
|
408
|
+
const p = session.request('wallet_getAccounts');
|
|
409
|
+
await wait(20);
|
|
410
|
+
const req = transport.sent.find(m => m.t === 'req') as any;
|
|
411
|
+
const reqId = req.body.id;
|
|
412
|
+
|
|
413
|
+
// Impersonator uses a different key
|
|
414
|
+
const impersonatorKp = generateX25519KeyPair();
|
|
415
|
+
transport.receive({
|
|
416
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
417
|
+
ts: Date.now(), from: impersonatorKp.publicKeyB64, // wrong key!
|
|
418
|
+
body: {
|
|
419
|
+
id: reqId,
|
|
420
|
+
sealed: sealPayload(
|
|
421
|
+
recvKey, session.channelId, 0,
|
|
422
|
+
{ _ok: true, _result: 'evil' },
|
|
423
|
+
{ type: 'res', from: impersonatorKp.publicKeyB64, id: reqId },
|
|
424
|
+
),
|
|
425
|
+
},
|
|
426
|
+
} as ProtocolMessage);
|
|
427
|
+
|
|
428
|
+
await wait(20);
|
|
429
|
+
|
|
430
|
+
// The response should have been silently dropped (from mismatch)
|
|
431
|
+
// The request should still be pending (not resolved)
|
|
432
|
+
// Clean up by closing
|
|
433
|
+
session.close();
|
|
434
|
+
await expect(p).rejects.toThrow('Session closed');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Attack 6: Wallet sends unsupported protocol version
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
describe('Malicious Wallet: Unsupported protocol version', () => {
|
|
443
|
+
it('dApp closes with unsupported_version on receiving v!=1', async () => {
|
|
444
|
+
// ATTACK: Wallet sends messages with a different protocol version
|
|
445
|
+
// to confuse parsing or exploit version-specific vulnerabilities.
|
|
446
|
+
//
|
|
447
|
+
// PREVENTS: Version confusion attacks. Section 15 rule 12.
|
|
448
|
+
|
|
449
|
+
const ctx = setupDAppManual();
|
|
450
|
+
const { transport, session, walletKp } = ctx;
|
|
451
|
+
await connectDAppManual(ctx);
|
|
452
|
+
|
|
453
|
+
transport.receive({
|
|
454
|
+
v: 99 as any, t: 'res', ch: session.channelId,
|
|
455
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
456
|
+
body: { id: 'req-1', sealed: 'whatever' },
|
|
457
|
+
} as ProtocolMessage);
|
|
458
|
+
|
|
459
|
+
await wait();
|
|
460
|
+
|
|
461
|
+
// DApp should close with unsupported_version
|
|
462
|
+
expect(session.phase).toBe('closed');
|
|
463
|
+
const closeMsg = transport.sent.find(m => m.t === 'close') as any;
|
|
464
|
+
expect(closeMsg).toBeTruthy();
|
|
465
|
+
expect(closeMsg.body.reason).toBe('unsupported_version');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletPair Protocol v1 — Section 6.3 canonical JSON test vectors.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the SDK's canonicalJson implementation matches every
|
|
5
|
+
* test vector in the specification, including SHA-256 hashes, and
|
|
6
|
+
* handles all documented edge cases.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { canonicalJson, sha256Hex } from '../../crypto.js';
|
|
11
|
+
|
|
12
|
+
function hash(s: string): string {
|
|
13
|
+
return sha256Hex(new TextEncoder().encode(s));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Section 6.3 — Normative test vectors
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('Section 6.3 — Vector 1: capabilities (key sorting, nested objects)', () => {
|
|
21
|
+
const input = {
|
|
22
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
23
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
24
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
25
|
+
};
|
|
26
|
+
const expected =
|
|
27
|
+
'{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]}';
|
|
28
|
+
const expectedHash = '4da366e2aae26b47b3d90fff52410752348733350ce2525dce7d64510f571333';
|
|
29
|
+
|
|
30
|
+
it('produces the correct canonical output', () => {
|
|
31
|
+
expect(canonicalJson(input)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('SHA-256 of output matches the spec', () => {
|
|
35
|
+
expect(hash(canonicalJson(input))).toBe(expectedHash);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Section 6.3 — Vector 2: join plaintext (nested + meta)', () => {
|
|
40
|
+
const input = {
|
|
41
|
+
capabilities: {
|
|
42
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
43
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
44
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
45
|
+
},
|
|
46
|
+
meta: {
|
|
47
|
+
name: 'MyWallet',
|
|
48
|
+
description: 'A multi-chain wallet',
|
|
49
|
+
url: 'https://mywallet.app',
|
|
50
|
+
icon: 'https://mywallet.app/icon.png',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const expected =
|
|
54
|
+
'{"capabilities":{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]},"meta":{"description":"A multi-chain wallet","icon":"https://mywallet.app/icon.png","name":"MyWallet","url":"https://mywallet.app"}}';
|
|
55
|
+
const expectedHash = '9f4f3b71b0db39ba8b86173b8c78182799d0a745c68b6e89e5d8f0d3def52594';
|
|
56
|
+
|
|
57
|
+
it('produces the correct canonical output', () => {
|
|
58
|
+
expect(canonicalJson(input)).toBe(expected);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('SHA-256 of output matches the spec', () => {
|
|
62
|
+
expect(hash(canonicalJson(input))).toBe(expectedHash);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Section 6.3 — Vector 3: primitives', () => {
|
|
67
|
+
it('null -> "null"', () => {
|
|
68
|
+
const output = canonicalJson(null);
|
|
69
|
+
expect(output).toBe('null');
|
|
70
|
+
expect(hash(output)).toBe('74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('true -> "true"', () => {
|
|
74
|
+
const output = canonicalJson(true);
|
|
75
|
+
expect(output).toBe('true');
|
|
76
|
+
expect(hash(output)).toBe('b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('42 -> "42"', () => {
|
|
80
|
+
const output = canonicalJson(42);
|
|
81
|
+
expect(output).toBe('42');
|
|
82
|
+
expect(hash(output)).toBe('73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('"hello" -> \'"hello"\'', () => {
|
|
86
|
+
const output = canonicalJson('hello');
|
|
87
|
+
expect(output).toBe('"hello"');
|
|
88
|
+
expect(hash(output)).toBe('5aa762ae383fbb727af3c7a36d4940a5b8c40a989452d2304fc958ff3f354e7a');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Section 6.3 — Vector 4: empty containers', () => {
|
|
93
|
+
it('{} -> "{}"', () => {
|
|
94
|
+
const output = canonicalJson({});
|
|
95
|
+
expect(output).toBe('{}');
|
|
96
|
+
expect(hash(output)).toBe('44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('[] -> "[]"', () => {
|
|
100
|
+
const output = canonicalJson([]);
|
|
101
|
+
expect(output).toBe('[]');
|
|
102
|
+
expect(hash(output)).toBe('4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Section 6.3 — Vector 5: negative zero', () => {
|
|
107
|
+
it('-0 -> "0"', () => {
|
|
108
|
+
const output = canonicalJson(-0);
|
|
109
|
+
expect(output).toBe('0');
|
|
110
|
+
expect(hash(output)).toBe('5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Section 6.3 — Vector 6: escaped control character', () => {
|
|
115
|
+
it('U+0001 -> "\\u0001" (lowercase hex)', () => {
|
|
116
|
+
const output = canonicalJson('\u0001');
|
|
117
|
+
expect(output).toBe('"\\u0001"');
|
|
118
|
+
expect(hash(output)).toBe('b81cfb0a6715e53b373345b49e8ad94eb55fd777519dc539373d0634973c186e');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Edge cases from Section 6.3 rules
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe('Section 6.3 — Edge cases', () => {
|
|
127
|
+
it('negative zero inside an object serializes as 0', () => {
|
|
128
|
+
expect(canonicalJson({ value: -0 })).toBe('{"value":0}');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('negative zero inside an array serializes as 0', () => {
|
|
132
|
+
expect(canonicalJson([-0])).toBe('[0]');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('NaN is rejected', () => {
|
|
136
|
+
expect(() => canonicalJson(NaN)).toThrow();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('Infinity is rejected', () => {
|
|
140
|
+
expect(() => canonicalJson(Infinity)).toThrow();
|
|
141
|
+
expect(() => canonicalJson(-Infinity)).toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('forward slash is NOT escaped', () => {
|
|
145
|
+
const output = canonicalJson('/');
|
|
146
|
+
expect(output).toBe('"/"');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('non-ASCII Unicode is output as literal UTF-8', () => {
|
|
150
|
+
expect(canonicalJson('中文')).toBe('"中文"');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('control characters U+0000-U+001F use correct escape forms', () => {
|
|
154
|
+
// Short forms
|
|
155
|
+
expect(canonicalJson('\b')).toBe('"\\b"');
|
|
156
|
+
expect(canonicalJson('\t')).toBe('"\\t"');
|
|
157
|
+
expect(canonicalJson('\n')).toBe('"\\n"');
|
|
158
|
+
expect(canonicalJson('\f')).toBe('"\\f"');
|
|
159
|
+
expect(canonicalJson('\r')).toBe('"\\r"');
|
|
160
|
+
|
|
161
|
+
// All other C0 controls use lowercase \\uXXXX
|
|
162
|
+
expect(canonicalJson('\x00')).toBe('"\\u0000"');
|
|
163
|
+
expect(canonicalJson('\x01')).toBe('"\\u0001"');
|
|
164
|
+
expect(canonicalJson('\x1f')).toBe('"\\u001f"');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('\\uXXXX escapes use lowercase hex digits only', () => {
|
|
168
|
+
for (let cp = 0; cp <= 0x1f; cp++) {
|
|
169
|
+
const result = canonicalJson(String.fromCharCode(cp));
|
|
170
|
+
// Verify no uppercase hex digits in any \u escape
|
|
171
|
+
const matches = result.match(/\\u[0-9a-fA-F]{4}/g);
|
|
172
|
+
if (matches) {
|
|
173
|
+
for (const m of matches) {
|
|
174
|
+
expect(m).toBe(m.toLowerCase());
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('no whitespace in structural output', () => {
|
|
181
|
+
const complex = {
|
|
182
|
+
z: [3, 1, 2],
|
|
183
|
+
a: { y: 'value', x: true },
|
|
184
|
+
m: null,
|
|
185
|
+
};
|
|
186
|
+
const output = canonicalJson(complex);
|
|
187
|
+
// Remove string content, then check structural chars only
|
|
188
|
+
const structural = output.replace(/"[^"]*"/g, '""');
|
|
189
|
+
expect(structural).not.toMatch(/\s/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('array order is preserved (NOT sorted)', () => {
|
|
193
|
+
expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('object keys are sorted alphabetically at every nesting level', () => {
|
|
197
|
+
const input = { z: { b: 2, a: 1 }, a: 0 };
|
|
198
|
+
expect(canonicalJson(input)).toBe('{"a":0,"z":{"a":1,"b":2}}');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('no trailing .0 on whole-number floats', () => {
|
|
202
|
+
expect(canonicalJson(100.0)).toBe('100');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('numbers have no leading zeroes or + prefix', () => {
|
|
206
|
+
expect(canonicalJson(42)).toBe('42');
|
|
207
|
+
expect(canonicalJson(0)).toBe('0');
|
|
208
|
+
expect(canonicalJson(-1)).toBe('-1');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Idempotency
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe('Section 6.3 — Idempotency', () => {
|
|
217
|
+
it('parsing the canonical output and re-canonicalizing produces identical bytes', () => {
|
|
218
|
+
const input = {
|
|
219
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
220
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
221
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
222
|
+
};
|
|
223
|
+
const first = canonicalJson(input);
|
|
224
|
+
const reparsed = JSON.parse(first);
|
|
225
|
+
expect(canonicalJson(reparsed)).toBe(first);
|
|
226
|
+
});
|
|
227
|
+
});
|