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