walletpair-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security tests for WalletPair SDK.
|
|
3
|
+
*
|
|
4
|
+
* Covers: replay detection, AAD tampering, ciphertext tampering, wrong key,
|
|
5
|
+
* sequence overflow, mandatory encryption enforcement, and key isolation
|
|
6
|
+
* across sessions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { DAppSession } from './dapp-session.js';
|
|
11
|
+
import { WalletSession } from './wallet-session.js';
|
|
12
|
+
import { makeJoinBody, MockTransport, MockRelay } from './test-helpers.js';
|
|
13
|
+
import {
|
|
14
|
+
generateX25519KeyPair,
|
|
15
|
+
generateChannelId,
|
|
16
|
+
computeSharedSecret,
|
|
17
|
+
deriveSessionKey,
|
|
18
|
+
sealPayload,
|
|
19
|
+
unsealPayload,
|
|
20
|
+
b64urlEncode,
|
|
21
|
+
b64urlDecode,
|
|
22
|
+
bytesToHex,
|
|
23
|
+
buildPairingUri,
|
|
24
|
+
} from './crypto.js';
|
|
25
|
+
import { deriveDirectionalSessionKeys } from './crypto.js';
|
|
26
|
+
import type { SessionCryptoContext } from './crypto.js';
|
|
27
|
+
import type { ProtocolMessage } from './types.js';
|
|
28
|
+
|
|
29
|
+
function wait(ms = 50): Promise<void> {
|
|
30
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers to set up sessions with manual control
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function setupDAppManual() {
|
|
38
|
+
const transport = new MockTransport();
|
|
39
|
+
const session = new DAppSession({ transport, meta: { name: 'Test', description: 'Test dApp', url: 'https://test.com', icon: 'https://test.com/icon.png' } });
|
|
40
|
+
const walletKp = generateX25519KeyPair();
|
|
41
|
+
return { transport, session, walletKp };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function connectDAppManual(ctx: ReturnType<typeof setupDAppManual>) {
|
|
45
|
+
const { transport, session, walletKp } = ctx;
|
|
46
|
+
await session.createPairing();
|
|
47
|
+
|
|
48
|
+
transport.receive({
|
|
49
|
+
v: 1, t: 'join', ch: session.channelId,
|
|
50
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
51
|
+
body: makeJoinBody(session.channelId, transport.sent[0]!.from!, walletKp),
|
|
52
|
+
} as ProtocolMessage);
|
|
53
|
+
|
|
54
|
+
transport.receive({
|
|
55
|
+
v: 1, t: 'ready', ch: session.channelId,
|
|
56
|
+
ts: Date.now(), from: '_adapter',
|
|
57
|
+
body: { state: 'connected', reconnect: false, remote: walletKp.publicKeyB64 },
|
|
58
|
+
} as ProtocolMessage);
|
|
59
|
+
|
|
60
|
+
// Derive the wallet's send key (walletToDappKey) which is what
|
|
61
|
+
// the DAppSession expects to receive (its recvKey).
|
|
62
|
+
const recvKey = (session as any).recvKey as Uint8Array;
|
|
63
|
+
const dappPubB64 = transport.sent[0]!.from!;
|
|
64
|
+
return { recvKey, dappPubB64 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setupWalletManual() {
|
|
68
|
+
const transport = new MockTransport();
|
|
69
|
+
const dappKp = generateX25519KeyPair();
|
|
70
|
+
const channelId = generateChannelId();
|
|
71
|
+
const session = new WalletSession({
|
|
72
|
+
transport,
|
|
73
|
+
meta: { name: 'Test Wallet', description: 'Test wallet', url: 'https://wallet.test', icon: 'https://wallet.test/icon.png' },
|
|
74
|
+
capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
|
|
75
|
+
});
|
|
76
|
+
return { transport, session, dappKp, channelId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function connectWalletManual(ctx: ReturnType<typeof setupWalletManual>) {
|
|
80
|
+
const { transport, session, dappKp, channelId } = ctx;
|
|
81
|
+
const uri = buildPairingUri({
|
|
82
|
+
channelId,
|
|
83
|
+
pubkeyB64: dappKp.publicKeyB64,
|
|
84
|
+
relayUrl: 'ws://localhost/v1',
|
|
85
|
+
name: 'Test dApp',
|
|
86
|
+
url: 'https://dapp.test',
|
|
87
|
+
icon: 'https://dapp.test/icon.png',
|
|
88
|
+
});
|
|
89
|
+
await session.joinFromUri(uri);
|
|
90
|
+
|
|
91
|
+
transport.receive({
|
|
92
|
+
v: 1, t: 'ready', ch: channelId,
|
|
93
|
+
ts: Date.now(), from: '_adapter',
|
|
94
|
+
body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
|
|
95
|
+
} as ProtocolMessage);
|
|
96
|
+
|
|
97
|
+
// 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')!.from!;
|
|
100
|
+
return { recvKey, walletPubB64 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Tests: Replay detection
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
describe('Security: Replay detection', () => {
|
|
108
|
+
|
|
109
|
+
it('same sequence number is rejected', async () => {
|
|
110
|
+
const ctx = setupDAppManual();
|
|
111
|
+
const { transport, session, walletKp } = ctx;
|
|
112
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
113
|
+
|
|
114
|
+
// 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;
|
|
119
|
+
|
|
120
|
+
transport.receive({
|
|
121
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
122
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
123
|
+
body: { id: req0Id, sealed: sealPayload(recvKey, session.channelId, 0, { _ok: true, _result: ['0xa'] },
|
|
124
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req0Id }) },
|
|
125
|
+
} as ProtocolMessage);
|
|
126
|
+
expect(await p0).toEqual(['0xa']);
|
|
127
|
+
|
|
128
|
+
// 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;
|
|
133
|
+
|
|
134
|
+
transport.receive({
|
|
135
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
136
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
137
|
+
body: { id: req1Id, sealed: sealPayload(recvKey, session.channelId, 0, { _ok: true, _result: ['replay'] },
|
|
138
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: req1Id }) },
|
|
139
|
+
} as ProtocolMessage);
|
|
140
|
+
await expect(p1).rejects.toThrow('Replay detected');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('lower sequence number is rejected', async () => {
|
|
144
|
+
const ctx = setupDAppManual();
|
|
145
|
+
const { transport, session, walletKp } = ctx;
|
|
146
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
147
|
+
|
|
148
|
+
// 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;
|
|
153
|
+
transport.receive({
|
|
154
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
155
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
156
|
+
body: { id: r0id, sealed: sealPayload(recvKey, session.channelId, 5, { _ok: true, _result: 'ok' },
|
|
157
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id }) },
|
|
158
|
+
} as ProtocolMessage);
|
|
159
|
+
expect(await p0).toBe('ok');
|
|
160
|
+
|
|
161
|
+
// 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;
|
|
166
|
+
transport.receive({
|
|
167
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
168
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
169
|
+
body: { id: r1id, sealed: sealPayload(recvKey, session.channelId, 3, { _ok: true, _result: 'stale' },
|
|
170
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id }) },
|
|
171
|
+
} as ProtocolMessage);
|
|
172
|
+
await expect(p1).rejects.toThrow('Replay detected');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('higher sequence number is accepted', async () => {
|
|
176
|
+
const ctx = setupDAppManual();
|
|
177
|
+
const { transport, session, walletKp } = ctx;
|
|
178
|
+
const { recvKey } = await connectDAppManual(ctx);
|
|
179
|
+
|
|
180
|
+
// 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;
|
|
185
|
+
transport.receive({
|
|
186
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
187
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
188
|
+
body: { id: r0id, sealed: sealPayload(recvKey, session.channelId, 0, { _ok: true, _result: 'first' },
|
|
189
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r0id }) },
|
|
190
|
+
} as ProtocolMessage);
|
|
191
|
+
expect(await p0).toBe('first');
|
|
192
|
+
|
|
193
|
+
// 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;
|
|
198
|
+
transport.receive({
|
|
199
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
200
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
201
|
+
body: { id: r1id, sealed: sealPayload(recvKey, session.channelId, 10, { _ok: true, _result: 'second' },
|
|
202
|
+
{ type: 'res', from: walletKp.publicKeyB64, id: r1id }) },
|
|
203
|
+
} as ProtocolMessage);
|
|
204
|
+
expect(await p1).toBe('second');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Tests: AAD tampering
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
describe('Security: AAD tampering', () => {
|
|
213
|
+
|
|
214
|
+
it('tampered AAD (wrong id) causes decryption failure', () => {
|
|
215
|
+
const key = new Uint8Array(32);
|
|
216
|
+
crypto.getRandomValues(key);
|
|
217
|
+
const channelId = generateChannelId();
|
|
218
|
+
|
|
219
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' };
|
|
220
|
+
const sealed = sealPayload(key, channelId, 0, { foo: 'bar' }, hdr);
|
|
221
|
+
|
|
222
|
+
const tamperedHdr = { ...hdr, id: 'req-999' };
|
|
223
|
+
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('tampered AAD (wrong from) causes decryption failure', () => {
|
|
227
|
+
const key = new Uint8Array(32);
|
|
228
|
+
crypto.getRandomValues(key);
|
|
229
|
+
const channelId = generateChannelId();
|
|
230
|
+
|
|
231
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' };
|
|
232
|
+
const sealed = sealPayload(key, channelId, 0, {}, hdr);
|
|
233
|
+
|
|
234
|
+
const tamperedHdr = { ...hdr, from: 'evil-relay' };
|
|
235
|
+
expect(() => unsealPayload(key, channelId, sealed, tamperedHdr)).toThrow();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Tests: Ciphertext and key tampering
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe('Security: Ciphertext and key tampering', () => {
|
|
244
|
+
|
|
245
|
+
it('tampered ciphertext causes decryption failure', () => {
|
|
246
|
+
const key = new Uint8Array(32);
|
|
247
|
+
crypto.getRandomValues(key);
|
|
248
|
+
const channelId = generateChannelId();
|
|
249
|
+
|
|
250
|
+
const hdr = { type: 'req' as const, from: 'dapp', id: 'req-1' };
|
|
251
|
+
const sealed = sealPayload(key, channelId, 0, { secret: true }, hdr);
|
|
252
|
+
|
|
253
|
+
// 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);
|
|
257
|
+
|
|
258
|
+
expect(() => unsealPayload(key, channelId, tampered, hdr)).toThrow();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
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();
|
|
267
|
+
|
|
268
|
+
const sealed = sealPayload(key, channelId, 0, { data: 'test' });
|
|
269
|
+
expect(() => unsealPayload(wrongKey, channelId, sealed)).toThrow();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Tests: Sequence overflow closes session
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
describe('Security: Sequence overflow', () => {
|
|
278
|
+
|
|
279
|
+
it('DAppSession: overflow at MAX_SEND_SEQ causes session close', async () => {
|
|
280
|
+
const ctx = setupDAppManual();
|
|
281
|
+
const { session } = ctx;
|
|
282
|
+
await connectDAppManual(ctx);
|
|
283
|
+
|
|
284
|
+
(session as any).sendSeq = 2 ** 31;
|
|
285
|
+
|
|
286
|
+
const errorHandler = vi.fn();
|
|
287
|
+
session.on('error', errorHandler);
|
|
288
|
+
|
|
289
|
+
await expect(session.request('wallet_getAccounts')).rejects.toThrow('Send sequence overflow');
|
|
290
|
+
expect(errorHandler).toHaveBeenCalled();
|
|
291
|
+
expect(session.phase).toBe('closed');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
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);
|
|
298
|
+
|
|
299
|
+
(session as any).sendSeq = 2 ** 31 - 1;
|
|
300
|
+
|
|
301
|
+
const errorHandler = vi.fn();
|
|
302
|
+
session.on('error', errorHandler);
|
|
303
|
+
|
|
304
|
+
// Last allowed
|
|
305
|
+
session.pushEvent('accountsChanged', { accounts: ['0xa'] });
|
|
306
|
+
expect(session.phase).toBe('connected');
|
|
307
|
+
|
|
308
|
+
// Overflow
|
|
309
|
+
session.pushEvent('accountsChanged', { accounts: ['0xb'] });
|
|
310
|
+
expect(errorHandler).toHaveBeenCalled();
|
|
311
|
+
expect(session.phase).toBe('closed');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Tests: Mandatory encryption enforcement
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
describe('Security: Mandatory encryption', () => {
|
|
320
|
+
|
|
321
|
+
it('DAppSession rejects unsealed responses', async () => {
|
|
322
|
+
const ctx = setupDAppManual();
|
|
323
|
+
const { transport, session, walletKp } = ctx;
|
|
324
|
+
await connectDAppManual(ctx);
|
|
325
|
+
|
|
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;
|
|
330
|
+
|
|
331
|
+
// Send a response without sealed field
|
|
332
|
+
transport.receive({
|
|
333
|
+
v: 1, t: 'res', ch: session.channelId,
|
|
334
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
335
|
+
body: { id: reqId },
|
|
336
|
+
// no sealed field in body
|
|
337
|
+
} as ProtocolMessage);
|
|
338
|
+
|
|
339
|
+
await expect(p).rejects.toThrow('Response must be encrypted');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('WalletSession rejects unsealed requests', async () => {
|
|
343
|
+
const ctx = setupWalletManual();
|
|
344
|
+
const { transport, session, dappKp, channelId } = ctx;
|
|
345
|
+
await connectWalletManual(ctx);
|
|
346
|
+
|
|
347
|
+
const requestHandler = vi.fn();
|
|
348
|
+
session.on('request', requestHandler);
|
|
349
|
+
|
|
350
|
+
// Send a request without sealed field in body
|
|
351
|
+
transport.receive({
|
|
352
|
+
v: 1, t: 'req', ch: channelId,
|
|
353
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
354
|
+
body: { id: 'req-unseal' },
|
|
355
|
+
// no sealed field
|
|
356
|
+
} as ProtocolMessage);
|
|
357
|
+
|
|
358
|
+
// The request handler should NOT be called
|
|
359
|
+
expect(requestHandler).not.toHaveBeenCalled();
|
|
360
|
+
|
|
361
|
+
// Wallet should have sent a rejection response
|
|
362
|
+
const rejectionMsg = transport.sent.find(m => m.t === 'res' && (m as any).body?.id === 'req-unseal') as any;
|
|
363
|
+
expect(rejectionMsg).toBeTruthy();
|
|
364
|
+
// ok no longer exists on wire body
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('DAppSession drops unsealed events', async () => {
|
|
368
|
+
const ctx = setupDAppManual();
|
|
369
|
+
const { transport, session, walletKp } = ctx;
|
|
370
|
+
await connectDAppManual(ctx);
|
|
371
|
+
|
|
372
|
+
const eventHandler = vi.fn();
|
|
373
|
+
session.on('event', eventHandler);
|
|
374
|
+
|
|
375
|
+
// Send an event without sealed field in body
|
|
376
|
+
transport.receive({
|
|
377
|
+
v: 1, t: 'evt', ch: session.channelId,
|
|
378
|
+
ts: Date.now(), from: walletKp.publicKeyB64,
|
|
379
|
+
body: { id: 'evt-1' },
|
|
380
|
+
// no sealed field
|
|
381
|
+
} as ProtocolMessage);
|
|
382
|
+
|
|
383
|
+
await wait(20);
|
|
384
|
+
expect(eventHandler).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Tests: Key isolation across sessions
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe('Security: Key isolation across sessions', () => {
|
|
393
|
+
|
|
394
|
+
it('session key changes if wallet pubkey changes (no key reuse)', async () => {
|
|
395
|
+
const dappKp = generateX25519KeyPair();
|
|
396
|
+
const channelId = generateChannelId();
|
|
397
|
+
|
|
398
|
+
// First wallet
|
|
399
|
+
const wallet1 = generateX25519KeyPair();
|
|
400
|
+
const shared1 = computeSharedSecret(dappKp.privateKey, wallet1.publicKey);
|
|
401
|
+
const root1 = deriveSessionKey(shared1, channelId);
|
|
402
|
+
const ctx1: SessionCryptoContext = {
|
|
403
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
404
|
+
walletPubKeyB64: wallet1.publicKeyB64,
|
|
405
|
+
capabilities: null,
|
|
406
|
+
walletMeta: null,
|
|
407
|
+
dappName: 'App',
|
|
408
|
+
};
|
|
409
|
+
const keys1 = deriveDirectionalSessionKeys(root1, channelId, ctx1);
|
|
410
|
+
|
|
411
|
+
// Second wallet (different pubkey)
|
|
412
|
+
const wallet2 = generateX25519KeyPair();
|
|
413
|
+
const shared2 = computeSharedSecret(dappKp.privateKey, wallet2.publicKey);
|
|
414
|
+
const root2 = deriveSessionKey(shared2, channelId);
|
|
415
|
+
const ctx2: SessionCryptoContext = {
|
|
416
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
417
|
+
walletPubKeyB64: wallet2.publicKeyB64,
|
|
418
|
+
capabilities: null,
|
|
419
|
+
walletMeta: null,
|
|
420
|
+
dappName: 'App',
|
|
421
|
+
};
|
|
422
|
+
const keys2 = deriveDirectionalSessionKeys(root2, channelId, ctx2);
|
|
423
|
+
|
|
424
|
+
// 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
|
+
});
|