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,557 @@
1
+ /**
2
+ * Adversarial tests: Cryptographic Attacks
3
+ *
4
+ * Tests resistance to cryptographic attacks at the primitive level:
5
+ * nonce reuse, ciphertext tampering, AAD manipulation, directional
6
+ * key confusion, low-order point rejection, and sealed_join forgery.
7
+ *
8
+ * Uses real crypto operations from the SDK — no mocks for crypto.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import {
13
+ generateX25519KeyPair,
14
+ generateChannelId,
15
+ computeSharedSecret,
16
+ deriveSessionKey,
17
+ deriveJoinEncryptionKey,
18
+ deriveDirectionalSessionKeys,
19
+ computeHandshakeTranscriptHash,
20
+ sealPayload,
21
+ unsealPayload,
22
+ sealJoin,
23
+ unsealJoin,
24
+ b64urlEncode,
25
+ b64urlDecode,
26
+ bytesToHex,
27
+ hexToBytes,
28
+ } from '../../crypto.js';
29
+ import type { AadHeader, SessionCryptoContext } from '../../crypto.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Attack 1: Nonce reuse detection
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('Crypto Attack: Nonce reuse', () => {
36
+ it('same seq number produces identical nonce — replay is detected by seq check', () => {
37
+ // ATTACK: An attacker re-sends a message with the same sequence number.
38
+ // The nonce is deterministically derived from the traffic key and seq,
39
+ // so the same seq produces the same nonce. AEAD with the same key+nonce
40
+ // and different plaintext would be catastrophic, but the protocol
41
+ // prevents this by requiring strictly increasing sequence numbers.
42
+ //
43
+ // PREVENTS: Nonce reuse in ChaCha20-Poly1305, which would break
44
+ // confidentiality (Section 6.6.1).
45
+
46
+ const key = new Uint8Array(32);
47
+ crypto.getRandomValues(key);
48
+ const ch = generateChannelId();
49
+
50
+ // Two messages with same seq produce same ciphertext (deterministic nonce)
51
+ const s1 = sealPayload(key, ch, 42, { data: 'same' });
52
+ const s2 = sealPayload(key, ch, 42, { data: 'same' });
53
+ expect(s1).toBe(s2); // same key + seq + data = same output
54
+
55
+ // Receiver would accept the first and reject the second (seq not increasing).
56
+ // At the crypto level, verify the first decrypts correctly:
57
+ const { seq, data } = unsealPayload(key, ch, s1);
58
+ expect(seq).toBe(42);
59
+ expect(data).toEqual({ data: 'same' });
60
+ });
61
+
62
+ it('same seq with different data produces different ciphertext but same nonce (dangerous without seq check)', () => {
63
+ // This demonstrates WHY the protocol must enforce strictly increasing seq:
64
+ // if two different plaintexts share the same nonce, the XOR of the two
65
+ // ciphertexts leaks the XOR of the two plaintexts.
66
+
67
+ const key = new Uint8Array(32);
68
+ crypto.getRandomValues(key);
69
+ const ch = generateChannelId();
70
+
71
+ const s1 = sealPayload(key, ch, 0, { secret: 'alpha' });
72
+ const s2 = sealPayload(key, ch, 0, { secret: 'bravo' });
73
+
74
+ // Different plaintext -> different ciphertext (even with same nonce)
75
+ expect(s1).not.toBe(s2);
76
+
77
+ // Both decrypt individually (attacker with key could see both)
78
+ expect(unsealPayload(key, ch, s1).data).toEqual({ secret: 'alpha' });
79
+ expect(unsealPayload(key, ch, s2).data).toEqual({ secret: 'bravo' });
80
+ });
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Attack 2: Bit-flip in ciphertext (AEAD integrity)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('Crypto Attack: Ciphertext bit-flip', () => {
88
+ it('single bit flip in ciphertext body causes AEAD decryption failure', () => {
89
+ // ATTACK: Attacker flips a single bit in the ciphertext portion
90
+ // (after the 4-byte seq prefix). AEAD (ChaCha20-Poly1305) provides
91
+ // integrity — the Poly1305 tag will not verify.
92
+ //
93
+ // PREVENTS: Ciphertext malleability. Attacker cannot modify
94
+ // encrypted data without detection.
95
+
96
+ const key = new Uint8Array(32);
97
+ crypto.getRandomValues(key);
98
+ const ch = generateChannelId();
99
+
100
+ const sealed = sealPayload(key, ch, 0, { amount: '1000000', to: '0xVictim' });
101
+ const bytes = b64urlDecode(sealed);
102
+
103
+ // Flip a bit in the ciphertext body (byte 10, well past the 4-byte seq)
104
+ const tampered = new Uint8Array(bytes);
105
+ tampered[10] = tampered[10]! ^ 0x01;
106
+
107
+ expect(() => unsealPayload(key, ch, b64urlEncode(tampered))).toThrow();
108
+ });
109
+
110
+ it('bit flip in Poly1305 tag causes decryption failure', () => {
111
+ // ATTACK: Attacker flips the last byte (part of the 16-byte tag).
112
+ //
113
+ // PREVENTS: Tag forgery.
114
+
115
+ const key = new Uint8Array(32);
116
+ crypto.getRandomValues(key);
117
+ const ch = generateChannelId();
118
+
119
+ const sealed = sealPayload(key, ch, 0, { data: 'protected' });
120
+ const bytes = b64urlDecode(sealed);
121
+
122
+ const tampered = new Uint8Array(bytes);
123
+ tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff;
124
+
125
+ expect(() => unsealPayload(key, ch, b64urlEncode(tampered))).toThrow();
126
+ });
127
+
128
+ it('bit flip in seq bytes changes nonce and causes decryption failure', () => {
129
+ // ATTACK: Attacker modifies the seq bytes (first 4 bytes of sealed).
130
+ // This changes the derived nonce, so decryption fails.
131
+ //
132
+ // PREVENTS: Sequence number tampering.
133
+
134
+ const key = new Uint8Array(32);
135
+ crypto.getRandomValues(key);
136
+ const ch = generateChannelId();
137
+
138
+ const sealed = sealPayload(key, ch, 7, { msg: 'test' });
139
+ const bytes = b64urlDecode(sealed);
140
+
141
+ const tampered = new Uint8Array(bytes);
142
+ tampered[3] = tampered[3]! ^ 0x01; // flip low bit of seq
143
+
144
+ expect(() => unsealPayload(key, ch, b64urlEncode(tampered))).toThrow();
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Attack 3: Bit-flip in AAD fields
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe('Crypto Attack: AAD field manipulation', () => {
153
+ it('changing message type in AAD causes decryption failure', () => {
154
+ // ATTACK: Relay changes the message type (req -> res) while forwarding.
155
+ // The AAD includes the type byte, so the Poly1305 tag won't verify
156
+ // against the modified AAD.
157
+ //
158
+ // PREVENTS: Type confusion attacks where a req is reinterpreted as res.
159
+
160
+ const key = new Uint8Array(32);
161
+ crypto.getRandomValues(key);
162
+ const ch = generateChannelId();
163
+
164
+ const hdr: AadHeader = { type: 'req', from: 'dapp-key', id: 'req-1' };
165
+ const sealed = sealPayload(key, ch, 0, { _method: 'wallet_signMessage' }, hdr);
166
+
167
+ // Try to decrypt with 'res' type — AAD mismatch
168
+ const wrongHdr: AadHeader = { type: 'res', from: 'dapp-key', id: 'req-1' };
169
+ expect(() => unsealPayload(key, ch, sealed, wrongHdr)).toThrow();
170
+ });
171
+
172
+ it('changing from in AAD causes decryption failure', () => {
173
+ // ATTACK: Relay substitutes the from field in the envelope.
174
+ // The AAD binds the from field, so tag verification fails.
175
+ //
176
+ // PREVENTS: Sender impersonation at the AEAD level.
177
+
178
+ const key = new Uint8Array(32);
179
+ crypto.getRandomValues(key);
180
+ const ch = generateChannelId();
181
+
182
+ const hdr: AadHeader = { type: 'req', from: 'real-dapp-key', id: 'r1' };
183
+ const sealed = sealPayload(key, ch, 0, { _method: 'test' }, hdr);
184
+
185
+ const spoofed: AadHeader = { type: 'req', from: 'relay-fake-key', id: 'r1' };
186
+ expect(() => unsealPayload(key, ch, sealed, spoofed)).toThrow();
187
+ });
188
+
189
+ it('changing id in AAD causes decryption failure', () => {
190
+ // ATTACK: Relay swaps the request ID to redirect a response to
191
+ // a different pending request.
192
+ //
193
+ // PREVENTS: Request ID substitution.
194
+
195
+ const key = new Uint8Array(32);
196
+ crypto.getRandomValues(key);
197
+ const ch = generateChannelId();
198
+
199
+ const hdr: AadHeader = { type: 'res', from: 'wallet-key', id: 'req-42' };
200
+ const sealed = sealPayload(key, ch, 0, { _ok: true, _result: 'secret' }, hdr);
201
+
202
+ const swapped: AadHeader = { type: 'res', from: 'wallet-key', id: 'req-1' };
203
+ expect(() => unsealPayload(key, ch, sealed, swapped)).toThrow();
204
+ });
205
+
206
+ it('changing channel ID causes decryption failure', () => {
207
+ // ATTACK: Relay forwards a message from one channel to another.
208
+ // The AAD includes channel_id_bytes, so cross-channel replay fails.
209
+ //
210
+ // PREVENTS: Cross-channel message injection.
211
+
212
+ const key = new Uint8Array(32);
213
+ crypto.getRandomValues(key);
214
+ const ch1 = generateChannelId();
215
+ const ch2 = generateChannelId();
216
+
217
+ const sealed = sealPayload(key, ch1, 0, { data: 'channel-1' });
218
+ expect(() => unsealPayload(key, ch2, sealed)).toThrow();
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Attack 4: Wrong traffic key direction
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe('Crypto Attack: Directional key confusion', () => {
227
+ it('message encrypted with dappToWallet key cannot be decrypted with walletToDapp key', () => {
228
+ // ATTACK: Attacker (or misconfigured peer) tries to use the wrong
229
+ // directional key. Since dappToWalletKey != walletToDappKey,
230
+ // decryption fails. This prevents reflection attacks where a
231
+ // message sent in one direction is replayed in the other.
232
+ //
233
+ // PREVENTS: Direction reversal and reflection attacks (Section 6.2).
234
+
235
+ const dappKp = generateX25519KeyPair();
236
+ const walletKp = generateX25519KeyPair();
237
+ const ch = generateChannelId();
238
+
239
+ const shared = computeSharedSecret(dappKp.privateKey, walletKp.publicKey);
240
+ const rootKey = deriveSessionKey(shared, ch);
241
+ const ctx: SessionCryptoContext = {
242
+ dappPubKeyB64: dappKp.publicKeyB64,
243
+ walletPubKeyB64: walletKp.publicKeyB64,
244
+ capabilities: { methods: ['test'], events: [], chains: [] },
245
+ dappName: 'Test',
246
+ };
247
+ const keys = deriveDirectionalSessionKeys(rootKey, ch, ctx);
248
+
249
+ // Encrypt with dappToWalletKey
250
+ const sealed = sealPayload(keys.dappToWalletKey, ch, 0, { secret: 'hello' });
251
+
252
+ // Cannot decrypt with walletToDappKey
253
+ expect(() => unsealPayload(keys.walletToDappKey, ch, sealed)).toThrow();
254
+
255
+ // Can decrypt with correct key
256
+ const { data } = unsealPayload(keys.dappToWalletKey, ch, sealed);
257
+ expect(data).toEqual({ secret: 'hello' });
258
+ });
259
+
260
+ it('wallet response encrypted with dappToWallet key cannot be decrypted by dApp', () => {
261
+ // If a wallet mistakenly uses the wrong key direction for responses,
262
+ // the dApp cannot decrypt them.
263
+
264
+ const dappKp = generateX25519KeyPair();
265
+ const walletKp = generateX25519KeyPair();
266
+ const ch = generateChannelId();
267
+
268
+ const shared = computeSharedSecret(dappKp.privateKey, walletKp.publicKey);
269
+ const rootKey = deriveSessionKey(shared, ch);
270
+ const ctx: SessionCryptoContext = {
271
+ dappPubKeyB64: dappKp.publicKeyB64,
272
+ walletPubKeyB64: walletKp.publicKeyB64,
273
+ capabilities: null,
274
+ dappName: 'Test',
275
+ };
276
+ const keys = deriveDirectionalSessionKeys(rootKey, ch, ctx);
277
+
278
+ // Wallet "accidentally" encrypts response with dappToWalletKey (wrong direction)
279
+ const hdr: AadHeader = { type: 'res', from: walletKp.publicKeyB64, id: 'r1' };
280
+ const sealedWrong = sealPayload(keys.dappToWalletKey, ch, 0, { _ok: true, _result: 'oops' }, hdr);
281
+
282
+ // DApp tries to decrypt with walletToDappKey (correct for responses)
283
+ expect(() => unsealPayload(keys.walletToDappKey, ch, sealedWrong, hdr)).toThrow();
284
+ });
285
+ });
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Attack 5: All-zero shared secret (low-order point)
289
+ // ---------------------------------------------------------------------------
290
+
291
+ describe('Crypto Attack: Low-order point rejection', () => {
292
+ it('all-zero shared secret is rejected during key exchange', () => {
293
+ // ATTACK: Attacker sends a low-order X25519 public key (one of the
294
+ // 12 low-order points on Curve25519). The X25519 operation produces
295
+ // an all-zero shared secret, which would lead to predictable keys
296
+ // that any observer could derive.
297
+ //
298
+ // PREVENTS: Key exchange with degenerate public keys that produce
299
+ // trivial shared secrets (RFC 7748 Section 6, Protocol Section 6.2).
300
+
301
+ const myKp = generateX25519KeyPair();
302
+
303
+ // Known low-order point: the all-zero point
304
+ const allZeroKey = new Uint8Array(32);
305
+
306
+ // The noble library may throw its own error before our all-zero check,
307
+ // or our code throws "all-zero shared secret". Either way, it must throw.
308
+ expect(() => computeSharedSecret(myKp.privateKey, allZeroKey)).toThrow();
309
+ });
310
+
311
+ it('another low-order point (order 2: point at x=1) is rejected', () => {
312
+ // The point with x-coordinate = 1 has order 2 on Curve25519.
313
+ // X25519 with this point should produce all-zero output.
314
+
315
+ const myKp = generateX25519KeyPair();
316
+ const lowOrderPoint = new Uint8Array(32);
317
+ lowOrderPoint[0] = 1; // x = 1
318
+
319
+ // This should either throw due to all-zero result or be safe
320
+ // depending on the X25519 implementation. The important thing
321
+ // is that the SDK's computeSharedSecret rejects all-zero output.
322
+ // The noble library may reject this at the X25519 level or our
323
+ // code rejects the all-zero output. Either way, it must throw.
324
+ try {
325
+ computeSharedSecret(myKp.privateKey, lowOrderPoint);
326
+ // If it didn't throw, the implementation clamped and produced
327
+ // non-zero output — this is also acceptable behavior.
328
+ } catch {
329
+ // Expected: low-order point rejected
330
+ }
331
+ });
332
+
333
+ it('remote public key must be exactly 32 bytes', () => {
334
+ // ATTACK: Attacker sends a malformed public key (wrong length).
335
+ //
336
+ // PREVENTS: Buffer overflows or unexpected behavior from wrong-sized keys.
337
+
338
+ const myKp = generateX25519KeyPair();
339
+
340
+ // Too short
341
+ expect(() => computeSharedSecret(myKp.privateKey, new Uint8Array(16))).toThrow(
342
+ '32 bytes',
343
+ );
344
+
345
+ // Too long
346
+ expect(() => computeSharedSecret(myKp.privateKey, new Uint8Array(64))).toThrow(
347
+ '32 bytes',
348
+ );
349
+
350
+ // Empty
351
+ expect(() => computeSharedSecret(myKp.privateKey, new Uint8Array(0))).toThrow(
352
+ '32 bytes',
353
+ );
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Attack 6: Modified sealed_join
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe('Crypto Attack: Sealed join tampering', () => {
362
+ it('tampered sealed_join ciphertext fails AEAD decryption', () => {
363
+ // ATTACK: Relay modifies the sealed_join payload (e.g., to change
364
+ // capabilities or wallet metadata).
365
+ //
366
+ // PREVENTS: Capability injection — relay cannot grant methods that
367
+ // the wallet did not authorize.
368
+
369
+ const dappKp = generateX25519KeyPair();
370
+ const walletKp = generateX25519KeyPair();
371
+ const ch = generateChannelId();
372
+
373
+ const shared = computeSharedSecret(walletKp.privateKey, dappKp.publicKey);
374
+ const rootKey = deriveSessionKey(shared, ch);
375
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch);
376
+
377
+ const caps = { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] };
378
+ const meta = { name: 'W', description: 'W', url: 'https://w.test', icon: 'https://w.test/i.png' };
379
+ const sealed = sealJoin(joinKey, ch, caps, meta);
380
+
381
+ // Tamper with the ciphertext
382
+ const bytes = b64urlDecode(sealed);
383
+ const tampered = new Uint8Array(bytes);
384
+ tampered[20] = tampered[20]! ^ 0xff; // flip a byte in ciphertext
385
+
386
+ expect(() => unsealJoin(joinKey, ch, b64urlEncode(tampered))).toThrow();
387
+ });
388
+
389
+ it('sealed_join with wrong channel ID fails decryption', () => {
390
+ // ATTACK: Relay forwards a sealed_join from one channel to another.
391
+ // The AAD includes channel_id_bytes, preventing cross-channel reuse.
392
+ //
393
+ // PREVENTS: Cross-channel sealed_join replay.
394
+
395
+ const dappKp = generateX25519KeyPair();
396
+ const walletKp = generateX25519KeyPair();
397
+ const ch1 = generateChannelId();
398
+ const ch2 = generateChannelId();
399
+
400
+ const shared = computeSharedSecret(walletKp.privateKey, dappKp.publicKey);
401
+ const rootKey = deriveSessionKey(shared, ch1);
402
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch1);
403
+
404
+ const caps = { methods: ['wallet_signMessage'], events: [], chains: [] };
405
+ const sealed = sealJoin(joinKey, ch1, caps);
406
+
407
+ // Same key but different channel ID → AAD mismatch
408
+ expect(() => unsealJoin(joinKey, ch2, sealed)).toThrow();
409
+ });
410
+
411
+ it('modified sealed_join produces different transcript hash and traffic keys', () => {
412
+ // If sealed_join content were somehow different (e.g., different
413
+ // capabilities), the transcript hash would differ, and therefore
414
+ // the traffic keys would differ. The peers would be unable to
415
+ // communicate. This is a defense-in-depth check.
416
+ //
417
+ // PREVENTS: Capability downgrade attack at the transcript level.
418
+
419
+ const dappKp = generateX25519KeyPair();
420
+ const walletKp = generateX25519KeyPair();
421
+ const ch = generateChannelId();
422
+
423
+ const shared = computeSharedSecret(dappKp.privateKey, walletKp.publicKey);
424
+ const rootKey = deriveSessionKey(shared, ch);
425
+
426
+ // Transcript with capabilities A
427
+ const ctx1: SessionCryptoContext = {
428
+ dappPubKeyB64: dappKp.publicKeyB64,
429
+ walletPubKeyB64: walletKp.publicKeyB64,
430
+ capabilities: { methods: ['wallet_signMessage'], events: [], chains: [] },
431
+ dappName: 'App',
432
+ };
433
+ const keys1 = deriveDirectionalSessionKeys(new Uint8Array(rootKey), ch, ctx1);
434
+
435
+ // Transcript with capabilities B (relay tried to modify sealed_join)
436
+ const ctx2: SessionCryptoContext = {
437
+ dappPubKeyB64: dappKp.publicKeyB64,
438
+ walletPubKeyB64: walletKp.publicKeyB64,
439
+ capabilities: { methods: ['wallet_signMessage', 'wallet_drainFunds'], events: [], chains: [] },
440
+ dappName: 'App',
441
+ };
442
+ const keys2 = deriveDirectionalSessionKeys(new Uint8Array(rootKey), ch, ctx2);
443
+
444
+ // Traffic keys must differ — peers would not be able to communicate
445
+ expect(bytesToHex(keys1.dappToWalletKey)).not.toBe(bytesToHex(keys2.dappToWalletKey));
446
+ expect(bytesToHex(keys1.walletToDappKey)).not.toBe(bytesToHex(keys2.walletToDappKey));
447
+ expect(bytesToHex(keys1.transcriptHash)).not.toBe(bytesToHex(keys2.transcriptHash));
448
+ });
449
+
450
+ it('sealed_join with truncated data fails', () => {
451
+ // ATTACK: Relay truncates the sealed_join envelope.
452
+
453
+ const dappKp = generateX25519KeyPair();
454
+ const walletKp = generateX25519KeyPair();
455
+ const ch = generateChannelId();
456
+
457
+ const shared = computeSharedSecret(walletKp.privateKey, dappKp.publicKey);
458
+ const rootKey = deriveSessionKey(shared, ch);
459
+ const joinKey = deriveJoinEncryptionKey(rootKey, ch);
460
+
461
+ const caps = { methods: ['test'], events: [], chains: [] };
462
+ const sealed = sealJoin(joinKey, ch, caps);
463
+ const bytes = b64urlDecode(sealed);
464
+
465
+ // Truncate to just nonce (12 bytes) + partial ciphertext
466
+ const truncated = b64urlEncode(bytes.slice(0, 20));
467
+ expect(() => unsealJoin(joinKey, ch, truncated)).toThrow();
468
+ });
469
+ });
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Attack 7: Cross-session key isolation
473
+ // ---------------------------------------------------------------------------
474
+
475
+ describe('Crypto Attack: Cross-session key isolation', () => {
476
+ it('keys from one session cannot decrypt messages from another', () => {
477
+ // ATTACK: Attacker captures encrypted messages from session A and
478
+ // tries to decrypt them with keys from session B (same peers,
479
+ // different channel). Each channel uses a unique channel ID as
480
+ // HKDF salt, so keys are independent.
481
+ //
482
+ // PREVENTS: Cross-session key reuse.
483
+
484
+ const dappKp = generateX25519KeyPair();
485
+ const walletKp = generateX25519KeyPair();
486
+ const ch1 = generateChannelId();
487
+ const ch2 = generateChannelId();
488
+
489
+ const shared = computeSharedSecret(dappKp.privateKey, walletKp.publicKey);
490
+
491
+ const root1 = deriveSessionKey(shared, ch1);
492
+ const root2 = deriveSessionKey(shared, ch2);
493
+
494
+ // Root keys differ (different channel ID salt)
495
+ expect(bytesToHex(root1)).not.toBe(bytesToHex(root2));
496
+
497
+ const ctx: SessionCryptoContext = {
498
+ dappPubKeyB64: dappKp.publicKeyB64,
499
+ walletPubKeyB64: walletKp.publicKeyB64,
500
+ capabilities: null,
501
+ dappName: 'App',
502
+ };
503
+
504
+ const keys1 = deriveDirectionalSessionKeys(root1, ch1, ctx);
505
+ const keys2 = deriveDirectionalSessionKeys(root2, ch2, ctx);
506
+
507
+ // Encrypt with session 1 key
508
+ const sealed = sealPayload(keys1.dappToWalletKey, ch1, 0, { secret: 'session1' });
509
+
510
+ // Cannot decrypt with session 2 key
511
+ expect(() => unsealPayload(keys2.dappToWalletKey, ch2, sealed)).toThrow();
512
+
513
+ // Cannot decrypt with session 2 key + session 1 channel
514
+ expect(() => unsealPayload(keys2.dappToWalletKey, ch1, sealed)).toThrow();
515
+
516
+ // Can decrypt with correct key + channel
517
+ const { data } = unsealPayload(keys1.dappToWalletKey, ch1, sealed);
518
+ expect(data).toEqual({ secret: 'session1' });
519
+ });
520
+ });
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Attack 8: Empty and malformed sealed payloads
524
+ // ---------------------------------------------------------------------------
525
+
526
+ describe('Crypto Attack: Malformed sealed payloads', () => {
527
+ it('empty string sealed payload throws', () => {
528
+ const key = new Uint8Array(32);
529
+ crypto.getRandomValues(key);
530
+ const ch = generateChannelId();
531
+ expect(() => unsealPayload(key, ch, '')).toThrow();
532
+ });
533
+
534
+ it('sealed payload with only seq bytes (no ciphertext) throws', () => {
535
+ const key = new Uint8Array(32);
536
+ crypto.getRandomValues(key);
537
+ const ch = generateChannelId();
538
+ const onlySeq = b64urlEncode(new Uint8Array(4));
539
+ expect(() => unsealPayload(key, ch, onlySeq)).toThrow();
540
+ });
541
+
542
+ it('sealed payload with random garbage throws', () => {
543
+ const key = new Uint8Array(32);
544
+ crypto.getRandomValues(key);
545
+ const ch = generateChannelId();
546
+ const garbage = b64urlEncode(crypto.getRandomValues(new Uint8Array(100)));
547
+ expect(() => unsealPayload(key, ch, garbage)).toThrow();
548
+ });
549
+
550
+ it('sealed_join with envelope smaller than nonce+tag rejects early', () => {
551
+ const key = new Uint8Array(32);
552
+ crypto.getRandomValues(key);
553
+ const ch = generateChannelId();
554
+ const tiny = b64urlEncode(new Uint8Array(10)); // < 12 + 16
555
+ expect(() => unsealJoin(key, ch, tiny)).toThrow('Invalid sealed_join');
556
+ });
557
+ });