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,683 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { WalletSession } from './wallet-session.js';
|
|
3
|
+
import { MockTransport } from './test-helpers.js';
|
|
4
|
+
import {
|
|
5
|
+
generateX25519KeyPair,
|
|
6
|
+
generateChannelId,
|
|
7
|
+
buildPairingUri,
|
|
8
|
+
computeSharedSecret,
|
|
9
|
+
deriveSessionKey,
|
|
10
|
+
deriveDirectionalSessionKeys,
|
|
11
|
+
deriveJoinEncryptionKey,
|
|
12
|
+
computeSessionFingerprint,
|
|
13
|
+
sealPayload,
|
|
14
|
+
unsealPayload,
|
|
15
|
+
unsealJoin,
|
|
16
|
+
b64urlEncode,
|
|
17
|
+
b64urlDecode,
|
|
18
|
+
bytesToHex,
|
|
19
|
+
hexToBytes,
|
|
20
|
+
} from './crypto.js';
|
|
21
|
+
import type { AadHeader, SessionCryptoContext } from './crypto.js';
|
|
22
|
+
import type { ProtocolMessage } from './types.js';
|
|
23
|
+
|
|
24
|
+
function flushMicrotasks(): Promise<void> {
|
|
25
|
+
return new Promise((r) => setTimeout(r, 10));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('WalletSession', () => {
|
|
29
|
+
let transport: MockTransport;
|
|
30
|
+
let session: WalletSession;
|
|
31
|
+
let dappKp: ReturnType<typeof generateX25519KeyPair>;
|
|
32
|
+
let channelId: string;
|
|
33
|
+
let relayUrl: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
transport = new MockTransport();
|
|
37
|
+
session = new WalletSession({
|
|
38
|
+
transport,
|
|
39
|
+
capabilities: {
|
|
40
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
41
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
42
|
+
chains: ['eip155:1'],
|
|
43
|
+
},
|
|
44
|
+
meta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
45
|
+
});
|
|
46
|
+
dappKp = generateX25519KeyPair();
|
|
47
|
+
channelId = generateChannelId();
|
|
48
|
+
relayUrl = 'ws://localhost:8080/v1';
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function makePairingUri(): string {
|
|
52
|
+
return buildPairingUri({
|
|
53
|
+
channelId,
|
|
54
|
+
pubkeyB64: dappKp.publicKeyB64,
|
|
55
|
+
relayUrl,
|
|
56
|
+
name: 'Test dApp',
|
|
57
|
+
url: 'https://test.com',
|
|
58
|
+
icon: 'https://test.com/icon.png',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function receiveConnected(): void {
|
|
63
|
+
transport.receive({
|
|
64
|
+
v: 1, t: 'ready', ch: channelId,
|
|
65
|
+
ts: Date.now(), from: '_adapter',
|
|
66
|
+
body: { state: 'connected', reconnect: false, remote: dappKp.publicKeyB64 },
|
|
67
|
+
} as ProtocolMessage);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('joinFromUri', () => {
|
|
71
|
+
it('starts in idle phase', () => {
|
|
72
|
+
expect(session.phase).toBe('idle');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('parses URI and sends join message with sealed_join', async () => {
|
|
76
|
+
const uri = makePairingUri();
|
|
77
|
+
await session.joinFromUri(uri);
|
|
78
|
+
|
|
79
|
+
expect(session.channelId).toBe(channelId);
|
|
80
|
+
expect(session.phase).toBe('waiting_accept');
|
|
81
|
+
|
|
82
|
+
const joinMsg = transport.sent.find(m => m.t === 'join');
|
|
83
|
+
expect(joinMsg).toBeTruthy();
|
|
84
|
+
// Capabilities are now inside sealed_join, not plaintext
|
|
85
|
+
expect((joinMsg as any).body.sealed_join).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('computes and emits session fingerprint', async () => {
|
|
89
|
+
const handler = vi.fn();
|
|
90
|
+
session.on('sessionFingerprint', handler);
|
|
91
|
+
|
|
92
|
+
const uri = makePairingUri();
|
|
93
|
+
await session.joinFromUri(uri);
|
|
94
|
+
|
|
95
|
+
expect(handler).toHaveBeenCalled();
|
|
96
|
+
expect(session.sessionFingerprint).toMatch(/^\d{4}$/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('session fingerprint matches dApp side derivation', async () => {
|
|
100
|
+
const uri = makePairingUri();
|
|
101
|
+
await session.joinFromUri(uri);
|
|
102
|
+
|
|
103
|
+
// Both sides use computeSessionFingerprint(channelId, dappPubKeyB64)
|
|
104
|
+
const dappFingerprint = computeSessionFingerprint(channelId, dappKp.publicKeyB64);
|
|
105
|
+
|
|
106
|
+
expect(session.sessionFingerprint).toBe(dappFingerprint);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('transitions to connected on ready.connected', async () => {
|
|
110
|
+
const phases: string[] = [];
|
|
111
|
+
session.on('phase', (p) => phases.push(p));
|
|
112
|
+
|
|
113
|
+
await session.joinFromUri(makePairingUri());
|
|
114
|
+
|
|
115
|
+
receiveConnected();
|
|
116
|
+
|
|
117
|
+
expect(session.phase).toBe('connected');
|
|
118
|
+
expect(phases).toContain('waiting_accept');
|
|
119
|
+
expect(phases).toContain('connected');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects ready.connected with missing remote', async () => {
|
|
123
|
+
const errorHandler = vi.fn();
|
|
124
|
+
session.on('error', errorHandler);
|
|
125
|
+
|
|
126
|
+
await session.joinFromUri(makePairingUri());
|
|
127
|
+
transport.receive({
|
|
128
|
+
v: 1, t: 'ready', ch: channelId,
|
|
129
|
+
ts: Date.now(), from: '_adapter',
|
|
130
|
+
body: { state: 'connected', reconnect: false, remote: null },
|
|
131
|
+
} as ProtocolMessage);
|
|
132
|
+
|
|
133
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
134
|
+
message: expect.stringContaining('remote does not match'),
|
|
135
|
+
}));
|
|
136
|
+
expect(session.phase).toBe('closed');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('request handling', () => {
|
|
141
|
+
let dappToWalletKey: Uint8Array;
|
|
142
|
+
let walletToDappKey: Uint8Array;
|
|
143
|
+
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
await session.joinFromUri(makePairingUri());
|
|
146
|
+
|
|
147
|
+
// Derive directional keys from dApp side
|
|
148
|
+
const walletPubB64 = transport.sent.find(m => m.t === 'join')!.from!;
|
|
149
|
+
const walletPub = b64urlDecode(walletPubB64);
|
|
150
|
+
const shared = computeSharedSecret(dappKp.privateKey, walletPub);
|
|
151
|
+
const rootKey = deriveSessionKey(shared, channelId);
|
|
152
|
+
const context: SessionCryptoContext = {
|
|
153
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
154
|
+
walletPubKeyB64: walletPubB64,
|
|
155
|
+
capabilities: {
|
|
156
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
157
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
158
|
+
chains: ['eip155:1'],
|
|
159
|
+
},
|
|
160
|
+
walletMeta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
161
|
+
dappName: 'Test dApp',
|
|
162
|
+
};
|
|
163
|
+
const keys = deriveDirectionalSessionKeys(rootKey, channelId, context);
|
|
164
|
+
dappToWalletKey = keys.dappToWalletKey;
|
|
165
|
+
walletToDappKey = keys.walletToDappKey;
|
|
166
|
+
|
|
167
|
+
// Connect
|
|
168
|
+
receiveConnected();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('emits request event with decrypted params', () => {
|
|
172
|
+
const handler = vi.fn();
|
|
173
|
+
session.on('request', handler);
|
|
174
|
+
|
|
175
|
+
// Real method inside sealed payload
|
|
176
|
+
const sealedParams = { _method: 'wallet_signMessage', message: 'Hello World' };
|
|
177
|
+
transport.receive({
|
|
178
|
+
v: 1, t: 'req', ch: channelId,
|
|
179
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
180
|
+
body: { id: 'req-1', sealed: sealPayload(dappToWalletKey, channelId, 0, sealedParams, { type: 'req', from: dappKp.publicKeyB64, id: 'req-1' }) },
|
|
181
|
+
} as ProtocolMessage);
|
|
182
|
+
|
|
183
|
+
expect(handler).toHaveBeenCalledWith({
|
|
184
|
+
id: 'req-1',
|
|
185
|
+
method: 'wallet_signMessage',
|
|
186
|
+
params: { message: 'Hello World' },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('emits request with empty params for parameterless sealed request', () => {
|
|
191
|
+
const handler = vi.fn();
|
|
192
|
+
session.on('request', handler);
|
|
193
|
+
|
|
194
|
+
const sealedParams = { _method: 'wallet_getAccounts' };
|
|
195
|
+
transport.receive({
|
|
196
|
+
v: 1, t: 'req', ch: channelId,
|
|
197
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
198
|
+
body: { id: 'req-2', sealed: sealPayload(dappToWalletKey, channelId, 1, sealedParams, { type: 'req', from: dappKp.publicKeyB64, id: 'req-2' }) },
|
|
199
|
+
} as ProtocolMessage);
|
|
200
|
+
|
|
201
|
+
expect(handler).toHaveBeenCalledWith({
|
|
202
|
+
id: 'req-2',
|
|
203
|
+
method: 'wallet_getAccounts',
|
|
204
|
+
params: {},
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects unsealed request with decryption_failed', () => {
|
|
209
|
+
const handler = vi.fn();
|
|
210
|
+
session.on('request', handler);
|
|
211
|
+
|
|
212
|
+
transport.receive({
|
|
213
|
+
v: 1, t: 'req', ch: channelId,
|
|
214
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
215
|
+
body: { id: 'req-3' },
|
|
216
|
+
} as ProtocolMessage);
|
|
217
|
+
|
|
218
|
+
// Should NOT emit request - unsealed requests are rejected
|
|
219
|
+
expect(handler).not.toHaveBeenCalled();
|
|
220
|
+
// Should send a rejection response
|
|
221
|
+
const resMsg = transport.sent.find(m => m.t === 'res') as any;
|
|
222
|
+
expect(resMsg).toBeTruthy();
|
|
223
|
+
// ok no longer exists on wire body
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('rejects sealed request missing _method with invalid_params', () => {
|
|
227
|
+
const handler = vi.fn();
|
|
228
|
+
session.on('request', handler);
|
|
229
|
+
|
|
230
|
+
transport.receive({
|
|
231
|
+
v: 1, t: 'req', ch: channelId,
|
|
232
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
233
|
+
body: { id: 'req-missing-method', sealed: sealPayload(dappToWalletKey, channelId, 0, { message: 'no method' }, { type: 'req', from: dappKp.publicKeyB64, id: 'req-missing-method' }) },
|
|
234
|
+
} as ProtocolMessage);
|
|
235
|
+
|
|
236
|
+
expect(handler).not.toHaveBeenCalled();
|
|
237
|
+
const resMsg = transport.sent.find(m => m.t === 'res') as any;
|
|
238
|
+
expect(resMsg).toBeTruthy();
|
|
239
|
+
const { data } = unsealPayload(walletToDappKey, channelId, resMsg.body.sealed, { type: 'res', from: transport.sent.find(m => m.t === 'join')!.from!, id: 'req-missing-method' });
|
|
240
|
+
expect(data).toMatchObject({ _ok: false, code: 'invalid_params' });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('approve/reject', () => {
|
|
245
|
+
let dappToWalletKey: Uint8Array;
|
|
246
|
+
let walletToDappKey: Uint8Array;
|
|
247
|
+
let walletPubB64: string;
|
|
248
|
+
|
|
249
|
+
beforeEach(async () => {
|
|
250
|
+
await session.joinFromUri(makePairingUri());
|
|
251
|
+
walletPubB64 = transport.sent.find(m => m.t === 'join')!.from!;
|
|
252
|
+
const shared = computeSharedSecret(dappKp.privateKey, b64urlDecode(walletPubB64));
|
|
253
|
+
const rootKey = deriveSessionKey(shared, channelId);
|
|
254
|
+
const context: SessionCryptoContext = {
|
|
255
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
256
|
+
walletPubKeyB64: walletPubB64,
|
|
257
|
+
capabilities: {
|
|
258
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
259
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
260
|
+
chains: ['eip155:1'],
|
|
261
|
+
},
|
|
262
|
+
walletMeta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
263
|
+
dappName: 'Test dApp',
|
|
264
|
+
};
|
|
265
|
+
const keys = deriveDirectionalSessionKeys(rootKey, channelId, context);
|
|
266
|
+
dappToWalletKey = keys.dappToWalletKey;
|
|
267
|
+
walletToDappKey = keys.walletToDappKey;
|
|
268
|
+
|
|
269
|
+
receiveConnected();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('approve sends encrypted ok response', () => {
|
|
273
|
+
transport.receive({
|
|
274
|
+
v: 1, t: 'req', ch: channelId,
|
|
275
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
276
|
+
body: { id: 'req-1', sealed: sealPayload(dappToWalletKey, channelId, 0, { _method: 'wallet_getAccounts' }, { type: 'req', from: dappKp.publicKeyB64, id: 'req-1' }) },
|
|
277
|
+
} as ProtocolMessage);
|
|
278
|
+
|
|
279
|
+
session.approve('req-1', ['0xabc123']);
|
|
280
|
+
|
|
281
|
+
const resMsg = transport.sent.find(m => m.t === 'res') as any;
|
|
282
|
+
expect(resMsg).toBeTruthy();
|
|
283
|
+
expect(resMsg.body.id).toBe('req-1');
|
|
284
|
+
expect(resMsg.body.sealed).toBeTruthy();
|
|
285
|
+
|
|
286
|
+
// Verify dApp can decrypt the response (wallet->dApp uses walletToDappKey)
|
|
287
|
+
const { data } = unsealPayload(walletToDappKey, channelId, resMsg.body.sealed, { type: 'res', from: walletPubB64, id: 'req-1' });
|
|
288
|
+
expect(data).toEqual({ _ok: true, _result: ['0xabc123'] });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('reject sends encrypted error response', () => {
|
|
292
|
+
transport.receive({
|
|
293
|
+
v: 1, t: 'req', ch: channelId,
|
|
294
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
295
|
+
body: { id: 'req-2', sealed: sealPayload(dappToWalletKey, channelId, 0, { _method: 'wallet_signMessage', message: 'test' }, { type: 'req', from: dappKp.publicKeyB64, id: 'req-2' }) },
|
|
296
|
+
} as ProtocolMessage);
|
|
297
|
+
|
|
298
|
+
session.reject('req-2', 'user_rejected', 'User said no');
|
|
299
|
+
|
|
300
|
+
const resMsg = transport.sent.find(m => m.t === 'res') as any;
|
|
301
|
+
expect(resMsg).toBeTruthy();
|
|
302
|
+
expect(resMsg.body.id).toBe('req-2');
|
|
303
|
+
expect(resMsg.body.sealed).toBeTruthy();
|
|
304
|
+
|
|
305
|
+
const { data } = unsealPayload(walletToDappKey, channelId, resMsg.body.sealed, { type: 'res', from: walletPubB64, id: 'req-2' });
|
|
306
|
+
expect(data).toEqual({ _ok: false, code: 'user_rejected', message: 'User said no' });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('approve increments send sequence', () => {
|
|
310
|
+
for (let i = 0; i < 3; i++) {
|
|
311
|
+
transport.receive({
|
|
312
|
+
v: 1, t: 'req', ch: channelId,
|
|
313
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
314
|
+
body: { id: `req-${i}`, sealed: sealPayload(dappToWalletKey, channelId, i, { _method: 'wallet_getAccounts' }, { type: 'req', from: dappKp.publicKeyB64, id: `req-${i}` }) },
|
|
315
|
+
} as ProtocolMessage);
|
|
316
|
+
session.approve(`req-${i}`, ['0x123']);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// All 3 responses should have different sealed payloads (different seqs)
|
|
320
|
+
const responses = transport.sent.filter(m => m.t === 'res') as any[];
|
|
321
|
+
expect(responses).toHaveLength(3);
|
|
322
|
+
const sealedSet = new Set(responses.map((r: any) => r.body.sealed));
|
|
323
|
+
expect(sealedSet.size).toBe(3);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('re-encrypts cached response for duplicate request id with same params', () => {
|
|
327
|
+
const handler = vi.fn();
|
|
328
|
+
session.on('request', handler);
|
|
329
|
+
const requestPayload = { _method: 'wallet_getAccounts' };
|
|
330
|
+
|
|
331
|
+
transport.receive({
|
|
332
|
+
v: 1, t: 'req', ch: channelId,
|
|
333
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
334
|
+
body: { id: 'dup-1', sealed: sealPayload(dappToWalletKey, channelId, 0, requestPayload, { type: 'req', from: dappKp.publicKeyB64, id: 'dup-1' }) },
|
|
335
|
+
} as ProtocolMessage);
|
|
336
|
+
session.approve('dup-1', ['0xabc123']);
|
|
337
|
+
|
|
338
|
+
transport.receive({
|
|
339
|
+
v: 1, t: 'req', ch: channelId,
|
|
340
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
341
|
+
body: { id: 'dup-1', sealed: sealPayload(dappToWalletKey, channelId, 1, requestPayload, { type: 'req', from: dappKp.publicKeyB64, id: 'dup-1' }) },
|
|
342
|
+
} as ProtocolMessage);
|
|
343
|
+
|
|
344
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
345
|
+
const responses = transport.sent.filter(m => m.t === 'res') as any[];
|
|
346
|
+
expect(responses).toHaveLength(2);
|
|
347
|
+
expect(responses[0]!.body.sealed).not.toBe(responses[1]!.body.sealed);
|
|
348
|
+
const { data } = unsealPayload(walletToDappKey, channelId, responses[1]!.body.sealed, { type: 'res', from: walletPubB64, id: 'dup-1' });
|
|
349
|
+
expect(data).toEqual({ _ok: true, _result: ['0xabc123'] });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('rejects duplicate request id with different params', () => {
|
|
353
|
+
const handler = vi.fn();
|
|
354
|
+
session.on('request', handler);
|
|
355
|
+
|
|
356
|
+
transport.receive({
|
|
357
|
+
v: 1, t: 'req', ch: channelId,
|
|
358
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
359
|
+
body: { id: 'dup-2', sealed: sealPayload(dappToWalletKey, channelId, 0, { _method: 'wallet_getAccounts' }, { type: 'req', from: dappKp.publicKeyB64, id: 'dup-2' }) },
|
|
360
|
+
} as ProtocolMessage);
|
|
361
|
+
session.approve('dup-2', ['0xabc123']);
|
|
362
|
+
|
|
363
|
+
transport.receive({
|
|
364
|
+
v: 1, t: 'req', ch: channelId,
|
|
365
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
366
|
+
body: { id: 'dup-2', sealed: sealPayload(dappToWalletKey, channelId, 1, { _method: 'wallet_getAccounts', changed: true }, { type: 'req', from: dappKp.publicKeyB64, id: 'dup-2' }) },
|
|
367
|
+
} as ProtocolMessage);
|
|
368
|
+
|
|
369
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
370
|
+
const responses = transport.sent.filter(m => m.t === 'res') as any[];
|
|
371
|
+
const duplicateResponse = responses[1]!;
|
|
372
|
+
const { data } = unsealPayload(walletToDappKey, channelId, duplicateResponse.body.sealed, { type: 'res', from: walletPubB64, id: 'dup-2' });
|
|
373
|
+
expect(data).toMatchObject({ _ok: false, code: 'invalid_params' });
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('pushEvent', () => {
|
|
378
|
+
let walletToDappKey: Uint8Array;
|
|
379
|
+
let walletPubB64: string;
|
|
380
|
+
|
|
381
|
+
beforeEach(async () => {
|
|
382
|
+
await session.joinFromUri(makePairingUri());
|
|
383
|
+
walletPubB64 = transport.sent.find(m => m.t === 'join')!.from!;
|
|
384
|
+
const shared = computeSharedSecret(dappKp.privateKey, b64urlDecode(walletPubB64));
|
|
385
|
+
const rootKey = deriveSessionKey(shared, channelId);
|
|
386
|
+
const context: SessionCryptoContext = {
|
|
387
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
388
|
+
walletPubKeyB64: walletPubB64,
|
|
389
|
+
capabilities: {
|
|
390
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
391
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
392
|
+
chains: ['eip155:1'],
|
|
393
|
+
},
|
|
394
|
+
walletMeta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
395
|
+
dappName: 'Test dApp',
|
|
396
|
+
};
|
|
397
|
+
const keys = deriveDirectionalSessionKeys(rootKey, channelId, context);
|
|
398
|
+
walletToDappKey = keys.walletToDappKey;
|
|
399
|
+
|
|
400
|
+
receiveConnected();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('sends encrypted event message', () => {
|
|
404
|
+
session.pushEvent('accountsChanged', { accounts: ['0xnew'] });
|
|
405
|
+
|
|
406
|
+
const evtMsg = transport.sent.find(m => m.t === 'evt') as any;
|
|
407
|
+
expect(evtMsg).toBeTruthy();
|
|
408
|
+
expect(evtMsg.body.id).toBeTruthy();
|
|
409
|
+
expect(evtMsg.body.sealed).toBeTruthy();
|
|
410
|
+
|
|
411
|
+
// Verify dApp can decrypt (wallet->dApp uses walletToDappKey)
|
|
412
|
+
const { data } = unsealPayload(walletToDappKey, channelId, evtMsg.body.sealed, { type: 'evt', from: walletPubB64, id: evtMsg.body.id });
|
|
413
|
+
// Real event name and data are inside sealed payload
|
|
414
|
+
expect(data).toEqual({ _event: 'accountsChanged', accounts: ['0xnew'] });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('does nothing when not connected', () => {
|
|
418
|
+
const idleSession = new WalletSession({
|
|
419
|
+
transport: new MockTransport(),
|
|
420
|
+
capabilities: { methods: [], events: [], chains: [] },
|
|
421
|
+
meta: { name: 'Test Wallet', description: 'Test', url: 'https://wallet.test', icon: 'https://wallet.test/icon.png' },
|
|
422
|
+
});
|
|
423
|
+
idleSession.pushEvent('test', {});
|
|
424
|
+
// Should not throw, just no-op
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('sends chainChanged event', () => {
|
|
428
|
+
session.pushEvent('chainChanged', { chainId: 'eip155:137' });
|
|
429
|
+
|
|
430
|
+
const evtMsg = transport.sent.find(m => m.t === 'evt') as any;
|
|
431
|
+
const { data } = unsealPayload(walletToDappKey, channelId, evtMsg.body.sealed, { type: 'evt', from: walletPubB64, id: evtMsg.body.id });
|
|
432
|
+
expect(data).toEqual({ _event: 'chainChanged', chainId: 'eip155:137' });
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('ping/pong', () => {
|
|
437
|
+
beforeEach(async () => {
|
|
438
|
+
await session.joinFromUri(makePairingUri());
|
|
439
|
+
receiveConnected();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('responds to ping with pong', () => {
|
|
443
|
+
transport.receive({
|
|
444
|
+
v: 1, t: 'ping', ch: channelId,
|
|
445
|
+
ts: 12345, from: dappKp.publicKeyB64, body: {},
|
|
446
|
+
} as ProtocolMessage);
|
|
447
|
+
|
|
448
|
+
const pong = transport.sent.find(m => m.t === 'pong');
|
|
449
|
+
expect(pong).toBeTruthy();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('sends ping', () => {
|
|
453
|
+
session.ping();
|
|
454
|
+
const ping = transport.sent.find(m => m.t === 'ping');
|
|
455
|
+
expect(ping).toBeTruthy();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('close', () => {
|
|
460
|
+
it('sends close and transitions to closed', async () => {
|
|
461
|
+
await session.joinFromUri(makePairingUri());
|
|
462
|
+
session.close();
|
|
463
|
+
|
|
464
|
+
const closeMsg = transport.sent.find(m => m.t === 'close');
|
|
465
|
+
expect(closeMsg).toBeTruthy();
|
|
466
|
+
expect((closeMsg as any).body.reason).toBe('normal');
|
|
467
|
+
expect(session.phase).toBe('closed');
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('serialize/restore', () => {
|
|
472
|
+
it('round-trips session state', async () => {
|
|
473
|
+
await session.joinFromUri(makePairingUri());
|
|
474
|
+
|
|
475
|
+
receiveConnected();
|
|
476
|
+
|
|
477
|
+
const json = session.serialize();
|
|
478
|
+
const newSession = new WalletSession({
|
|
479
|
+
transport: new MockTransport(),
|
|
480
|
+
capabilities: {
|
|
481
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
482
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
483
|
+
chains: ['eip155:1'],
|
|
484
|
+
},
|
|
485
|
+
meta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
486
|
+
});
|
|
487
|
+
expect(newSession.restore(json)).toBe(true);
|
|
488
|
+
expect(newSession.channelId).toBe(channelId);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('rejects restore when capabilities no longer match the transcript', async () => {
|
|
492
|
+
await session.joinFromUri(makePairingUri());
|
|
493
|
+
|
|
494
|
+
const json = session.serialize();
|
|
495
|
+
const newSession = new WalletSession({
|
|
496
|
+
transport: new MockTransport(),
|
|
497
|
+
capabilities: { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] },
|
|
498
|
+
meta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
expect(newSession.restore(json)).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('returns false for invalid JSON', () => {
|
|
505
|
+
expect(session.restore('invalid')).toBe(false);
|
|
506
|
+
expect(session.restore('{}')).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe('close message handling', () => {
|
|
511
|
+
it('transitions to closed on close message from dApp', async () => {
|
|
512
|
+
await session.joinFromUri(makePairingUri());
|
|
513
|
+
receiveConnected();
|
|
514
|
+
|
|
515
|
+
transport.receive({
|
|
516
|
+
v: 1, t: 'close', ch: channelId,
|
|
517
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
518
|
+
body: { reason: 'normal' },
|
|
519
|
+
} as ProtocolMessage);
|
|
520
|
+
|
|
521
|
+
expect(session.phase).toBe('closed');
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe('destroy', () => {
|
|
526
|
+
it('closes and removes all listeners', async () => {
|
|
527
|
+
await session.joinFromUri(makePairingUri());
|
|
528
|
+
const handler = vi.fn();
|
|
529
|
+
session.on('phase', handler);
|
|
530
|
+
session.destroy();
|
|
531
|
+
expect(session.phase).toBe('closed');
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
describe('session fingerprint after prepareJoin', () => {
|
|
536
|
+
it('sessionFingerprint is set and event was emitted after prepareJoin', () => {
|
|
537
|
+
const fpHandler = vi.fn();
|
|
538
|
+
session.on('sessionFingerprint', fpHandler);
|
|
539
|
+
|
|
540
|
+
const uri = makePairingUri();
|
|
541
|
+
const fingerprint = session.prepareJoin(uri);
|
|
542
|
+
|
|
543
|
+
expect(session.sessionFingerprint).toMatch(/^\d{4}$/);
|
|
544
|
+
expect(fingerprint).toBe(session.sessionFingerprint);
|
|
545
|
+
expect(fpHandler).toHaveBeenCalledTimes(1);
|
|
546
|
+
expect(fpHandler).toHaveBeenCalledWith(session.sessionFingerprint);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('protocol compliance', () => {
|
|
551
|
+
it('rejects messages with from="_adapter" for peer types (§2)', async () => {
|
|
552
|
+
const errorHandler = vi.fn();
|
|
553
|
+
session.on('error', errorHandler);
|
|
554
|
+
|
|
555
|
+
await session.joinFromUri(makePairingUri());
|
|
556
|
+
receiveConnected();
|
|
557
|
+
|
|
558
|
+
// Send a req message with from: '_adapter' — should be rejected
|
|
559
|
+
transport.receive({
|
|
560
|
+
v: 1, t: 'req', ch: channelId,
|
|
561
|
+
ts: Date.now(), from: '_adapter',
|
|
562
|
+
body: { id: 'spoofed-1', sealed: 'fake' },
|
|
563
|
+
} as ProtocolMessage);
|
|
564
|
+
|
|
565
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
566
|
+
message: expect.stringContaining('_adapter'),
|
|
567
|
+
}));
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('rejects messages with unsupported version (§15 rule 12)', async () => {
|
|
571
|
+
await session.joinFromUri(makePairingUri());
|
|
572
|
+
receiveConnected();
|
|
573
|
+
|
|
574
|
+
// Send a message with v: 2 — should close with unsupported_version
|
|
575
|
+
transport.receive({
|
|
576
|
+
v: 2, t: 'req', ch: channelId,
|
|
577
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
578
|
+
body: { id: 'v2-req', sealed: 'fake' },
|
|
579
|
+
} as unknown as ProtocolMessage);
|
|
580
|
+
|
|
581
|
+
expect(session.phase).toBe('closed');
|
|
582
|
+
const closeMsg = transport.sent.find(m => m.t === 'close') as any;
|
|
583
|
+
expect(closeMsg).toBeTruthy();
|
|
584
|
+
expect(closeMsg.body.reason).toBe('unsupported_version');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('rejects unsupported methods at runtime (§7.1)', async () => {
|
|
588
|
+
// Session has capabilities.methods = ['wallet_getAccounts', 'wallet_signMessage']
|
|
589
|
+
await session.joinFromUri(makePairingUri());
|
|
590
|
+
|
|
591
|
+
const walletPubB64 = transport.sent.find(m => m.t === 'join')!.from!;
|
|
592
|
+
const shared = computeSharedSecret(dappKp.privateKey, b64urlDecode(walletPubB64));
|
|
593
|
+
const rootKey = deriveSessionKey(shared, channelId);
|
|
594
|
+
const context: SessionCryptoContext = {
|
|
595
|
+
dappPubKeyB64: dappKp.publicKeyB64,
|
|
596
|
+
walletPubKeyB64: walletPubB64,
|
|
597
|
+
capabilities: {
|
|
598
|
+
methods: ['wallet_getAccounts', 'wallet_signMessage'],
|
|
599
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
600
|
+
chains: ['eip155:1'],
|
|
601
|
+
},
|
|
602
|
+
walletMeta: { name: 'Test Wallet', description: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png', address: '0xtest' },
|
|
603
|
+
dappName: 'Test dApp',
|
|
604
|
+
};
|
|
605
|
+
const keys = deriveDirectionalSessionKeys(rootKey, channelId, context);
|
|
606
|
+
const dappToWalletKey = keys.dappToWalletKey;
|
|
607
|
+
const walletToDappKey = keys.walletToDappKey;
|
|
608
|
+
|
|
609
|
+
receiveConnected();
|
|
610
|
+
|
|
611
|
+
const handler = vi.fn();
|
|
612
|
+
session.on('request', handler);
|
|
613
|
+
|
|
614
|
+
// Send a request for a method NOT in capabilities
|
|
615
|
+
const sealedParams = { _method: 'wallet_signTransaction', data: '0x...' };
|
|
616
|
+
transport.receive({
|
|
617
|
+
v: 1, t: 'req', ch: channelId,
|
|
618
|
+
ts: Date.now(), from: dappKp.publicKeyB64,
|
|
619
|
+
body: { id: 'unsup-1', sealed: sealPayload(dappToWalletKey, channelId, 0, sealedParams, { type: 'req', from: dappKp.publicKeyB64, id: 'unsup-1' }) },
|
|
620
|
+
} as ProtocolMessage);
|
|
621
|
+
|
|
622
|
+
// Should NOT emit request
|
|
623
|
+
expect(handler).not.toHaveBeenCalled();
|
|
624
|
+
|
|
625
|
+
// Should send an error response with unsupported_method
|
|
626
|
+
const resMsg = transport.sent.find(m => m.t === 'res') as any;
|
|
627
|
+
expect(resMsg).toBeTruthy();
|
|
628
|
+
const { data } = unsealPayload(walletToDappKey, channelId, resMsg.body.sealed, { type: 'res', from: walletPubB64, id: 'unsup-1' });
|
|
629
|
+
expect(data).toMatchObject({ _ok: false, code: 'unsupported_method' });
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('scope intersection (computeScopeIntersection)', () => {
|
|
634
|
+
it('intersects wallet capabilities with dApp-declared scope from URI', async () => {
|
|
635
|
+
const wideWalletTransport = new MockTransport();
|
|
636
|
+
const wideWalletSession = new WalletSession({
|
|
637
|
+
transport: wideWalletTransport,
|
|
638
|
+
capabilities: {
|
|
639
|
+
methods: ['a', 'b', 'c'],
|
|
640
|
+
events: ['accountsChanged'],
|
|
641
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
642
|
+
},
|
|
643
|
+
meta: { name: 'W', description: 'W', url: 'https://w.com', icon: 'https://w.com/i.png' },
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Build a URI that declares only methods=a,b and chains=eip155:1
|
|
647
|
+
const dappKpLocal = generateX25519KeyPair();
|
|
648
|
+
const chLocal = generateChannelId();
|
|
649
|
+
const uri = buildPairingUri({
|
|
650
|
+
channelId: chLocal,
|
|
651
|
+
pubkeyB64: dappKpLocal.publicKeyB64,
|
|
652
|
+
relayUrl: 'ws://localhost:8080/v1',
|
|
653
|
+
name: 'Test dApp',
|
|
654
|
+
url: 'https://test.com',
|
|
655
|
+
icon: 'https://test.com/icon.png',
|
|
656
|
+
methods: ['a', 'b'],
|
|
657
|
+
chains: ['eip155:1'],
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
await wideWalletSession.joinFromUri(uri);
|
|
661
|
+
|
|
662
|
+
// The join message should contain sealed_join with intersected capabilities
|
|
663
|
+
const joinMsg = wideWalletTransport.sent.find(m => m.t === 'join') as any;
|
|
664
|
+
expect(joinMsg).toBeTruthy();
|
|
665
|
+
expect(joinMsg.body.sealed_join).toBeTruthy();
|
|
666
|
+
|
|
667
|
+
// Unseal the join to verify effective capabilities
|
|
668
|
+
const walletPubB64 = joinMsg.from!;
|
|
669
|
+
const walletPub = b64urlDecode(walletPubB64);
|
|
670
|
+
const shared = computeSharedSecret(dappKpLocal.privateKey, walletPub);
|
|
671
|
+
const rootKey = deriveSessionKey(shared, chLocal);
|
|
672
|
+
const joinKey = deriveJoinEncryptionKey(rootKey, chLocal);
|
|
673
|
+
const unsealed = unsealJoin(joinKey, chLocal, joinMsg.body.sealed_join);
|
|
674
|
+
|
|
675
|
+
const caps = unsealed.capabilities as { methods: string[]; chains: string[]; events: string[] };
|
|
676
|
+
// Wallet grants ALL its capabilities (not just the intersection)
|
|
677
|
+
// per §7.1: wallet MAY grant additional methods/chains beyond requested
|
|
678
|
+
expect(caps.methods).toEqual(['a', 'b', 'c']);
|
|
679
|
+
expect(caps.chains).toEqual(['eip155:1', 'eip155:137']);
|
|
680
|
+
expect(caps.events).toEqual(['accountsChanged']);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
});
|