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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/dist/ble/framing.d.ts +23 -0
  4. package/dist/ble/framing.d.ts.map +1 -0
  5. package/dist/ble/framing.js +83 -0
  6. package/dist/ble/framing.js.map +1 -0
  7. package/dist/ble/index.d.ts +9 -0
  8. package/dist/ble/index.d.ts.map +1 -0
  9. package/dist/ble/index.js +9 -0
  10. package/dist/ble/index.js.map +1 -0
  11. package/dist/ble/web-ble-transport.d.ts +29 -0
  12. package/dist/ble/web-ble-transport.d.ts.map +1 -0
  13. package/dist/ble/web-ble-transport.js +93 -0
  14. package/dist/ble/web-ble-transport.js.map +1 -0
  15. package/dist/crypto.d.ts +102 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +279 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dapp-session.d.ts +106 -0
  20. package/dist/dapp-session.d.ts.map +1 -0
  21. package/dist/dapp-session.js +918 -0
  22. package/dist/dapp-session.js.map +1 -0
  23. package/dist/emitter.d.ts +16 -0
  24. package/dist/emitter.d.ts.map +1 -0
  25. package/dist/emitter.js +41 -0
  26. package/dist/emitter.js.map +1 -0
  27. package/dist/evm/eip1193.d.ts +83 -0
  28. package/dist/evm/eip1193.d.ts.map +1 -0
  29. package/dist/evm/eip1193.js +270 -0
  30. package/dist/evm/eip1193.js.map +1 -0
  31. package/dist/evm/index.d.ts +8 -0
  32. package/dist/evm/index.d.ts.map +1 -0
  33. package/dist/evm/index.js +8 -0
  34. package/dist/evm/index.js.map +1 -0
  35. package/dist/evm/wagmi.d.ts +118 -0
  36. package/dist/evm/wagmi.d.ts.map +1 -0
  37. package/dist/evm/wagmi.js +205 -0
  38. package/dist/evm/wagmi.js.map +1 -0
  39. package/dist/index.d.ts +22 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +24 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/types.d.ts +225 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +31 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/wallet-session.d.ts +107 -0
  48. package/dist/wallet-session.d.ts.map +1 -0
  49. package/dist/wallet-session.js +794 -0
  50. package/dist/wallet-session.js.map +1 -0
  51. package/dist/ws-transport.d.ts +29 -0
  52. package/dist/ws-transport.d.ts.map +1 -0
  53. package/dist/ws-transport.js +79 -0
  54. package/dist/ws-transport.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
  57. package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
  58. package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
  59. package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
  60. package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
  61. package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
  62. package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
  63. package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
  64. package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
  65. package/src/ble/framing.test.ts +196 -0
  66. package/src/ble/framing.ts +100 -0
  67. package/src/ble/index.ts +18 -0
  68. package/src/ble/web-ble-transport.test.ts +192 -0
  69. package/src/ble/web-ble-transport.ts +116 -0
  70. package/src/ble/web-bluetooth.d.ts +47 -0
  71. package/src/canonical-json.test.ts +612 -0
  72. package/src/crypto-directional.test.ts +263 -0
  73. package/src/crypto-hardening.test.ts +529 -0
  74. package/src/crypto.test.ts +635 -0
  75. package/src/crypto.ts +405 -0
  76. package/src/dapp-session.test.ts +647 -0
  77. package/src/dapp-session.ts +1004 -0
  78. package/src/emitter.test.ts +169 -0
  79. package/src/emitter.ts +45 -0
  80. package/src/evm/eip1193.test.ts +365 -0
  81. package/src/evm/eip1193.ts +346 -0
  82. package/src/evm/index.ts +19 -0
  83. package/src/evm/wagmi.test.ts +396 -0
  84. package/src/evm/wagmi.ts +321 -0
  85. package/src/index.ts +86 -0
  86. package/src/integration.test.ts +385 -0
  87. package/src/security.test.ts +430 -0
  88. package/src/sequence-validation.test.ts +1185 -0
  89. package/src/test-helpers.ts +216 -0
  90. package/src/types.test.ts +82 -0
  91. package/src/types.ts +305 -0
  92. package/src/wallet-session.test.ts +683 -0
  93. package/src/wallet-session.ts +922 -0
  94. package/src/ws-transport.test.ts +231 -0
  95. package/src/ws-transport.ts +92 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Hardening tests for crypto primitives.
