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
package/src/security.test.ts
CHANGED
|
@@ -6,28 +6,28 @@
|
|
|
6
6
|
* across sessions.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe,
|
|
10
|
-
import {
|
|
11
|
-
import { WalletSession } from './wallet-session.js';
|
|
12
|
-
import { makeJoinBody, MockTransport, MockRelay } from './test-helpers.js';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import type { SessionCryptoContext } from './crypto.js'
|
|
13
11
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
b64urlDecode,
|
|
13
|
+
b64urlEncode,
|
|
14
|
+
buildPairingUri,
|
|
15
|
+
bytesToHex,
|
|
16
16
|
computeSharedSecret,
|
|
17
|
+
deriveDirectionalSessionKeys,
|
|
17
18
|
deriveSessionKey,
|
|
19
|
+
generateChannelId,
|
|
20
|
+
generateX25519KeyPair,
|
|
18
21
|
sealPayload,
|
|
19
22
|
unsealPayload,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from './
|
|
25
|
-
import { deriveDirectionalSessionKeys } from './crypto.js';
|
|
26
|
-
import type { SessionCryptoContext } from './crypto.js';
|
|
27
|
-
import type { ProtocolMessage } from './types.js';
|
|
23
|
+
} from './crypto.js'
|
|
24
|
+
import { DAppSession } from './dapp-session.js'
|
|
25
|
+
import { MockTransport, makeJoinBody } from './test-helpers.js'
|
|
26
|
+
import type { ProtocolMessage } from './types.js'
|
|
27
|
+
import { WalletSession } from './wallet-session.js'
|
|
28
28
|
|
|
29
29
|
function wait(ms = 50): Promise<void> {
|
|
30
|
-
return new Promise((r) => setTimeout(r, ms))
|
|
30
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
@@ -35,49 +35,68 @@ function wait(ms = 50): Promise<void> {
|
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
function setupDAppManual() {
|
|
38
|
-
const transport = new MockTransport()
|
|
39
|
-
const session = new DAppSession({
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const transport = new MockTransport()
|
|
39
|
+
const session = new DAppSession({
|
|
40
|
+
transport,
|
|
41
|
+
meta: {
|
|
42
|
+
name: 'Test',
|
|
43
|
+
description: 'Test dApp',
|
|
44
|
+
url: 'https://test.com',
|
|
45
|
+
icon: 'https://test.com/icon.png',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
const walletKp = generateX25519KeyPair()
|
|
49
|
+
return { transport, session, walletKp }
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
|
|
45
|
-
const { transport, session, walletKp } = ctx
|
|
46
|
-
await session.createPairing()
|
|
53
|
+
const { transport, session, walletKp } = ctx
|
|
54
|
+
await session.createPairing()
|
|
47
55
|
|
|
48
56
|
transport.receive({
|
|
49
|
-
v: 1,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
v: 1,
|
|
58
|
+
t: 'join',
|
|
59
|
+
ch: session.channelId,
|
|
60
|
+
ts: Date.now(),
|
|
61
|
+
from: walletKp.publicKeyB64,
|
|
62
|
+
body: makeJoinBody(session.channelId, transport.sent[0]?.from!, walletKp),
|
|
63
|
+
} as ProtocolMessage)
|
|
53
64
|
|
|
54
65
|
transport.receive({
|
|
55
|
-
v: 1,
|
|
56
|
-
|
|
66
|
+
v: 1,
|
|
67
|
+
t: 'ready',
|
|
68
|
+
ch: session.channelId,
|
|
69
|
+
ts: Date.now(),
|
|
70
|
+
from: '_adapter',
|
|
57
71
|
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
58
|
-
} as ProtocolMessage)
|
|
72
|
+
} as ProtocolMessage)
|
|
59
73
|
|
|
60
74
|
// Derive the wallet's send key (walletToDappKey) which is what
|
|
61
75
|
// the DAppSession expects to receive (its recvKey).
|
|
62
|
-
const recvKey = (session as any).recvKey as Uint8Array
|
|
63
|
-
const dappPubB64 = transport.sent[0]
|
|
64
|
-
return { recvKey, dappPubB64 }
|
|
76
|
+
const recvKey = (session as any).recvKey as Uint8Array
|
|
77
|
+
const dappPubB64 = transport.sent[0]?.from!
|
|
78
|
+
return { recvKey, dappPubB64 }
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
function setupWalletManual() {
|
|
68
|
-
const transport = new MockTransport()
|
|
69
|
-
const dappKp = generateX25519KeyPair()
|
|
70
|
-
const channelId = generateChannelId()
|
|
82
|
+
const transport = new MockTransport()
|
|
83
|
+
const dappKp = generateX25519KeyPair()
|
|
84
|
+
const channelId = generateChannelId()
|
|
71
85
|
const session = new WalletSession({
|
|
72
86
|
transport,
|
|
73
|
-
meta: {
|
|
87
|
+
meta: {
|
|
88
|
+
name: 'Test Wallet',
|
|
89
|
+
description: 'Test wallet',
|
|
90
|
+
url: 'https://wallet.test',
|
|
91
|
+
icon: 'https://wallet.test/icon.png',
|
|
92
|
+
},
|
|
74
93
|
capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
|
|
75
|
-
})
|
|
76
|
-
return { transport, session, dappKp, channelId }
|
|
94
|
+
})
|
|
95
|
+
return { transport, session, dappKp, channelId }
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
async function connectWalletManual(ctx: ReturnType<typeof setupWalletManual>) {
|
|
80
|
-
const { transport, session, dappKp, channelId } = ctx
|
|
99
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
81
100
|
const uri = buildPairingUri({
|
|
82
101
|
channelId,
|
|
83
102
|
pubkeyB64: dappKp.publicKeyB64,
|
|
@@ -85,19 +104,22 @@ async function connectWalletManual(ctx: ReturnType<typeof setupWalletManual>) {
|
|
|
85
104
|
name: 'Test dApp',
|
|
86
105
|
url: 'https://dapp.test',
|
|
87
106
|
icon: 'https://dapp.test/icon.png',
|
|
88
|
-
})
|
|
89
|
-
await session.joinFromUri(uri)
|
|
107
|
+
})
|
|
108
|
+
await session.joinFromUri(uri)
|
|
90
109
|
|
|
91
110
|
transport.receive({
|
|
92
|
-
v: 1,
|
|
93
|
-
|
|
111
|
+
v: 1,
|
|
112
|
+
t: 'ready',
|
|
113
|
+
ch: channelId,
|
|
114
|
+
ts: Date.now(),
|
|
115
|
+
from: '_adapter',
|
|
94
116
|
body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
|
|
95
|
-
} as ProtocolMessage)
|
|
117
|
+
} as ProtocolMessage)
|
|
96
118
|
|
|
97
119
|
// The wallet's recvKey is dappToWalletKey
|
|
98
|
-
const recvKey = (session as any).recvKey as Uint8Array
|
|
99
|
-
const walletPubB64 = transport.sent.find(m => m.t === 'join')
|
|
100
|
-
return { recvKey, walletPubB64 }
|
|
120
|
+
const recvKey = (session as any).recvKey as Uint8Array
|
|
121
|
+
const walletPubB64 = transport.sent.find((m) => m.t === 'join')?.from!
|
|
122
|
+
return { recvKey, walletPubB64 }
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
// ---------------------------------------------------------------------------
|
|
@@ -105,326 +127,397 @@ async function connectWalletManual(ctx: ReturnType<typeof setupWalletManual>) {
|
|
|
105
127
|
// ---------------------------------------------------------------------------
|
|
106
128
|
|
|
107
129
|
describe('Security: Replay detection', () => {
|
|
108
|
-
|
|
109
130
|
it('same sequence number is rejected', async () => {
|
|
110
|
-
const ctx = setupDAppManual()
|
|
111
|
-
const { transport, session, walletKp } = ctx
|
|
112
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
131
|
+
const ctx = setupDAppManual()
|
|
132
|
+
const { transport, session, walletKp } = ctx
|
|
133
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
113
134
|
|
|
114
135
|
// First request -> seq=0 response accepted
|
|
115
|
-
const p0 = session.request('wallet_getAccounts')
|
|
116
|
-
await wait(20)
|
|
117
|
-
const req0 = transport.sent.find(m => m.t === 'req') as any
|
|
118
|
-
const req0Id = req0.body.id
|
|
136
|
+
const p0 = session.request('wallet_getAccounts')
|
|
137
|
+
await wait(20)
|
|
138
|
+
const req0 = transport.sent.find((m) => m.t === 'req') as any
|
|
139
|
+
const req0Id = req0.body.id
|
|
119
140
|
|
|
120
141
|
transport.receive({
|
|
121
|
-
v: 1,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
v: 1,
|
|
143
|
+
t: 'res',
|
|
144
|
+
ch: session.channelId,
|
|
145
|
+
ts: Date.now(),
|
|
146
|
+
from: walletKp.publicKeyB64,
|
|
147
|
+
body: {
|
|
148
|
+
id: req0Id,
|
|
149
|
+
sealed: sealPayload(
|
|
150
|
+
recvKey,
|
|
151
|
+
session.channelId,
|
|
152
|
+
0,
|
|
153
|
+
{ _ok: true, _result: ['0xa'] },
|
|
154
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req0Id },
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
} as ProtocolMessage)
|
|
158
|
+
expect(await p0).toEqual(['0xa'])
|
|
127
159
|
|
|
128
160
|
// Second request -> seq=0 again (replay) must be rejected
|
|
129
|
-
const p1 = session.request('wallet_getAccounts')
|
|
130
|
-
await wait(20)
|
|
131
|
-
const req1 = transport.sent.filter(m => m.t === 'req')[1] as any
|
|
132
|
-
const req1Id = req1.body.id
|
|
161
|
+
const p1 = session.request('wallet_getAccounts')
|
|
162
|
+
await wait(20)
|
|
163
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
|
|
164
|
+
const req1Id = req1.body.id
|
|
133
165
|
|
|
134
166
|
transport.receive({
|
|
135
|
-
v: 1,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
167
|
+
v: 1,
|
|
168
|
+
t: 'res',
|
|
169
|
+
ch: session.channelId,
|
|
170
|
+
ts: Date.now(),
|
|
171
|
+
from: walletKp.publicKeyB64,
|
|
172
|
+
body: {
|
|
173
|
+
id: req1Id,
|
|
174
|
+
sealed: sealPayload(
|
|
175
|
+
recvKey,
|
|
176
|
+
session.channelId,
|
|
177
|
+
0,
|
|
178
|
+
{ _ok: true, _result: ['replay'] },
|
|
179
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req1Id },
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
} as ProtocolMessage)
|
|
183
|
+
await expect(p1).rejects.toThrow('Replay detected')
|
|
184
|
+
})
|
|
142
185
|
|
|
143
186
|
it('lower sequence number is rejected', async () => {
|
|
144
|
-
const ctx = setupDAppManual()
|
|
145
|
-
const { transport, session, walletKp } = ctx
|
|
146
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
187
|
+
const ctx = setupDAppManual()
|
|
188
|
+
const { transport, session, walletKp } = ctx
|
|
189
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
147
190
|
|
|
148
191
|
// seq=5 accepted
|
|
149
|
-
const p0 = session.request('wallet_getAccounts')
|
|
150
|
-
await wait(20)
|
|
151
|
-
const req0 = transport.sent.find(m => m.t === 'req') as any
|
|
152
|
-
const r0id = req0.body.id
|
|
192
|
+
const p0 = session.request('wallet_getAccounts')
|
|
193
|
+
await wait(20)
|
|
194
|
+
const req0 = transport.sent.find((m) => m.t === 'req') as any
|
|
195
|
+
const r0id = req0.body.id
|
|
153
196
|
transport.receive({
|
|
154
|
-
v: 1,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
197
|
+
v: 1,
|
|
198
|
+
t: 'res',
|
|
199
|
+
ch: session.channelId,
|
|
200
|
+
ts: Date.now(),
|
|
201
|
+
from: walletKp.publicKeyB64,
|
|
202
|
+
body: {
|
|
203
|
+
id: r0id,
|
|
204
|
+
sealed: sealPayload(
|
|
205
|
+
recvKey,
|
|
206
|
+
session.channelId,
|
|
207
|
+
5,
|
|
208
|
+
{ _ok: true, _result: 'ok' },
|
|
209
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
} as ProtocolMessage)
|
|
213
|
+
expect(await p0).toBe('ok')
|
|
160
214
|
|
|
161
215
|
// seq=3 (lower) must be rejected
|
|
162
|
-
const p1 = session.request('wallet_getAccounts')
|
|
163
|
-
await wait(20)
|
|
164
|
-
const req1 = transport.sent.filter(m => m.t === 'req')[1] as any
|
|
165
|
-
const r1id = req1.body.id
|
|
216
|
+
const p1 = session.request('wallet_getAccounts')
|
|
217
|
+
await wait(20)
|
|
218
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
|
|
219
|
+
const r1id = req1.body.id
|
|
166
220
|
transport.receive({
|
|
167
|
-
v: 1,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
221
|
+
v: 1,
|
|
222
|
+
t: 'res',
|
|
223
|
+
ch: session.channelId,
|
|
224
|
+
ts: Date.now(),
|
|
225
|
+
from: walletKp.publicKeyB64,
|
|
226
|
+
body: {
|
|
227
|
+
id: r1id,
|
|
228
|
+
sealed: sealPayload(
|
|
229
|
+
recvKey,
|
|
230
|
+
session.channelId,
|
|
231
|
+
3,
|
|
232
|
+
{ _ok: true, _result: 'stale' },
|
|
233
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
234
|
+
),
|
|
235
|
+
},
|
|
236
|
+
} as ProtocolMessage)
|
|
237
|
+
await expect(p1).rejects.toThrow('Replay detected')
|
|
238
|
+
})
|
|
174
239
|
|
|
175
240
|
it('higher sequence number is accepted', async () => {
|
|
176
|
-
const ctx = setupDAppManual()
|
|
177
|
-
const { transport, session, walletKp } = ctx
|
|
178
|
-
const { recvKey } = await connectDAppManual(ctx)
|
|
241
|
+
const ctx = setupDAppManual()
|
|
242
|
+
const { transport, session, walletKp } = ctx
|
|
243
|
+
const { recvKey } = await connectDAppManual(ctx)
|
|
179
244
|
|
|
180
245
|
// seq=0 accepted
|
|
181
|
-
const p0 = session.request('wallet_getAccounts')
|
|
182
|
-
await wait(20)
|
|
183
|
-
const req0 = transport.sent.find(m => m.t === 'req') as any
|
|
184
|
-
const r0id = req0.body.id
|
|
246
|
+
const p0 = session.request('wallet_getAccounts')
|
|
247
|
+
await wait(20)
|
|
248
|
+
const req0 = transport.sent.find((m) => m.t === 'req') as any
|
|
249
|
+
const r0id = req0.body.id
|
|
185
250
|
transport.receive({
|
|
186
|
-
v: 1,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
251
|
+
v: 1,
|
|
252
|
+
t: 'res',
|
|
253
|
+
ch: session.channelId,
|
|
254
|
+
ts: Date.now(),
|
|
255
|
+
from: walletKp.publicKeyB64,
|
|
256
|
+
body: {
|
|
257
|
+
id: r0id,
|
|
258
|
+
sealed: sealPayload(
|
|
259
|
+
recvKey,
|
|
260
|
+
session.channelId,
|
|
261
|
+
0,
|
|
262
|
+
{ _ok: true, _result: 'first' },
|
|
263
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id },
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
} as ProtocolMessage)
|
|
267
|
+
expect(await p0).toBe('first')
|
|
192
268
|
|
|
193
269
|
// seq=10 (higher) accepted
|
|
194
|
-
const p1 = session.request('wallet_getAccounts')
|
|
195
|
-
await wait(20)
|
|
196
|
-
const req1 = transport.sent.filter(m => m.t === 'req')[1] as any
|
|
197
|
-
const r1id = req1.body.id
|
|
270
|
+
const p1 = session.request('wallet_getAccounts')
|
|
271
|
+
await wait(20)
|
|
272
|
+
const req1 = transport.sent.filter((m) => m.t === 'req')[1] as any
|
|
273
|
+
const r1id = req1.body.id
|
|
198
274
|
transport.receive({
|
|
199
|
-
v: 1,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
275
|
+
v: 1,
|
|
276
|
+
t: 'res',
|
|
277
|
+
ch: session.channelId,
|
|
278
|
+
ts: Date.now(),
|
|
279
|
+
from: walletKp.publicKeyB64,
|
|
280
|
+
body: {
|
|
281
|
+
id: r1id,
|
|
282
|
+
sealed: sealPayload(
|
|
283
|
+
recvKey,
|
|
284
|
+
session.channelId,
|
|
285
|
+
10,
|
|
286
|
+
{ _ok: true, _result: 'second' },
|
|
287
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id },
|
|
288
|
+
),
|
|
289
|
+
},
|
|
290
|
+
} as ProtocolMessage)
|
|
291
|
+
expect(await p1).toBe('second')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
207
294
|
|
|
208
295
|
// ---------------------------------------------------------------------------
|
|
209
296
|
// Tests: AAD tampering
|
|
210
297
|
// ---------------------------------------------------------------------------
|
|
211
298
|
|
|
212
299
|
describe('Security: AAD tampering', () => {
|
|
213
|
-
|
|
214
300
|
it('tampered AAD (wrong id) causes decryption failure', () => {
|
|
215
|
-
const key = new Uint8Array(32)
|
|
216
|
-
crypto.getRandomValues(key)
|
|
217
|
-
const channelId = generateChannelId()
|
|
301
|
+
const key = new Uint8Array(32)
|
|
302
|
+
crypto.getRandomValues(key)
|
|
303
|
+
const channelId = generateChannelId()
|
|
218
304
|
|
|
219
|
-
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
220
|
-
const sealed = sealPayload(key, channelId, 0, { foo: 'bar' }, hdr)
|
|
305
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
306
|
+
const sealed = sealPayload(key, channelId, 0, { foo: 'bar' }, hdr)
|
|
221
307
|
|
|
222
|
-
const tamperedHdr = { ...hdr, id: 'req-999' }
|
|
223
|
-
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow()
|
|
224
|
-
})
|
|
308
|
+
const tamperedHdr = { ...hdr, id: 'req-999' }
|
|
309
|
+
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow()
|
|
310
|
+
})
|
|
225
311
|
|
|
226
312
|
it('tampered AAD (wrong from) causes decryption failure', () => {
|
|
227
|
-
const key = new Uint8Array(32)
|
|
228
|
-
crypto.getRandomValues(key)
|
|
229
|
-
const channelId = generateChannelId()
|
|
313
|
+
const key = new Uint8Array(32)
|
|
314
|
+
crypto.getRandomValues(key)
|
|
315
|
+
const channelId = generateChannelId()
|
|
230
316
|
|
|
231
|
-
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
232
|
-
const sealed = sealPayload(key, channelId, 0, {}, hdr)
|
|
317
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
318
|
+
const sealed = sealPayload(key, channelId, 0, {}, hdr)
|
|
233
319
|
|
|
234
|
-
const tamperedHdr = { ...hdr, from: 'evil-relay' }
|
|
235
|
-
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow()
|
|
236
|
-
})
|
|
237
|
-
})
|
|
320
|
+
const tamperedHdr = { ...hdr, from: 'evil-relay' }
|
|
321
|
+
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow()
|
|
322
|
+
})
|
|
323
|
+
})
|
|
238
324
|
|
|
239
325
|
// ---------------------------------------------------------------------------
|
|
240
326
|
// Tests: Ciphertext and key tampering
|
|
241
327
|
// ---------------------------------------------------------------------------
|
|
242
328
|
|
|
243
329
|
describe('Security: Ciphertext and key tampering', () => {
|
|
244
|
-
|
|
245
330
|
it('tampered ciphertext causes decryption failure', () => {
|
|
246
|
-
const key = new Uint8Array(32)
|
|
247
|
-
crypto.getRandomValues(key)
|
|
248
|
-
const channelId = generateChannelId()
|
|
331
|
+
const key = new Uint8Array(32)
|
|
332
|
+
crypto.getRandomValues(key)
|
|
333
|
+
const channelId = generateChannelId()
|
|
249
334
|
|
|
250
|
-
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
251
|
-
const sealed = sealPayload(key, channelId, 0, { secret: true }, hdr)
|
|
335
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' }
|
|
336
|
+
const sealed = sealPayload(key, channelId, 0, { secret: true }, hdr)
|
|
252
337
|
|
|
253
338
|
// Decode, flip a byte in the ciphertext, re-encode
|
|
254
|
-
const bytes = b64urlDecode(sealed)
|
|
255
|
-
bytes[10] = bytes[10]! ^ 0xff
|
|
256
|
-
const tampered = b64urlEncode(bytes)
|
|
339
|
+
const bytes = b64urlDecode(sealed)
|
|
340
|
+
bytes[10] = bytes[10]! ^ 0xff
|
|
341
|
+
const tampered = b64urlEncode(bytes)
|
|
257
342
|
|
|
258
|
-
expect(() => unsealPayload(key, channelId, tampered, hdr)).toThrow()
|
|
259
|
-
})
|
|
343
|
+
expect(() => unsealPayload(key, channelId, tampered, hdr)).toThrow()
|
|
344
|
+
})
|
|
260
345
|
|
|
261
346
|
it('wrong key causes decryption failure', () => {
|
|
262
|
-
const key = new Uint8Array(32)
|
|
263
|
-
crypto.getRandomValues(key)
|
|
264
|
-
const wrongKey = new Uint8Array(32)
|
|
265
|
-
crypto.getRandomValues(wrongKey)
|
|
266
|
-
const channelId = generateChannelId()
|
|
347
|
+
const key = new Uint8Array(32)
|
|
348
|
+
crypto.getRandomValues(key)
|
|
349
|
+
const wrongKey = new Uint8Array(32)
|
|
350
|
+
crypto.getRandomValues(wrongKey)
|
|
351
|
+
const channelId = generateChannelId()
|
|
267
352
|
|
|
268
|
-
const sealed = sealPayload(key, channelId, 0, { data: 'test' })
|
|
269
|
-
expect(() => unsealPayload(wrongKey, channelId, sealed)).toThrow()
|
|
270
|
-
})
|
|
271
|
-
})
|
|
353
|
+
const sealed = sealPayload(key, channelId, 0, { data: 'test' })
|
|
354
|
+
expect(() => unsealPayload(wrongKey, channelId, sealed)).toThrow()
|
|
355
|
+
})
|
|
356
|
+
})
|
|
272
357
|
|
|
273
358
|
// ---------------------------------------------------------------------------
|
|
274
359
|
// Tests: Sequence overflow closes session
|
|
275
360
|
// ---------------------------------------------------------------------------
|
|
276
361
|
|
|
277
362
|
describe('Security: Sequence overflow', () => {
|
|
278
|
-
|
|
279
363
|
it('DAppSession: overflow at MAX_SEND_SEQ causes session close', async () => {
|
|
280
|
-
const ctx = setupDAppManual()
|
|
281
|
-
const { session } = ctx
|
|
282
|
-
await connectDAppManual(ctx)
|
|
364
|
+
const ctx = setupDAppManual()
|
|
365
|
+
const { session } = ctx
|
|
366
|
+
await connectDAppManual(ctx)
|
|
283
367
|
|
|
284
|
-
(session as any).sendSeq = 2 ** 31
|
|
368
|
+
;(session as any).sendSeq = 2 ** 31
|
|
285
369
|
|
|
286
|
-
const errorHandler = vi.fn()
|
|
287
|
-
session.on('error', errorHandler)
|
|
370
|
+
const errorHandler = vi.fn()
|
|
371
|
+
session.on('error', errorHandler)
|
|
288
372
|
|
|
289
|
-
await expect(session.request('wallet_getAccounts')).rejects.toThrow('Send sequence overflow')
|
|
290
|
-
expect(errorHandler).toHaveBeenCalled()
|
|
291
|
-
expect(session.phase).toBe('closed')
|
|
292
|
-
})
|
|
373
|
+
await expect(session.request('wallet_getAccounts')).rejects.toThrow('Send sequence overflow')
|
|
374
|
+
expect(errorHandler).toHaveBeenCalled()
|
|
375
|
+
expect(session.phase).toBe('closed')
|
|
376
|
+
})
|
|
293
377
|
|
|
294
378
|
it('WalletSession: overflow at MAX_SEND_SEQ causes session close via pushEvent', async () => {
|
|
295
|
-
const ctx = setupWalletManual()
|
|
296
|
-
const { session } = ctx
|
|
297
|
-
await connectWalletManual(ctx)
|
|
379
|
+
const ctx = setupWalletManual()
|
|
380
|
+
const { session } = ctx
|
|
381
|
+
await connectWalletManual(ctx)
|
|
298
382
|
|
|
299
|
-
(session as any).sendSeq = 2 ** 31 - 1
|
|
383
|
+
;(session as any).sendSeq = 2 ** 31 - 1
|
|
300
384
|
|
|
301
|
-
const errorHandler = vi.fn()
|
|
302
|
-
session.on('error', errorHandler)
|
|
385
|
+
const errorHandler = vi.fn()
|
|
386
|
+
session.on('error', errorHandler)
|
|
303
387
|
|
|
304
388
|
// Last allowed
|
|
305
|
-
session.pushEvent('accountsChanged', { accounts: ['0xa'] })
|
|
306
|
-
expect(session.phase).toBe('connected')
|
|
389
|
+
session.pushEvent('accountsChanged', { accounts: ['0xa'] })
|
|
390
|
+
expect(session.phase).toBe('connected')
|
|
307
391
|
|
|
308
392
|
// Overflow
|
|
309
|
-
session.pushEvent('accountsChanged', { accounts: ['0xb'] })
|
|
310
|
-
expect(errorHandler).toHaveBeenCalled()
|
|
311
|
-
expect(session.phase).toBe('closed')
|
|
312
|
-
})
|
|
313
|
-
})
|
|
393
|
+
session.pushEvent('accountsChanged', { accounts: ['0xb'] })
|
|
394
|
+
expect(errorHandler).toHaveBeenCalled()
|
|
395
|
+
expect(session.phase).toBe('closed')
|
|
396
|
+
})
|
|
397
|
+
})
|
|
314
398
|
|
|
315
399
|
// ---------------------------------------------------------------------------
|
|
316
400
|
// Tests: Mandatory encryption enforcement
|
|
317
401
|
// ---------------------------------------------------------------------------
|
|
318
402
|
|
|
319
403
|
describe('Security: Mandatory encryption', () => {
|
|
320
|
-
|
|
321
404
|
it('DAppSession rejects unsealed responses', async () => {
|
|
322
|
-
const ctx = setupDAppManual()
|
|
323
|
-
const { transport, session, walletKp } = ctx
|
|
324
|
-
await connectDAppManual(ctx)
|
|
405
|
+
const ctx = setupDAppManual()
|
|
406
|
+
const { transport, session, walletKp } = ctx
|
|
407
|
+
await connectDAppManual(ctx)
|
|
325
408
|
|
|
326
|
-
const p = session.request('wallet_getAccounts')
|
|
327
|
-
await wait(20)
|
|
328
|
-
const req = transport.sent.find(m => m.t === 'req') as any
|
|
329
|
-
const reqId = req.body.id
|
|
409
|
+
const p = session.request('wallet_getAccounts')
|
|
410
|
+
await wait(20)
|
|
411
|
+
const req = transport.sent.find((m) => m.t === 'req') as any
|
|
412
|
+
const reqId = req.body.id
|
|
330
413
|
|
|
331
414
|
// Send a response without sealed field
|
|
332
415
|
transport.receive({
|
|
333
|
-
v: 1,
|
|
334
|
-
|
|
416
|
+
v: 1,
|
|
417
|
+
t: 'res',
|
|
418
|
+
ch: session.channelId,
|
|
419
|
+
ts: Date.now(),
|
|
420
|
+
from: walletKp.publicKeyB64,
|
|
335
421
|
body: { id: reqId },
|
|
336
422
|
// no sealed field in body
|
|
337
|
-
} as ProtocolMessage)
|
|
423
|
+
} as ProtocolMessage)
|
|
338
424
|
|
|
339
|
-
await expect(p).rejects.toThrow('Response must be encrypted')
|
|
340
|
-
})
|
|
425
|
+
await expect(p).rejects.toThrow('Response must be encrypted')
|
|
426
|
+
})
|
|
341
427
|
|
|
342
428
|
it('WalletSession rejects unsealed requests', async () => {
|
|
343
|
-
const ctx = setupWalletManual()
|
|
344
|
-
const { transport, session, dappKp, channelId } = ctx
|
|
345
|
-
await connectWalletManual(ctx)
|
|
429
|
+
const ctx = setupWalletManual()
|
|
430
|
+
const { transport, session, dappKp, channelId } = ctx
|
|
431
|
+
await connectWalletManual(ctx)
|
|
346
432
|
|
|
347
|
-
const requestHandler = vi.fn()
|
|
348
|
-
session.on('request', requestHandler)
|
|
433
|
+
const requestHandler = vi.fn()
|
|
434
|
+
session.on('request', requestHandler)
|
|
349
435
|
|
|
350
436
|
// Send a request without sealed field in body
|
|
351
437
|
transport.receive({
|
|
352
|
-
v: 1,
|
|
353
|
-
|
|
438
|
+
v: 1,
|
|
439
|
+
t: 'req',
|
|
440
|
+
ch: channelId,
|
|
441
|
+
ts: Date.now(),
|
|
442
|
+
from: dappKp.publicKeyB64,
|
|
354
443
|
body: { id: 'req-unseal' },
|
|
355
444
|
// no sealed field
|
|
356
|
-
} as ProtocolMessage)
|
|
445
|
+
} as ProtocolMessage)
|
|
357
446
|
|
|
358
447
|
// The request handler should NOT be called
|
|
359
|
-
expect(requestHandler).not.toHaveBeenCalled()
|
|
448
|
+
expect(requestHandler).not.toHaveBeenCalled()
|
|
360
449
|
|
|
361
450
|
// Wallet should have sent a rejection response
|
|
362
|
-
const rejectionMsg = transport.sent.find(
|
|
363
|
-
|
|
451
|
+
const rejectionMsg = transport.sent.find(
|
|
452
|
+
(m) => m.t === 'res' && (m as any).body?.id === 'req-unseal',
|
|
453
|
+
) as any
|
|
454
|
+
expect(rejectionMsg).toBeTruthy()
|
|
364
455
|
// ok no longer exists on wire body
|
|
365
|
-
})
|
|
456
|
+
})
|
|
366
457
|
|
|
367
458
|
it('DAppSession drops unsealed events', async () => {
|
|
368
|
-
const ctx = setupDAppManual()
|
|
369
|
-
const { transport, session, walletKp } = ctx
|
|
370
|
-
await connectDAppManual(ctx)
|
|
459
|
+
const ctx = setupDAppManual()
|
|
460
|
+
const { transport, session, walletKp } = ctx
|
|
461
|
+
await connectDAppManual(ctx)
|
|
371
462
|
|
|
372
|
-
const eventHandler = vi.fn()
|
|
373
|
-
session.on('event', eventHandler)
|
|
463
|
+
const eventHandler = vi.fn()
|
|
464
|
+
session.on('event', eventHandler)
|
|
374
465
|
|
|
375
466
|
// Send an event without sealed field in body
|
|
376
467
|
transport.receive({
|
|
377
|
-
v: 1,
|
|
378
|
-
|
|
468
|
+
v: 1,
|
|
469
|
+
t: 'evt',
|
|
470
|
+
ch: session.channelId,
|
|
471
|
+
ts: Date.now(),
|
|
472
|
+
from: walletKp.publicKeyB64,
|
|
379
473
|
body: { id: 'evt-1' },
|
|
380
474
|
// no sealed field
|
|
381
|
-
} as ProtocolMessage)
|
|
475
|
+
} as ProtocolMessage)
|
|
382
476
|
|
|
383
|
-
await wait(20)
|
|
384
|
-
expect(eventHandler).not.toHaveBeenCalled()
|
|
385
|
-
})
|
|
386
|
-
})
|
|
477
|
+
await wait(20)
|
|
478
|
+
expect(eventHandler).not.toHaveBeenCalled()
|
|
479
|
+
})
|
|
480
|
+
})
|
|
387
481
|
|
|
388
482
|
// ---------------------------------------------------------------------------
|
|
389
483
|
// Tests: Key isolation across sessions
|
|
390
484
|
// ---------------------------------------------------------------------------
|
|
391
485
|
|
|
392
486
|
describe('Security: Key isolation across sessions', () => {
|
|
393
|
-
|
|
394
487
|
it('session key changes if wallet pubkey changes (no key reuse)', async () => {
|
|
395
|
-
const dappKp = generateX25519KeyPair()
|
|
396
|
-
const channelId = generateChannelId()
|
|
488
|
+
const dappKp = generateX25519KeyPair()
|
|
489
|
+
const channelId = generateChannelId()
|
|
397
490
|
|
|
398
491
|
// First wallet
|
|
399
|
-
const wallet1 = generateX25519KeyPair()
|
|
400
|
-
const shared1 = computeSharedSecret(dappKp.privateKey, wallet1.publicKey)
|
|
401
|
-
const root1 = deriveSessionKey(shared1, channelId)
|
|
492
|
+
const wallet1 = generateX25519KeyPair()
|
|
493
|
+
const shared1 = computeSharedSecret(dappKp.privateKey, wallet1.publicKey)
|
|
494
|
+
const root1 = deriveSessionKey(shared1, channelId)
|
|
402
495
|
const ctx1: SessionCryptoContext = {
|
|
403
496
|
dappPubKeyB64: dappKp.publicKeyB64,
|
|
404
497
|
walletPubKeyB64: wallet1.publicKeyB64,
|
|
405
498
|
capabilities: null,
|
|
406
499
|
walletMeta: null,
|
|
407
500
|
dappName: 'App',
|
|
408
|
-
}
|
|
409
|
-
const keys1 = deriveDirectionalSessionKeys(root1, channelId, ctx1)
|
|
501
|
+
}
|
|
502
|
+
const keys1 = deriveDirectionalSessionKeys(root1, channelId, ctx1)
|
|
410
503
|
|
|
411
504
|
// Second wallet (different pubkey)
|
|
412
|
-
const wallet2 = generateX25519KeyPair()
|
|
413
|
-
const shared2 = computeSharedSecret(dappKp.privateKey, wallet2.publicKey)
|
|
414
|
-
const root2 = deriveSessionKey(shared2, channelId)
|
|
505
|
+
const wallet2 = generateX25519KeyPair()
|
|
506
|
+
const shared2 = computeSharedSecret(dappKp.privateKey, wallet2.publicKey)
|
|
507
|
+
const root2 = deriveSessionKey(shared2, channelId)
|
|
415
508
|
const ctx2: SessionCryptoContext = {
|
|
416
509
|
dappPubKeyB64: dappKp.publicKeyB64,
|
|
417
510
|
walletPubKeyB64: wallet2.publicKeyB64,
|
|
418
511
|
capabilities: null,
|
|
419
512
|
walletMeta: null,
|
|
420
513
|
dappName: 'App',
|
|
421
|
-
}
|
|
422
|
-
const keys2 = deriveDirectionalSessionKeys(root2, channelId, ctx2)
|
|
514
|
+
}
|
|
515
|
+
const keys2 = deriveDirectionalSessionKeys(root2, channelId, ctx2)
|
|
423
516
|
|
|
424
517
|
// All keys must differ
|
|
425
|
-
expect(bytesToHex(keys1.dappToWalletKey)).not.toBe(bytesToHex(keys2.dappToWalletKey))
|
|
426
|
-
expect(bytesToHex(keys1.walletToDappKey)).not.toBe(bytesToHex(keys2.walletToDappKey))
|
|
427
|
-
expect(bytesToHex(keys1.rootKey)).not.toBe(bytesToHex(keys2.rootKey))
|
|
428
|
-
expect(bytesToHex(keys1.transcriptHash)).not.toBe(bytesToHex(keys2.transcriptHash))
|
|
429
|
-
})
|
|
430
|
-
})
|
|
518
|
+
expect(bytesToHex(keys1.dappToWalletKey)).not.toBe(bytesToHex(keys2.dappToWalletKey))
|
|
519
|
+
expect(bytesToHex(keys1.walletToDappKey)).not.toBe(bytesToHex(keys2.walletToDappKey))
|
|
520
|
+
expect(bytesToHex(keys1.rootKey)).not.toBe(bytesToHex(keys2.rootKey))
|
|
521
|
+
expect(bytesToHex(keys1.transcriptHash)).not.toBe(bytesToHex(keys2.transcriptHash))
|
|
522
|
+
})
|
|
523
|
+
})
|