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