3
+ *
4
+ * Covers edge cases and boundary conditions for:
5
+ * - canonicalJson (RFC 8785 / I-JSON compliance)
6
+ * - seal/unseal with AAD headers (type byte differentiation)
7
+ * - Nonce determinism and uniqueness
8
+ * - sealJoin/unsealJoin error paths
9
+ * - Key separation guarantees
10
+ * - Protocol spec test vector SHA-256 verification
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import {
15
+ canonicalJson,
16
+ sealPayload,
17
+ unsealPayload,
18
+ sealJoin,
19
+ unsealJoin,
20
+ deriveSessionKey,
21
+ deriveJoinEncryptionKey,
22
+ deriveDirectionalSessionKeys,
23
+ computeHandshakeTranscriptHash,
24
+ computeSharedSecret,
25
+ generateX25519KeyPair,
26
+ generateChannelId,
27
+ b64urlEncode,
28
+ b64urlDecode,
29
+ bytesToHex,
30
+ hexToBytes,
31
+ sha256Hex,
32
+ } from './crypto.js';
33
+ import type { AadHeader, SessionCryptoContext } from './crypto.js';
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════
36
+ // canonicalJson — comprehensive edge cases
37
+ // ═══════════════════════════════════════════════════════════════════════
38
+
39
+ describe('canonicalJson — edge cases', () => {
40
+ // --- Object key sorting ---
41
+ it('sorts object keys lexicographically', () => {
42
+ expect(canonicalJson({ z: 1, a: 2, m: 3 })).toBe('{"a":2,"m":3,"z":1}');
43
+ });
44
+
45
+ it('sorts nested object keys recursively', () => {
46
+ expect(canonicalJson({ b: { z: 1, a: 2 }, a: 1 })).toBe('{"a":1,"b":{"a":2,"z":1}}');
47
+ });
48
+
49
+ it('sorts deeply nested objects (3+ levels)', () => {
50
+ const input = { c: { b: { z: 1, a: 2 }, a: 3 }, a: 0 };
51
+ expect(canonicalJson(input)).toBe('{"a":0,"c":{"a":3,"b":{"a":2,"z":1}}}');
52
+ });
53
+
54
+ it('does not sort array elements (preserves order)', () => {
55
+ expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]');
56
+ expect(canonicalJson(['z', 'a', 'm'])).toBe('["z","a","m"]');
57
+ });
58
+
59
+ it('sorts keys in objects inside arrays', () => {
60
+ expect(canonicalJson([{ b: 1, a: 2 }])).toBe('[{"a":2,"b":1}]');
61
+ });
62
+
63
+ // --- Primitives ---
64
+ it('handles null', () => {
65
+ expect(canonicalJson(null)).toBe('null');
66
+ });
67
+
68
+ it('handles undefined as null', () => {
69
+ expect(canonicalJson(undefined)).toBe('null');
70
+ });
71
+
72
+ it('handles booleans', () => {
73
+ expect(canonicalJson(true)).toBe('true');
74
+ expect(canonicalJson(false)).toBe('false');
75
+ });
76
+
77
+ it('handles integers', () => {
78
+ expect(canonicalJson(0)).toBe('0');
79
+ expect(canonicalJson(1)).toBe('1');
80
+ expect(canonicalJson(-1)).toBe('-1');
81
+ expect(canonicalJson(42)).toBe('42');
82
+ });
83
+
84
+ it('handles floating point numbers', () => {
85
+ expect(canonicalJson(1.5)).toBe('1.5');
86
+ expect(canonicalJson(0.1)).toBe('0.1');
87
+ });
88
+
89
+ it('handles negative zero as 0', () => {
90
+ // JSON.stringify(-0) produces "0" in JS
91
+ expect(canonicalJson(-0)).toBe('0');
92
+ });
93
+
94
+ // --- Strings ---
95
+ it('handles empty string', () => {
96
+ expect(canonicalJson('')).toBe('""');
97
+ });
98
+
99
+ it('handles unicode strings', () => {
100
+ expect(canonicalJson('你好')).toBe('"你好"');
101
+ expect(canonicalJson('🌍')).toBe('"🌍"');
102
+ });
103
+
104
+ it('escapes control characters', () => {
105
+ expect(canonicalJson('\n')).toBe('"\\n"');
106
+ expect(canonicalJson('\t')).toBe('"\\t"');
107
+ expect(canonicalJson('\r')).toBe('"\\r"');
108
+ });
109
+
110
+ it('escapes backslash and double quote', () => {
111
+ expect(canonicalJson('\\')).toBe('"\\\\"');
112
+ expect(canonicalJson('"')).toBe('"\\""');
113
+ });
114
+
115
+ it('does not escape forward slash', () => {
116
+ expect(canonicalJson('a/b')).toBe('"a/b"');
117
+ });
118
+
119
+ // --- Empty containers ---
120
+ it('handles empty object', () => {
121
+ expect(canonicalJson({})).toBe('{}');
122
+ });
123
+
124
+ it('handles empty array', () => {
125
+ expect(canonicalJson([])).toBe('[]');
126
+ });
127
+
128
+ // --- No whitespace ---
129
+ it('produces no whitespace', () => {
130
+ const result = canonicalJson({ a: [1, 2], b: { c: 3 } });
131
+ expect(result).not.toMatch(/\s/);
132
+ expect(result).toBe('{"a":[1,2],"b":{"c":3}}');
133
+ });
134
+
135
+ // --- Omits undefined values in objects ---
136
+ it('omits keys with undefined values (matches JSON.stringify)', () => {
137
+ const input = { a: 1, b: undefined, c: 3 };
138
+ const result = canonicalJson(input);
139
+ expect(result).toBe('{"a":1,"c":3}');
140
+ });
141
+
142
+ // --- Spec test vector with SHA-256 ---
143
+ it('matches protocol spec test vector byte-for-byte with correct SHA-256', () => {
144
+ const input = {
145
+ methods: ['wallet_signTransaction', 'wallet_signMessage'],
146
+ events: ['accountsChanged', 'chainChanged'],
147
+ chains: ['eip155:1', 'eip155:137'],
148
+ };
149
+ const output = canonicalJson(input);
150
+ const expected =
151
+ '{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]}';
152
+ expect(output).toBe(expected);
153
+
154
+ // Verify SHA-256 from protocol spec
155
+ const hash = sha256Hex(new TextEncoder().encode(output));
156
+ expect(hash).toBe('4da366e2aae26b47b3d90fff52410752348733350ce2525dce7d64510f571333');
157
+ });
158
+
159
+ // --- Determinism ---
160
+ it('is deterministic across multiple calls', () => {
161
+ const obj = { z: [3, 1], a: { y: 'hello', x: true } };
162
+ const r1 = canonicalJson(obj);
163
+ const r2 = canonicalJson(obj);
164
+ const r3 = canonicalJson(JSON.parse(JSON.stringify(obj)));
165
+ expect(r1).toBe(r2);
166
+ expect(r1).toBe(r3);
167
+ });
168
+
169
+ // --- Complex mixed structures ---
170
+ it('handles mixed nested structures', () => {
171
+ const input = {
172
+ capabilities: {
173
+ methods: ['wallet_signTransaction'],
174
+ events: ['accountsChanged'],
175
+ chains: ['eip155:1'],
176
+ },
177
+ meta: { name: 'MyWallet' },
178
+ };
179
+ const result = canonicalJson(input);
180
+ // Keys sorted: capabilities < meta; inner keys sorted too
181
+ expect(result).toBe(
182
+ '{"capabilities":{"chains":["eip155:1"],"events":["accountsChanged"],"methods":["wallet_signTransaction"]},"meta":{"name":"MyWallet"}}',
183
+ );
184
+ });
185
+
186
+ // --- Matches test vector for join plaintext ---
187
+ it('matches join plaintext test vector from protocol appendix', () => {
188
+ const input = {
189
+ capabilities: {
190
+ methods: ['wallet_signTransaction', 'wallet_signMessage'],
191
+ events: ['accountsChanged', 'chainChanged'],
192
+ chains: ['eip155:1', 'eip155:137'],
193
+ },
194
+ meta: { name: 'MyWallet' },
195
+ };
196
+ const result = canonicalJson(input);
197
+ expect(result).toBe(
198
+ '{"capabilities":{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]},"meta":{"name":"MyWallet"}}',
199
+ );
200
+ });
201
+ });
202
+
203
+ // ═══════════════════════════════════════════════════════════════════════
204
+ // Nonce determinism and uniqueness
205
+ // ═══════════════════════════════════════════════════════════════════════
206
+
207
+ describe('sealPayload nonce determinism', () => {
208
+ const key = new Uint8Array(32).fill(0xaa);
209
+ const ch = 'bb'.repeat(32);
210
+
211
+ it('same key + same seq + same data = same ciphertext (deterministic nonce)', () => {
212
+ const data = { test: true };
213
+ const s1 = sealPayload(key, ch, 0, data);
214
+ const s2 = sealPayload(key, ch, 0, data);
215
+ expect(s1).toBe(s2);
216
+ });
217
+
218
+ it('same key + different seq = different ciphertext (different nonce)', () => {
219
+ const data = { test: true };
220
+ const s0 = sealPayload(key, ch, 0, data);
221
+ const s1 = sealPayload(key, ch, 1, data);
222
+ expect(s0).not.toBe(s1);
223
+ });
224
+
225
+ it('different key + same seq = different ciphertext (different nonce derivation)', () => {
226
+ const key2 = new Uint8Array(32).fill(0xcc);
227
+ const data = { test: true };
228
+ const s1 = sealPayload(key, ch, 0, data);
229
+ const s2 = sealPayload(key2, ch, 0, data);
230
+ expect(s1).not.toBe(s2);
231
+ });
232
+
233
+ it('seq=0 and seq=2^31-1 both work', () => {
234
+ const data = { x: 1 };
235
+ const s0 = sealPayload(key, ch, 0, data);
236
+ const sMax = sealPayload(key, ch, 2 ** 31 - 1, data);
237
+ expect(unsealPayload(key, ch, s0).seq).toBe(0);
238
+ expect(unsealPayload(key, ch, sMax).seq).toBe(2 ** 31 - 1);
239
+ });
240
+ });
241
+
242
+ // ═══════════════════════════════════════════════════════════════════════
243
+ // AAD header type differentiation
244
+ // ═══════════════════════════════════════════════════════════════════════
245
+
246
+ describe('seal/unseal with AAD headers', () => {
247
+ const key = new Uint8Array(32).fill(0x55);
248
+ const ch = 'cc'.repeat(32);
249
+ const data = { method: 'eth_sign', params: [] };
250
+
251
+ it('req type AAD produces different ciphertext than res type', () => {
252
+ const reqHdr: AadHeader = { type: 'req', from: 'peer1', id: 'r1' };
253
+ const resHdr: AadHeader = { type: 'res', from: 'peer1', id: 'r1' };
254
+ const sealed1 = sealPayload(key, ch, 0, data, reqHdr);
255
+ const sealed2 = sealPayload(key, ch, 0, data, resHdr);
256
+ expect(sealed1).not.toBe(sealed2);
257
+ });
258
+
259
+ it('different from in AAD produces different ciphertext', () => {
260
+ const hdr1: AadHeader = { type: 'req', from: 'peerA', id: 'r1' };
261
+ const hdr2: AadHeader = { type: 'req', from: 'peerB', id: 'r1' };
262
+ const sealed1 = sealPayload(key, ch, 0, data, hdr1);
263
+ const sealed2 = sealPayload(key, ch, 0, data, hdr2);
264
+ expect(sealed1).not.toBe(sealed2);
265
+ });
266
+
267
+ it('different id in AAD produces different ciphertext', () => {
268
+ const hdr1: AadHeader = { type: 'req', from: 'peer1', id: 'id-1' };
269
+ const hdr2: AadHeader = { type: 'req', from: 'peer1', id: 'id-2' };
270
+ const sealed1 = sealPayload(key, ch, 0, data, hdr1);
271
+ const sealed2 = sealPayload(key, ch, 0, data, hdr2);
272
+ expect(sealed1).not.toBe(sealed2);
273
+ });
274
+
275
+ it('decrypt fails if AAD header type mismatches', () => {
276
+ const hdr: AadHeader = { type: 'req', from: 'peer1', id: 'r1' };
277
+ const sealed = sealPayload(key, ch, 0, data, hdr);
278
+ const wrongHdr: AadHeader = { type: 'res', from: 'peer1', id: 'r1' };
279
+ expect(() => unsealPayload(key, ch, sealed, wrongHdr)).toThrow();
280
+ });
281
+
282
+ it('decrypt fails if AAD from field mismatches', () => {
283
+ const hdr: AadHeader = { type: 'req', from: 'peerA', id: 'r1' };
284
+ const sealed = sealPayload(key, ch, 0, data, hdr);
285
+ const wrongHdr: AadHeader = { type: 'req', from: 'peerB', id: 'r1' };
286
+ expect(() => unsealPayload(key, ch, sealed, wrongHdr)).toThrow();
287
+ });
288
+
289
+ it('decrypt fails if AAD id field mismatches', () => {
290
+ const hdr: AadHeader = { type: 'req', from: 'peer1', id: 'r1' };
291
+ const sealed = sealPayload(key, ch, 0, data, hdr);
292
+ const wrongHdr: AadHeader = { type: 'req', from: 'peer1', id: 'r2' };
293
+ expect(() => unsealPayload(key, ch, sealed, wrongHdr)).toThrow();
294
+ });
295
+
296
+ it('decrypt succeeds with matching AAD header', () => {
297
+ const hdr: AadHeader = { type: 'evt', from: 'wallet', id: 'e1' };
298
+ const sealed = sealPayload(key, ch, 5, data, hdr);
299
+ const { seq, data: d } = unsealPayload(key, ch, sealed, hdr);
300
+ expect(seq).toBe(5);
301
+ expect(d).toEqual(data);
302
+ });
303
+
304
+ it('sealed with AAD cannot be decrypted without AAD', () => {
305
+ const hdr: AadHeader = { type: 'req', from: 'peer1', id: 'r1' };
306
+ const sealed = sealPayload(key, ch, 0, data, hdr);
307
+ // Decrypt without AAD — should fail because AAD mismatch
308
+ expect(() => unsealPayload(key, ch, sealed)).toThrow();
309
+ });
310
+
311
+ it('sealed without AAD cannot be decrypted with AAD', () => {
312
+ const sealed = sealPayload(key, ch, 0, data);
313
+ const hdr: AadHeader = { type: 'req', from: 'peer1', id: 'r1' };
314
+ expect(() => unsealPayload(key, ch, sealed, hdr)).toThrow();
315
+ });
316
+ });
317
+
318
+ // ═══════════════════════════════════════════════════════════════════════
319
+ // unsealPayload error paths
320
+ // ═══════════════════════════════════════════════════════════════════════
321
+
322
+ describe('unsealPayload error paths', () => {
323
+ const key = new Uint8Array(32).fill(0x77);
324
+ const ch = 'dd'.repeat(32);
325
+
326
+ it('throws on empty sealed string', () => {
327
+ expect(() => unsealPayload(key, ch, '')).toThrow();
328
+ });
329
+
330
+ it('throws on truncated sealed (too short for seq + tag)', () => {
331
+ const truncated = b64urlEncode(new Uint8Array(4)); // only seq, no ciphertext
332
+ expect(() => unsealPayload(key, ch, truncated)).toThrow();
333
+ });
334
+
335
+ it('throws on tampered seq bytes', () => {
336
+ const sealed = sealPayload(key, ch, 5, { test: true });
337
+ const bytes = b64urlDecode(sealed);
338
+ bytes[0]! ^= 0xff; // tamper seq byte
339
+ const tampered = b64urlEncode(bytes);
340
+ // Changing seq changes nonce → decryption fails
341
+ expect(() => unsealPayload(key, ch, tampered)).toThrow();
342
+ });
343
+
344
+ it('throws on single-bit flip in ciphertext', () => {
345
+ const sealed = sealPayload(key, ch, 0, { x: 1 });
346
+ const bytes = b64urlDecode(sealed);
347
+ bytes[bytes.length - 1]! ^= 0x01; // flip 1 bit in tag
348
+ expect(() => unsealPayload(key, ch, b64urlEncode(bytes))).toThrow();
349
+ });
350
+ });
351
+
352
+ // ═══════════════════════════════════════════════════════════════════════
353
+ // sealJoin / unsealJoin error paths
354
+ // ═══════════════════════════════════════════════════════════════════════
355
+
356
+ describe('sealJoin / unsealJoin error paths', () => {
357
+ const rootKey = new Uint8Array(32).fill(0x33);
358
+ const ch = 'ee'.repeat(32);
359
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch);
360
+
361
+ it('decrypt fails with wrong key', () => {
362
+ const caps = { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] };
363
+ const sealed = sealJoin(joinKey, ch, caps, { name: 'W' });
364
+ const wrongKey = new Uint8Array(32).fill(0x99);
365
+ expect(() => unsealJoin(wrongKey, ch, sealed)).toThrow();
366
+ });
367
+
368
+ it('decrypt fails with wrong channel ID', () => {
369
+ const caps = { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] };
370
+ const sealed = sealJoin(joinKey, ch, caps, { name: 'W' });
371
+ const wrongCh = 'ff'.repeat(32);
372
+ expect(() => unsealJoin(joinKey, wrongCh, sealed)).toThrow();
373
+ });
374
+
375
+ it('decrypt fails with tampered ciphertext', () => {
376
+ const caps = { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] };
377
+ const sealed = sealJoin(joinKey, ch, caps, { name: 'W' });
378
+ const bytes = b64urlDecode(sealed);
379
+ bytes[bytes.length - 1]! ^= 0xff;
380
+ expect(() => unsealJoin(joinKey, ch, b64urlEncode(bytes))).toThrow();
381
+ });
382
+
383
+ it('throws on envelope smaller than nonce + tag', () => {
384
+ const tiny = b64urlEncode(new Uint8Array(10));
385
+ expect(() => unsealJoin(joinKey, ch, tiny)).toThrow('Invalid sealed_join');
386
+ });
387
+
388
+ it('round-trips with null meta', () => {
389
+ const caps = { methods: ['test'], events: [], chains: [] };
390
+ const sealed = sealJoin(joinKey, ch, caps);
391
+ const { capabilities, meta } = unsealJoin(joinKey, ch, sealed);
392
+ expect(capabilities).toEqual(caps);
393
+ expect(meta).toBeNull();
394
+ });
395
+ });
396
+
397
+ // ═══════════════════════════════════════════════════════════════════════
398
+ // Key separation guarantees
399
+ // ═══════════════════════════════════════════════════════════════════════
400
+
401
+ describe('Key separation', () => {
402
+ const alice = generateX25519KeyPair();
403
+ const bob = generateX25519KeyPair();
404
+ const ch = generateChannelId();
405
+ const shared = computeSharedSecret(alice.privateKey, bob.publicKey);
406
+ const rootKey = deriveSessionKey(shared, ch);
407
+
408
+ it('joinEncryptionKey differs from rootKey', () => {
409
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch);
410
+ expect(bytesToHex(joinKey)).not.toBe(bytesToHex(rootKey));
411
+ });
412
+
413
+ it('dappToWalletKey differs from walletToDappKey', () => {
414
+ const ctx: SessionCryptoContext = {
415
+ dappPubKeyB64: alice.publicKeyB64,
416
+ walletPubKeyB64: bob.publicKeyB64,
417
+ capabilities: { methods: ['test'], events: [], chains: [] },
418
+ dappName: 'Test',
419
+ };
420
+ const keys = deriveDirectionalSessionKeys(rootKey, ch, ctx);
421
+ expect(bytesToHex(keys.dappToWalletKey)).not.toBe(bytesToHex(keys.walletToDappKey));
422
+ });
423
+
424
+ it('joinEncryptionKey differs from both directional keys', () => {
425
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch);
426
+ const ctx: SessionCryptoContext = {
427
+ dappPubKeyB64: alice.publicKeyB64,
428
+ walletPubKeyB64: bob.publicKeyB64,
429
+ capabilities: { methods: ['test'], events: [], chains: [] },
430
+ dappName: 'Test',
431
+ };
432
+ const keys = deriveDirectionalSessionKeys(rootKey, ch, ctx);
433
+ expect(bytesToHex(joinKey)).not.toBe(bytesToHex(keys.dappToWalletKey));
434
+ expect(bytesToHex(joinKey)).not.toBe(bytesToHex(keys.walletToDappKey));
435
+ });
436
+
437
+ it('different capabilities produce different directional keys', () => {
438
+ const ctx1: SessionCryptoContext = {
439
+ dappPubKeyB64: alice.publicKeyB64,
440
+ walletPubKeyB64: bob.publicKeyB64,
441
+ capabilities: { methods: ['wallet_signMessage'], events: [], chains: [] },
442
+ dappName: 'Test',
443
+ };
444
+ const ctx2: SessionCryptoContext = {
445
+ dappPubKeyB64: alice.publicKeyB64,
446
+ walletPubKeyB64: bob.publicKeyB64,
447
+ capabilities: { methods: ['wallet_sendTransaction'], events: [], chains: [] },
448
+ dappName: 'Test',
449
+ };
450
+ const k1 = deriveDirectionalSessionKeys(rootKey, ch, ctx1);
451
+ const k2 = deriveDirectionalSessionKeys(rootKey, ch, ctx2);
452
+ expect(bytesToHex(k1.dappToWalletKey)).not.toBe(bytesToHex(k2.dappToWalletKey));
453
+ });
454
+
455
+ it('different dappName produces different transcript hash', () => {
456
+ const ctx1: SessionCryptoContext = {
457
+ dappPubKeyB64: 'pub1', walletPubKeyB64: 'pub2',
458
+ capabilities: null, dappName: 'AppA',
459
+ };
460
+ const ctx2: SessionCryptoContext = {
461
+ dappPubKeyB64: 'pub1', walletPubKeyB64: 'pub2',
462
+ capabilities: null, dappName: 'AppB',
463
+ };
464
+ const h1 = computeHandshakeTranscriptHash(ch, ctx1);
465
+ const h2 = computeHandshakeTranscriptHash(ch, ctx2);
466
+ expect(bytesToHex(h1)).not.toBe(bytesToHex(h2));
467
+ });
468
+
469
+ it('cross-direction decryption fails', () => {
470
+ const ctx: SessionCryptoContext = {
471
+ dappPubKeyB64: alice.publicKeyB64,
472
+ walletPubKeyB64: bob.publicKeyB64,
473
+ capabilities: null,
474
+ dappName: 'Test',
475
+ };
476
+ const keys = deriveDirectionalSessionKeys(rootKey, ch, ctx);
477
+ // Encrypt with dapp→wallet key
478
+ const sealed = sealPayload(keys.dappToWalletKey, ch, 0, { msg: 'hello' });
479
+ // Decrypt with wallet→dapp key should fail
480
+ expect(() => unsealPayload(keys.walletToDappKey, ch, sealed)).toThrow();
481
+ // Decrypt with correct key should succeed
482
+ expect(unsealPayload(keys.dappToWalletKey, ch, sealed).data).toEqual({ msg: 'hello' });
483
+ });
484
+ });
485
+
486
+ // ═══════════════════════════════════════════════════════════════════════
487
+ // Transcript hash determinism
488
+ // ═══════════════════════════════════════════════════════════════════════
489
+
490
+ describe('transcriptHash determinism', () => {
491
+ it('same inputs always produce same transcript hash', () => {
492
+ const ch = 'aa'.repeat(32);
493
+ const ctx: SessionCryptoContext = {
494
+ dappPubKeyB64: 'dappPub',
495
+ walletPubKeyB64: 'walletPub',
496
+ capabilities: { methods: ['test'], events: [], chains: ['eip155:1'] },
497
+ walletMeta: { name: 'W' },
498
+ dappName: 'D',
499
+ };
500
+ const h1 = bytesToHex(computeHandshakeTranscriptHash(ch, ctx));
501
+ const h2 = bytesToHex(computeHandshakeTranscriptHash(ch, ctx));
502
+ expect(h1).toBe(h2);
503
+ });
504
+
505
+ it('different channel ID produces different hash', () => {
506
+ const ctx: SessionCryptoContext = {
507
+ dappPubKeyB64: 'pub1', walletPubKeyB64: 'pub2',
508
+ capabilities: null, dappName: 'X',
509
+ };
510
+ const h1 = bytesToHex(computeHandshakeTranscriptHash('aa'.repeat(32), ctx));
511
+ const h2 = bytesToHex(computeHandshakeTranscriptHash('bb'.repeat(32), ctx));
512
+ expect(h1).not.toBe(h2);
513
+ });
514
+
515
+ it('swapped pub keys produce different hash', () => {
516
+ const ch = 'cc'.repeat(32);
517
+ const ctx1: SessionCryptoContext = {
518
+ dappPubKeyB64: 'keyA', walletPubKeyB64: 'keyB',
519
+ capabilities: null, dappName: 'X',
520
+ };
521
+ const ctx2: SessionCryptoContext = {
522
+ dappPubKeyB64: 'keyB', walletPubKeyB64: 'keyA',
523
+ capabilities: null, dappName: 'X',
524
+ };
525
+ const h1 = bytesToHex(computeHandshakeTranscriptHash(ch, ctx1));
526
+ const h2 = bytesToHex(computeHandshakeTranscriptHash(ch, ctx2));
527
+ expect(h1).not.toBe(h2);
528
+ });
529
+ });