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,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
|
+
});
|