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
|
@@ -9,18 +9,14 @@
|
|
|
9
9
|
* correctly, protecting the dApp from a compromised or malicious wallet.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe,
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
sealPayload,
|
|
18
|
-
b64urlEncode,
|
|
19
|
-
} from '../../crypto.js';
|
|
20
|
-
import type { ProtocolMessage } from '../../types.js';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { generateX25519KeyPair, sealPayload } from '../../crypto.js'
|
|
14
|
+
import { DAppSession } from '../../dapp-session.js'
|
|
15
|
+
import { MockTransport, makeJoinBody } from '../../test-helpers.js'
|
|
16
|
+
import type { ProtocolMessage } from '../../types.js'
|
|
21
17
|
|
|
22
18
|
function wait(ms = 50): Promise<void> {
|
|
23
|
-
return new Promise((r) => setTimeout(r, ms))
|
|
19
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
// ---------------------------------------------------------------------------
|
|
@@ -28,34 +24,45 @@ function wait(ms = 50): Promise<void> {
|
|
|
28
24
|
// ---------------------------------------------------------------------------
|
|
29
25
|
|
|
30
26
|
function setupDAppManual() {
|
|
31
|
-
const transport = new MockTransport()
|
|
27
|
+
const transport = new MockTransport()
|
|
32
28
|
const session = new DAppSession({
|
|
33
29
|
transport,
|
|
34
|
-
meta: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
meta: {
|
|
31
|
+
name: 'Test',
|
|
32
|
+
description: 'Test dApp',
|
|
33
|
+
url: 'https://test.com',
|
|
34
|
+
icon: 'https://test.com/icon.png',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
const walletKp = generateX25519KeyPair()
|
|
38
|
+
return { transport, session, walletKp }
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
|
|
41
|
-
const { transport, session, walletKp } = ctx
|
|
42
|
-
await session.createPairing()
|
|
42
|
+
const { transport, session, walletKp } = ctx
|
|
43
|
+
await session.createPairing()
|
|
43
44
|
|
|
44
45
|
transport.receive({
|
|
45
|
-
v: 1,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
v: 1,
|
|
47
|
+
t: 'join',
|
|
48
|
+
ch: session.channelId,
|
|
49
|
+
ts: Date.now(),
|
|
50
|
+
from: walletKp.publicKeyB64,
|
|
51
|
+
body: makeJoinBody(session.channelId, transport.sent[0]?.from ?? '', walletKp),
|
|
52
|
+
} as ProtocolMessage)
|
|
49
53
|
|
|
50
54
|
transport.receive({
|
|
51
|
-
v: 1,
|
|
52
|
-
|
|
55
|
+
v: 1,
|
|
56
|
+
t: 'ready',
|
|
57
|
+
ch: session.channelId,
|
|
58
|
+
ts: Date.now(),
|
|
59
|
+
from: '_adapter',
|
|
53
60
|
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
54
|
-
} as ProtocolMessage)
|
|
61
|
+
} as ProtocolMessage)
|
|
55
62
|
|
|
56
|
-
const recvKey = (session as
|
|
57
|
-
const dappPubB64 = transport.sent[0]
|
|
58
|
-
return { recvKey, dappPubB64 }
|
|
63
|
+
const recvKey = (session as unknown as Record<string, unknown>).recvKey as Uint8Array
|
|
64
|
+
const dappPubB64 = transport.sent[0]?.from ?? ''
|
|
65
|
+
return { recvKey, dappPubB64 }
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
// ---------------------------------------------------------------------------
|
|
@@ -71,34 +78,39 @@ describe('Malicious Wallet: Wrong response ID', () => {
|
|
|
71
78
|
// PREVENTS: Response injection for non-existent requests.
|
|
72
79
|
// The dApp should silently ignore responses with unknown IDs.
|
|
73
80
|
|
|
74
|
-
const ctx = setupDAppManual()
|
|
75
|
-
const { transport, session, walletKp } = ctx
|
|
76
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
81
|
+
const ctx = setupDAppManual()
|
|
82
|
+
const { transport, session, walletKp } = ctx
|
|
83
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
77
84
|
|
|
78
|
-
const responseHandler = vi.fn()
|
|
79
|
-
session.on('response', responseHandler)
|
|
85
|
+
const responseHandler = vi.fn()
|
|
86
|
+
session.on('response', responseHandler)
|
|
80
87
|
|
|
81
88
|
// Wallet sends a response with a fabricated request ID
|
|
82
89
|
transport.receive({
|
|
83
|
-
v: 1,
|
|
84
|
-
|
|
90
|
+
v: 1,
|
|
91
|
+
t: 'res',
|
|
92
|
+
ch: session.channelId,
|
|
93
|
+
ts: Date.now(),
|
|
94
|
+
from: walletKp.publicKeyB64,
|
|
85
95
|
body: {
|
|
86
96
|
id: 'fabricated-req-id',
|
|
87
97
|
sealed: sealPayload(
|
|
88
|
-
recvKey,
|
|
98
|
+
recvKey,
|
|
99
|
+
session.channelId,
|
|
100
|
+
0,
|
|
89
101
|
{ _ok: true, _result: 'injected-data' },
|
|
90
102
|
{ type: 'res', from: walletKp.publicKeyB64, id: 'fabricated-req-id' },
|
|
91
103
|
),
|
|
92
104
|
},
|
|
93
|
-
} as ProtocolMessage)
|
|
105
|
+
} as ProtocolMessage)
|
|
94
106
|
|
|
95
|
-
await wait()
|
|
107
|
+
await wait()
|
|
96
108
|
|
|
97
109
|
// Response handler should NOT have been called (no matching pending request)
|
|
98
|
-
expect(responseHandler).not.toHaveBeenCalled()
|
|
110
|
+
expect(responseHandler).not.toHaveBeenCalled()
|
|
99
111
|
// Session should remain healthy
|
|
100
|
-
expect(session.phase).toBe('connected')
|
|
101
|
-
})
|
|
112
|
+
expect(session.phase).toBe('connected')
|
|
113
|
+
})
|
|
102
114
|
|
|
103
115
|
it('response to wrong req.id does not resolve a different pending request', async () => {
|
|
104
116
|
// ATTACK: Wallet sends a response with a different request's ID,
|
|
@@ -106,56 +118,66 @@ describe('Malicious Wallet: Wrong response ID', () => {
|
|
|
106
118
|
//
|
|
107
119
|
// PREVENTS: Cross-request response substitution.
|
|
108
120
|
|
|
109
|
-
const ctx = setupDAppManual()
|
|
110
|
-
const { transport, session, walletKp } = ctx
|
|
111
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
121
|
+
const ctx = setupDAppManual()
|
|
122
|
+
const { transport, session, walletKp } = ctx
|
|
123
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
112
124
|
|
|
113
125
|
// Send two requests
|
|
114
|
-
const p1 = session.request('wallet_getAccounts')
|
|
115
|
-
const p2 = session.request('wallet_signMessage', { message: 'test' })
|
|
116
|
-
await wait(20)
|
|
126
|
+
const p1 = session.request('wallet_getAccounts')
|
|
127
|
+
const p2 = session.request('wallet_signMessage', { message: 'test' })
|
|
128
|
+
await wait(20)
|
|
117
129
|
|
|
118
|
-
const reqs = transport.sent.filter(m => m.t === 'req')
|
|
119
|
-
const req1Id = reqs[0]
|
|
120
|
-
const req2Id = reqs[1]
|
|
130
|
+
const reqs = transport.sent.filter((m) => m.t === 'req')
|
|
131
|
+
const req1Id = (reqs[0]?.body as Record<string, unknown>)?.id as string
|
|
132
|
+
const req2Id = (reqs[1]?.body as Record<string, unknown>)?.id as string
|
|
121
133
|
|
|
122
134
|
// Wallet responds to req2 with req1's ID (cross-wired)
|
|
123
135
|
// The AAD includes the id field, so if the id doesn't match,
|
|
124
136
|
// AEAD decryption will fail (or the wrong request will be resolved
|
|
125
137
|
// with potentially confusing data).
|
|
126
138
|
transport.receive({
|
|
127
|
-
v: 1,
|
|
128
|
-
|
|
139
|
+
v: 1,
|
|
140
|
+
t: 'res',
|
|
141
|
+
ch: session.channelId,
|
|
142
|
+
ts: Date.now(),
|
|
143
|
+
from: walletKp.publicKeyB64,
|
|
129
144
|
body: {
|
|
130
145
|
id: req1Id,
|
|
131
146
|
sealed: sealPayload(
|
|
132
|
-
recvKey,
|
|
147
|
+
recvKey,
|
|
148
|
+
session.channelId,
|
|
149
|
+
0,
|
|
133
150
|
{ _ok: true, _result: 'correct-for-req1' },
|
|
134
151
|
{ type: 'res', from: walletKp.publicKeyB64, id: req1Id },
|
|
135
152
|
),
|
|
136
153
|
},
|
|
137
|
-
} as ProtocolMessage)
|
|
154
|
+
} as ProtocolMessage)
|
|
138
155
|
|
|
139
156
|
// req1 should resolve correctly
|
|
140
|
-
expect(await p1).toBe('correct-for-req1')
|
|
157
|
+
expect(await p1).toBe('correct-for-req1')
|
|
141
158
|
|
|
142
159
|
// Now respond to req2 normally
|
|
143
160
|
transport.receive({
|
|
144
|
-
v: 1,
|
|
145
|
-
|
|
161
|
+
v: 1,
|
|
162
|
+
t: 'res',
|
|
163
|
+
ch: session.channelId,
|
|
164
|
+
ts: Date.now(),
|
|
165
|
+
from: walletKp.publicKeyB64,
|
|
146
166
|
body: {
|
|
147
167
|
id: req2Id,
|
|
148
168
|
sealed: sealPayload(
|
|
149
|
-
recvKey,
|
|
169
|
+
recvKey,
|
|
170
|
+
session.channelId,
|
|
171
|
+
1,
|
|
150
172
|
{ _ok: true, _result: 'correct-for-req2' },
|
|
151
173
|
{ type: 'res', from: walletKp.publicKeyB64, id: req2Id },
|
|
152
174
|
),
|
|
153
175
|
},
|
|
154
|
-
} as ProtocolMessage)
|
|
176
|
+
} as ProtocolMessage)
|
|
155
177
|
|
|
156
|
-
expect(await p2).toBe('correct-for-req2')
|
|
157
|
-
})
|
|
158
|
-
})
|
|
178
|
+
expect(await p2).toBe('correct-for-req2')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
159
181
|
|
|
160
182
|
// ---------------------------------------------------------------------------
|
|
161
183
|
// Attack 2: Wallet sends req (role violation)
|
|
@@ -169,37 +191,42 @@ describe('Malicious Wallet: Sends req (role violation)', () => {
|
|
|
169
191
|
//
|
|
170
192
|
// PREVENTS: Role reversal attack where wallet tries to command the dApp.
|
|
171
193
|
|
|
172
|
-
const ctx = setupDAppManual()
|
|
173
|
-
const { transport, session, walletKp } = ctx
|
|
174
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
194
|
+
const ctx = setupDAppManual()
|
|
195
|
+
const { transport, session, walletKp } = ctx
|
|
196
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
175
197
|
|
|
176
|
-
const errorHandler = vi.fn()
|
|
177
|
-
session.on('error', errorHandler)
|
|
198
|
+
const errorHandler = vi.fn()
|
|
199
|
+
session.on('error', errorHandler)
|
|
178
200
|
|
|
179
201
|
// Wallet sends a req (which it should never do)
|
|
180
202
|
transport.receive({
|
|
181
|
-
v: 1,
|
|
182
|
-
|
|
203
|
+
v: 1,
|
|
204
|
+
t: 'req',
|
|
205
|
+
ch: session.channelId,
|
|
206
|
+
ts: Date.now(),
|
|
207
|
+
from: walletKp.publicKeyB64,
|
|
183
208
|
body: {
|
|
184
209
|
id: 'evil-req-1',
|
|
185
210
|
sealed: sealPayload(
|
|
186
|
-
recvKey,
|
|
211
|
+
recvKey,
|
|
212
|
+
session.channelId,
|
|
213
|
+
0,
|
|
187
214
|
{ _method: 'dapp_executeTransaction' },
|
|
188
215
|
{ type: 'req', from: walletKp.publicKeyB64, id: 'evil-req-1' },
|
|
189
216
|
),
|
|
190
217
|
},
|
|
191
|
-
} as ProtocolMessage)
|
|
218
|
+
} as ProtocolMessage)
|
|
192
219
|
|
|
193
|
-
await wait()
|
|
220
|
+
await wait()
|
|
194
221
|
|
|
195
222
|
// DApp does not have a request handler (it only sends requests).
|
|
196
223
|
// The message should be silently ignored or cause no state change.
|
|
197
224
|
// Key insight: DAppSession.handleMessage() has no case for 'req'
|
|
198
225
|
// messages from the wallet, so it falls through to the default
|
|
199
226
|
// case (no-op).
|
|
200
|
-
expect(session.phase).toBe('connected')
|
|
201
|
-
})
|
|
202
|
-
})
|
|
227
|
+
expect(session.phase).toBe('connected')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
203
230
|
|
|
204
231
|
// ---------------------------------------------------------------------------
|
|
205
232
|
// Attack 3: Wallet manipulates sequence numbers
|
|
@@ -210,31 +237,36 @@ describe('Malicious Wallet: Sequence number manipulation', () => {
|
|
|
210
237
|
// Per Section 6.6.1: "Gaps are valid (expected after reconnect)."
|
|
211
238
|
// This is NOT an attack — verifying correct behavior.
|
|
212
239
|
|
|
213
|
-
const ctx = setupDAppManual()
|
|
214
|
-
const { transport, session, walletKp } = ctx
|
|
215
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
240
|
+
const ctx = setupDAppManual()
|
|
241
|
+
const { transport, session, walletKp } = ctx
|
|
242
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
216
243
|
|
|
217
|
-
const p = session.request('wallet_getAccounts')
|
|
218
|
-
await wait(20)
|
|
219
|
-
const req = transport.sent.find(m => m.t === 'req')
|
|
220
|
-
const reqId = req
|
|
244
|
+
const p = session.request('wallet_getAccounts')
|
|
245
|
+
await wait(20)
|
|
246
|
+
const req = transport.sent.find((m) => m.t === 'req')
|
|
247
|
+
const reqId = (req?.body as Record<string, unknown>)?.id as string
|
|
221
248
|
|
|
222
249
|
// Wallet responds with seq=5 (skipping 0-4) — should be accepted
|
|
223
250
|
transport.receive({
|
|
224
|
-
v: 1,
|
|
225
|
-
|
|
251
|
+
v: 1,
|
|
252
|
+
t: 'res',
|
|
253
|
+
ch: session.channelId,
|
|
254
|
+
ts: Date.now(),
|
|
255
|
+
from: walletKp.publicKeyB64,
|
|
226
256
|
body: {
|
|
227
257
|
id: reqId,
|
|
228
258
|
sealed: sealPayload(
|
|
229
|
-
recvKey,
|
|
259
|
+
recvKey,
|
|
260
|
+
session.channelId,
|
|
261
|
+
5,
|
|
230
262
|
{ _ok: true, _result: 'gap-ok' },
|
|
231
263
|
{ type: 'res', from: walletKp.publicKeyB64, id: reqId },
|
|
232
264
|
),
|
|
233
265
|
},
|
|
234
|
-
} as ProtocolMessage)
|
|
266
|
+
} as ProtocolMessage)
|
|
235
267
|
|
|
236
|
-
expect(await p).toBe('gap-ok')
|
|
237
|
-
})
|
|
268
|
+
expect(await p).toBe('gap-ok')
|
|
269
|
+
})
|
|
238
270
|
|
|
239
271
|
it('reset sequence to 0 after receiving higher seq is rejected', async () => {
|
|
240
272
|
// ATTACK: Wallet sends seq=10, then tries to reset to seq=0.
|
|
@@ -244,97 +276,117 @@ describe('Malicious Wallet: Sequence number manipulation', () => {
|
|
|
244
276
|
//
|
|
245
277
|
// PREVENTS: Sequence counter reset allowing message replay.
|
|
246
278
|
|
|
247
|
-
const ctx = setupDAppManual()
|
|
248
|
-
const { transport, session, walletKp } = ctx
|
|
249
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
279
|
+
const ctx = setupDAppManual()
|
|
280
|
+
const { transport, session, walletKp } = ctx
|
|
281
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
250
282
|
|
|
251
283
|
// 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')
|
|
255
|
-
const r0id = req0
|
|
284
|
+
const p0 = session.request('wallet_getAccounts')
|
|
285
|
+
await wait(20)
|
|
286
|
+
const req0 = transport.sent.find((m) => m.t === 'req')
|
|
287
|
+
const r0id = (req0?.body as Record<string, unknown>)?.id as string
|
|
256
288
|
transport.receive({
|
|
257
|
-
v: 1,
|
|
258
|
-
|
|
289
|
+
v: 1,
|
|
290
|
+
t: 'res',
|
|
291
|
+
ch: session.channelId,
|
|
292
|
+
ts: Date.now(),
|
|
293
|
+
from: walletKp.publicKeyB64,
|
|
259
294
|
body: {
|
|
260
295
|
id: r0id,
|
|
261
296
|
sealed: sealPayload(
|
|
262
|
-
recvKey,
|
|
297
|
+
recvKey,
|
|
298
|
+
session.channelId,
|
|
299
|
+
10,
|
|
263
300
|
{ _ok: true, _result: 'first' },
|
|
264
301
|
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
265
302
|
),
|
|
266
303
|
},
|
|
267
|
-
} as ProtocolMessage)
|
|
268
|
-
expect(await p0).toBe('first')
|
|
304
|
+
} as ProtocolMessage)
|
|
305
|
+
expect(await p0).toBe('first')
|
|
269
306
|
|
|
270
307
|
// 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]
|
|
274
|
-
const r1id = req1
|
|
308
|
+
const p1 = session.request('wallet_getAccounts')
|
|
309
|
+
await wait(20)
|
|
310
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1]
|
|
311
|
+
const r1id = (req1?.body as Record<string, unknown>)?.id as string
|
|
275
312
|
transport.receive({
|
|
276
|
-
v: 1,
|
|
277
|
-
|
|
313
|
+
v: 1,
|
|
314
|
+
t: 'res',
|
|
315
|
+
ch: session.channelId,
|
|
316
|
+
ts: Date.now(),
|
|
317
|
+
from: walletKp.publicKeyB64,
|
|
278
318
|
body: {
|
|
279
319
|
id: r1id,
|
|
280
320
|
sealed: sealPayload(
|
|
281
|
-
recvKey,
|
|
321
|
+
recvKey,
|
|
322
|
+
session.channelId,
|
|
323
|
+
0, // reset to 0!
|
|
282
324
|
{ _ok: true, _result: 'replayed' },
|
|
283
325
|
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
284
326
|
),
|
|
285
327
|
},
|
|
286
|
-
} as ProtocolMessage)
|
|
287
|
-
await expect(p1).rejects.toThrow('Replay detected')
|
|
288
|
-
})
|
|
328
|
+
} as ProtocolMessage)
|
|
329
|
+
await expect(p1).rejects.toThrow('Replay detected')
|
|
330
|
+
})
|
|
289
331
|
|
|
290
332
|
it('reused sequence number is rejected', async () => {
|
|
291
333
|
// ATTACK: Wallet sends the same sequence number twice.
|
|
292
334
|
//
|
|
293
335
|
// PREVENTS: Nonce reuse in AEAD encryption.
|
|
294
336
|
|
|
295
|
-
const ctx = setupDAppManual()
|
|
296
|
-
const { transport, session, walletKp } = ctx
|
|
297
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
337
|
+
const ctx = setupDAppManual()
|
|
338
|
+
const { transport, session, walletKp } = ctx
|
|
339
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
298
340
|
|
|
299
341
|
// 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')
|
|
303
|
-
const r0id = req0
|
|
342
|
+
const p0 = session.request('wallet_getAccounts')
|
|
343
|
+
await wait(20)
|
|
344
|
+
const req0 = transport.sent.find((m) => m.t === 'req')
|
|
345
|
+
const r0id = (req0?.body as Record<string, unknown>)?.id as string
|
|
304
346
|
transport.receive({
|
|
305
|
-
v: 1,
|
|
306
|
-
|
|
347
|
+
v: 1,
|
|
348
|
+
t: 'res',
|
|
349
|
+
ch: session.channelId,
|
|
350
|
+
ts: Date.now(),
|
|
351
|
+
from: walletKp.publicKeyB64,
|
|
307
352
|
body: {
|
|
308
353
|
id: r0id,
|
|
309
354
|
sealed: sealPayload(
|
|
310
|
-
recvKey,
|
|
355
|
+
recvKey,
|
|
356
|
+
session.channelId,
|
|
357
|
+
3,
|
|
311
358
|
{ _ok: true, _result: 'ok' },
|
|
312
359
|
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
313
360
|
),
|
|
314
361
|
},
|
|
315
|
-
} as ProtocolMessage)
|
|
316
|
-
expect(await p0).toBe('ok')
|
|
362
|
+
} as ProtocolMessage)
|
|
363
|
+
expect(await p0).toBe('ok')
|
|
317
364
|
|
|
318
365
|
// 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]
|
|
322
|
-
const r1id = req1
|
|
366
|
+
const p1 = session.request('wallet_getAccounts')
|
|
367
|
+
await wait(20)
|
|
368
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1]
|
|
369
|
+
const r1id = (req1?.body as Record<string, unknown>)?.id as string
|
|
323
370
|
transport.receive({
|
|
324
|
-
v: 1,
|
|
325
|
-
|
|
371
|
+
v: 1,
|
|
372
|
+
t: 'res',
|
|
373
|
+
ch: session.channelId,
|
|
374
|
+
ts: Date.now(),
|
|
375
|
+
from: walletKp.publicKeyB64,
|
|
326
376
|
body: {
|
|
327
377
|
id: r1id,
|
|
328
378
|
sealed: sealPayload(
|
|
329
|
-
recvKey,
|
|
379
|
+
recvKey,
|
|
380
|
+
session.channelId,
|
|
381
|
+
3, // same seq!
|
|
330
382
|
{ _ok: true, _result: 'reused' },
|
|
331
383
|
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
332
384
|
),
|
|
333
385
|
},
|
|
334
|
-
} as ProtocolMessage)
|
|
335
|
-
await expect(p1).rejects.toThrow('Replay detected')
|
|
336
|
-
})
|
|
337
|
-
})
|
|
386
|
+
} as ProtocolMessage)
|
|
387
|
+
await expect(p1).rejects.toThrow('Replay detected')
|
|
388
|
+
})
|
|
389
|
+
})
|
|
338
390
|
|
|
339
391
|
// ---------------------------------------------------------------------------
|
|
340
392
|
// Attack 4: Wallet sends evt before ready.connected
|
|
@@ -348,46 +400,52 @@ describe('Malicious Wallet: Event before connected', () => {
|
|
|
348
400
|
//
|
|
349
401
|
// PREVENTS: Pre-connection event injection.
|
|
350
402
|
|
|
351
|
-
const transport = new MockTransport()
|
|
403
|
+
const transport = new MockTransport()
|
|
352
404
|
const session = new DAppSession({
|
|
353
405
|
transport,
|
|
354
406
|
meta: { name: 'T', description: 'T', url: 'https://t.test', icon: 'https://t.test/i.png' },
|
|
355
407
|
autoAccept: false, // manual accept to control timing
|
|
356
|
-
})
|
|
408
|
+
})
|
|
357
409
|
|
|
358
|
-
const eventHandler = vi.fn()
|
|
359
|
-
session.on('event', eventHandler)
|
|
410
|
+
const eventHandler = vi.fn()
|
|
411
|
+
session.on('event', eventHandler)
|
|
360
412
|
|
|
361
|
-
await session.createPairing()
|
|
362
|
-
const dappPubB64 = transport.sent[0]
|
|
413
|
+
await session.createPairing()
|
|
414
|
+
const dappPubB64 = transport.sent[0]?.from ?? ''
|
|
363
415
|
|
|
364
|
-
const walletKp = generateX25519KeyPair()
|
|
416
|
+
const walletKp = generateX25519KeyPair()
|
|
365
417
|
|
|
366
418
|
// Wallet joins
|
|
367
419
|
transport.receive({
|
|
368
|
-
v: 1,
|
|
369
|
-
|
|
420
|
+
v: 1,
|
|
421
|
+
t: 'join',
|
|
422
|
+
ch: session.channelId,
|
|
423
|
+
ts: Date.now(),
|
|
424
|
+
from: walletKp.publicKeyB64,
|
|
370
425
|
body: makeJoinBody(session.channelId, dappPubB64, walletKp),
|
|
371
|
-
} as ProtocolMessage)
|
|
426
|
+
} as ProtocolMessage)
|
|
372
427
|
|
|
373
|
-
await wait()
|
|
428
|
+
await wait()
|
|
374
429
|
// Session is now in pending_accept, NOT connected
|
|
375
430
|
|
|
376
431
|
// Wallet tries to send an event before connected
|
|
377
432
|
transport.receive({
|
|
378
|
-
v: 1,
|
|
379
|
-
|
|
433
|
+
v: 1,
|
|
434
|
+
t: 'evt',
|
|
435
|
+
ch: session.channelId,
|
|
436
|
+
ts: Date.now(),
|
|
437
|
+
from: walletKp.publicKeyB64,
|
|
380
438
|
body: { id: 'premature-evt', sealed: 'fake-sealed' },
|
|
381
|
-
} as ProtocolMessage)
|
|
439
|
+
} as ProtocolMessage)
|
|
382
440
|
|
|
383
|
-
await wait()
|
|
441
|
+
await wait()
|
|
384
442
|
|
|
385
443
|
// Event should NOT have been processed (recvKey exists but
|
|
386
444
|
// the sealed data is invalid, or the event is silently dropped
|
|
387
445
|
// due to decryption failure)
|
|
388
|
-
expect(eventHandler).not.toHaveBeenCalled()
|
|
389
|
-
})
|
|
390
|
-
})
|
|
446
|
+
expect(eventHandler).not.toHaveBeenCalled()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
391
449
|
|
|
392
450
|
// ---------------------------------------------------------------------------
|
|
393
451
|
// Attack 5: Wallet sends response from a different key
|
|
@@ -401,39 +459,44 @@ describe('Malicious Wallet: Response from wrong peer', () => {
|
|
|
401
459
|
//
|
|
402
460
|
// PREVENTS: Third-party response injection.
|
|
403
461
|
|
|
404
|
-
const ctx = setupDAppManual()
|
|
405
|
-
const { transport, session
|
|
406
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
462
|
+
const ctx = setupDAppManual()
|
|
463
|
+
const { transport, session } = ctx
|
|
464
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
407
465
|
|
|
408
|
-
const p = session.request('wallet_getAccounts')
|
|
409
|
-
await wait(20)
|
|
410
|
-
const req = transport.sent.find(m => m.t === 'req')
|
|
411
|
-
const reqId = req
|
|
466
|
+
const p = session.request('wallet_getAccounts')
|
|
467
|
+
await wait(20)
|
|
468
|
+
const req = transport.sent.find((m) => m.t === 'req')
|
|
469
|
+
const reqId = (req?.body as Record<string, unknown>)?.id as string
|
|
412
470
|
|
|
413
471
|
// Impersonator uses a different key
|
|
414
|
-
const impersonatorKp = generateX25519KeyPair()
|
|
472
|
+
const impersonatorKp = generateX25519KeyPair()
|
|
415
473
|
transport.receive({
|
|
416
|
-
v: 1,
|
|
417
|
-
|
|
474
|
+
v: 1,
|
|
475
|
+
t: 'res',
|
|
476
|
+
ch: session.channelId,
|
|
477
|
+
ts: Date.now(),
|
|
478
|
+
from: impersonatorKp.publicKeyB64, // wrong key!
|
|
418
479
|
body: {
|
|
419
480
|
id: reqId,
|
|
420
481
|
sealed: sealPayload(
|
|
421
|
-
recvKey,
|
|
482
|
+
recvKey,
|
|
483
|
+
session.channelId,
|
|
484
|
+
0,
|
|
422
485
|
{ _ok: true, _result: 'evil' },
|
|
423
486
|
{ type: 'res', from: impersonatorKp.publicKeyB64, id: reqId },
|
|
424
487
|
),
|
|
425
488
|
},
|
|
426
|
-
} as ProtocolMessage)
|
|
489
|
+
} as ProtocolMessage)
|
|
427
490
|
|
|
428
|
-
await wait(20)
|
|
491
|
+
await wait(20)
|
|
429
492
|
|
|
430
493
|
// The response should have been silently dropped (from mismatch)
|
|
431
494
|
// The request should still be pending (not resolved)
|
|
432
495
|
// Clean up by closing
|
|
433
|
-
session.close()
|
|
434
|
-
await expect(p).rejects.toThrow('Session closed')
|
|
435
|
-
})
|
|
436
|
-
})
|
|
496
|
+
session.close()
|
|
497
|
+
await expect(p).rejects.toThrow('Session closed')
|
|
498
|
+
})
|
|
499
|
+
})
|
|
437
500
|
|
|
438
501
|
// ---------------------------------------------------------------------------
|
|
439
502
|
// Attack 6: Wallet sends unsupported protocol version
|
|
@@ -446,22 +509,25 @@ describe('Malicious Wallet: Unsupported protocol version', () => {
|
|
|
446
509
|
//
|
|
447
510
|
// PREVENTS: Version confusion attacks. Section 15 rule 12.
|
|
448
511
|
|
|
449
|
-
const ctx = setupDAppManual()
|
|
450
|
-
const { transport, session, walletKp } = ctx
|
|
451
|
-
await connectDAppManual(ctx)
|
|
512
|
+
const ctx = setupDAppManual()
|
|
513
|
+
const { transport, session, walletKp } = ctx
|
|
514
|
+
await connectDAppManual(ctx)
|
|
452
515
|
|
|
453
516
|
transport.receive({
|
|
454
|
-
v: 99 as
|
|
455
|
-
|
|
517
|
+
v: 99 as ProtocolMessage['v'],
|
|
518
|
+
t: 'res',
|
|
519
|
+
ch: session.channelId,
|
|
520
|
+
ts: Date.now(),
|
|
521
|
+
from: walletKp.publicKeyB64,
|
|
456
522
|
body: { id: 'req-1', sealed: 'whatever' },
|
|
457
|
-
} as ProtocolMessage)
|
|
523
|
+
} as ProtocolMessage)
|
|
458
524
|
|
|
459
|
-
await wait()
|
|
525
|
+
await wait()
|
|
460
526
|
|
|
461
527
|
// DApp should close with unsupported_version
|
|
462
|
-
expect(session.phase).toBe('closed')
|
|
463
|
-
const closeMsg = transport.sent.find(m => m.t === 'close')
|
|
464
|
-
expect(closeMsg).toBeTruthy()
|
|
465
|
-
expect(closeMsg
|
|
466
|
-
})
|
|
467
|
-
})
|
|
528
|
+
expect(session.phase).toBe('closed')
|
|
529
|
+
const closeMsg = transport.sent.find((m) => m.t === 'close')
|
|
530
|
+
expect(closeMsg).toBeTruthy()
|
|
531
|
+
expect((closeMsg?.body as Record<string, unknown>)?.reason).toBe('unsupported_version')
|
|
532
|
+
})
|
|
533
|
+
})
|