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