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,635 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
b64urlEncode,
|
|
4
|
+
b64urlDecode,
|
|
5
|
+
generateX25519KeyPair,
|
|
6
|
+
getPublicKey,
|
|
7
|
+
computeSharedSecret,
|
|
8
|
+
deriveSessionKey,
|
|
9
|
+
deriveJoinEncryptionKey,
|
|
10
|
+
computeSessionFingerprint,
|
|
11
|
+
sealPayload,
|
|
12
|
+
unsealPayload,
|
|
13
|
+
sealJoin,
|
|
14
|
+
unsealJoin,
|
|
15
|
+
generateChannelId,
|
|
16
|
+
buildPairingUri,
|
|
17
|
+
parsePairingUri,
|
|
18
|
+
bytesToHex,
|
|
19
|
+
hexToBytes,
|
|
20
|
+
constantTimeEqual,
|
|
21
|
+
canonicalJson,
|
|
22
|
+
signSnapshot,
|
|
23
|
+
verifySnapshot,
|
|
24
|
+
} from './crypto.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Base64url
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('b64url', () => {
|
|
31
|
+
it('round-trips arbitrary bytes', () => {
|
|
32
|
+
const bytes = new Uint8Array([0, 1, 2, 255, 128, 64, 32, 16]);
|
|
33
|
+
expect(b64urlDecode(b64urlEncode(bytes))).toEqual(bytes);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('produces no padding characters', () => {
|
|
37
|
+
const encoded = b64urlEncode(new Uint8Array([1, 2, 3]));
|
|
38
|
+
expect(encoded).not.toContain('=');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('uses URL-safe alphabet (no + or /)', () => {
|
|
42
|
+
// Encode bytes that would produce + and / in standard base64
|
|
43
|
+
const bytes = new Uint8Array(256);
|
|
44
|
+
for (let i = 0; i < 256; i++) bytes[i] = i;
|
|
45
|
+
const encoded = b64urlEncode(bytes);
|
|
46
|
+
expect(encoded).not.toContain('+');
|
|
47
|
+
expect(encoded).not.toContain('/');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles empty input', () => {
|
|
51
|
+
expect(b64urlEncode(new Uint8Array(0))).toBe('');
|
|
52
|
+
expect(b64urlDecode('')).toEqual(new Uint8Array(0));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('decodes known value', () => {
|
|
56
|
+
// "Hello" in base64url = "SGVsbG8"
|
|
57
|
+
const decoded = b64urlDecode('SGVsbG8');
|
|
58
|
+
expect(new TextDecoder().decode(decoded)).toBe('Hello');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Key generation
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('generateX25519KeyPair', () => {
|
|
67
|
+
it('returns 32-byte private and public keys', () => {
|
|
68
|
+
const kp = generateX25519KeyPair();
|
|
69
|
+
expect(kp.privateKey).toHaveLength(32);
|
|
70
|
+
expect(kp.publicKey).toHaveLength(32);
|
|
71
|
+
expect(typeof kp.publicKeyB64).toBe('string');
|
|
72
|
+
expect(kp.publicKeyB64.length).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('generates unique key pairs', () => {
|
|
76
|
+
const a = generateX25519KeyPair();
|
|
77
|
+
const b = generateX25519KeyPair();
|
|
78
|
+
expect(bytesToHex(a.privateKey)).not.toBe(bytesToHex(b.privateKey));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('publicKeyB64 decodes back to publicKey', () => {
|
|
82
|
+
const kp = generateX25519KeyPair();
|
|
83
|
+
expect(b64urlDecode(kp.publicKeyB64)).toEqual(kp.publicKey);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getPublicKey', () => {
|
|
88
|
+
it('derives the same public key as generateX25519KeyPair', () => {
|
|
89
|
+
const kp = generateX25519KeyPair();
|
|
90
|
+
const derived = getPublicKey(kp.privateKey);
|
|
91
|
+
expect(derived).toEqual(kp.publicKey);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Shared secret & session key derivation
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('key exchange', () => {
|
|
100
|
+
it('both peers derive the same shared secret (X25519 DH)', () => {
|
|
101
|
+
const alice = generateX25519KeyPair();
|
|
102
|
+
const bob = generateX25519KeyPair();
|
|
103
|
+
|
|
104
|
+
const secretA = computeSharedSecret(alice.privateKey, bob.publicKey);
|
|
105
|
+
const secretB = computeSharedSecret(bob.privateKey, alice.publicKey);
|
|
106
|
+
|
|
107
|
+
expect(secretA).toEqual(secretB);
|
|
108
|
+
expect(secretA).toHaveLength(32);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('both peers derive the same session key', () => {
|
|
112
|
+
const alice = generateX25519KeyPair();
|
|
113
|
+
const bob = generateX25519KeyPair();
|
|
114
|
+
const channelId = generateChannelId();
|
|
115
|
+
|
|
116
|
+
const shared = computeSharedSecret(alice.privateKey, bob.publicKey);
|
|
117
|
+
const skA = deriveSessionKey(shared, channelId);
|
|
118
|
+
const skB = deriveSessionKey(computeSharedSecret(bob.privateKey, alice.publicKey), channelId);
|
|
119
|
+
|
|
120
|
+
expect(skA).toEqual(skB);
|
|
121
|
+
expect(skA).toHaveLength(32);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('different channel IDs produce different session keys', () => {
|
|
125
|
+
const alice = generateX25519KeyPair();
|
|
126
|
+
const bob = generateX25519KeyPair();
|
|
127
|
+
const shared = computeSharedSecret(alice.privateKey, bob.publicKey);
|
|
128
|
+
|
|
129
|
+
const sk1 = deriveSessionKey(shared, generateChannelId());
|
|
130
|
+
const sk2 = deriveSessionKey(shared, generateChannelId());
|
|
131
|
+
|
|
132
|
+
expect(bytesToHex(sk1)).not.toBe(bytesToHex(sk2));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects remote public key that is not 32 bytes', () => {
|
|
136
|
+
const alice = generateX25519KeyPair();
|
|
137
|
+
expect(() => computeSharedSecret(alice.privateKey, new Uint8Array(31))).toThrow('32 bytes');
|
|
138
|
+
expect(() => computeSharedSecret(alice.privateKey, new Uint8Array(33))).toThrow('32 bytes');
|
|
139
|
+
expect(() => computeSharedSecret(alice.privateKey, new Uint8Array(0))).toThrow('32 bytes');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects all-zero public key (low-order point)', () => {
|
|
143
|
+
const alice = generateX25519KeyPair();
|
|
144
|
+
const zeroKey = new Uint8Array(32); // all zeros — low-order point
|
|
145
|
+
// Noble library rejects this at the X25519 level; our wrapper also
|
|
146
|
+
// has an explicit all-zero check for libraries that don't.
|
|
147
|
+
expect(() => computeSharedSecret(alice.privateKey, zeroKey)).toThrow();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Session fingerprint
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe('computeSessionFingerprint', () => {
|
|
156
|
+
it('returns a 4-digit string', () => {
|
|
157
|
+
const channelId = generateChannelId();
|
|
158
|
+
const kp = generateX25519KeyPair();
|
|
159
|
+
|
|
160
|
+
const code = computeSessionFingerprint(channelId, kp.publicKeyB64);
|
|
161
|
+
expect(code).toMatch(/^\d{4}$/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('is deterministic for same inputs', () => {
|
|
165
|
+
const ch = '00'.repeat(32);
|
|
166
|
+
const pubB64 = b64urlEncode(new Uint8Array(32).fill(42));
|
|
167
|
+
expect(computeSessionFingerprint(ch, pubB64)).toBe(computeSessionFingerprint(ch, pubB64));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('different channel IDs produce different fingerprints', () => {
|
|
171
|
+
const kp = generateX25519KeyPair();
|
|
172
|
+
const ch1 = generateChannelId();
|
|
173
|
+
const ch2 = generateChannelId();
|
|
174
|
+
expect(computeSessionFingerprint(ch1, kp.publicKeyB64)).not.toBe(
|
|
175
|
+
computeSessionFingerprint(ch2, kp.publicKeyB64),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('different dApp pubkeys produce different fingerprints', () => {
|
|
180
|
+
const ch = generateChannelId();
|
|
181
|
+
const kp1 = generateX25519KeyPair();
|
|
182
|
+
const kp2 = generateX25519KeyPair();
|
|
183
|
+
expect(computeSessionFingerprint(ch, kp1.publicKeyB64)).not.toBe(
|
|
184
|
+
computeSessionFingerprint(ch, kp2.publicKeyB64),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('pads with leading zeros when necessary', () => {
|
|
189
|
+
// We can't force a specific output, but verify format consistency
|
|
190
|
+
const results = new Set<string>();
|
|
191
|
+
for (let i = 0; i < 20; i++) {
|
|
192
|
+
const ch = generateChannelId();
|
|
193
|
+
const kp = generateX25519KeyPair();
|
|
194
|
+
const code = computeSessionFingerprint(ch, kp.publicKeyB64);
|
|
195
|
+
expect(code).toHaveLength(4);
|
|
196
|
+
results.add(code);
|
|
197
|
+
}
|
|
198
|
+
// Very unlikely all 20 are the same
|
|
199
|
+
expect(results.size).toBeGreaterThan(1);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Seal / Unseal (encryption round-trip)
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
describe('seal/unseal', () => {
|
|
208
|
+
const sessionKey = new Uint8Array(32);
|
|
209
|
+
crypto.getRandomValues(sessionKey);
|
|
210
|
+
const channelId = generateChannelId();
|
|
211
|
+
|
|
212
|
+
it('round-trips a simple object', () => {
|
|
213
|
+
const data = { hello: 'world', num: 42 };
|
|
214
|
+
const sealed = sealPayload(sessionKey, channelId, 0, data);
|
|
215
|
+
const { seq, data: decrypted } = unsealPayload(sessionKey, channelId, sealed);
|
|
216
|
+
|
|
217
|
+
expect(seq).toBe(0);
|
|
218
|
+
expect(decrypted).toEqual(data);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('round-trips with different sequence numbers', () => {
|
|
222
|
+
for (const seqNum of [0, 1, 100, 65535, 2 ** 31 - 1]) {
|
|
223
|
+
const data = { seq: seqNum };
|
|
224
|
+
const sealed = sealPayload(sessionKey, channelId, seqNum, data);
|
|
225
|
+
const { seq, data: decrypted } = unsealPayload(sessionKey, channelId, sealed);
|
|
226
|
+
expect(seq).toBe(seqNum);
|
|
227
|
+
expect(decrypted).toEqual(data);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('round-trips arrays, strings, numbers, null', () => {
|
|
232
|
+
for (const data of [[1, 2, 3], 'hello', 42, null, { nested: { deep: true } }]) {
|
|
233
|
+
const sealed = sealPayload(sessionKey, channelId, 0, data);
|
|
234
|
+
const { data: decrypted } = unsealPayload(sessionKey, channelId, sealed);
|
|
235
|
+
expect(decrypted).toEqual(data);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('round-trips empty object', () => {
|
|
240
|
+
const sealed = sealPayload(sessionKey, channelId, 0, {});
|
|
241
|
+
const { data } = unsealPayload(sessionKey, channelId, sealed);
|
|
242
|
+
expect(data).toEqual({});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('round-trips unicode text', () => {
|
|
246
|
+
const data = { text: '你好世界 🌍 émojis' };
|
|
247
|
+
const sealed = sealPayload(sessionKey, channelId, 0, data);
|
|
248
|
+
const { data: decrypted } = unsealPayload(sessionKey, channelId, sealed);
|
|
249
|
+
expect(decrypted).toEqual(data);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('fails to decrypt with wrong session key', () => {
|
|
253
|
+
const sealed = sealPayload(sessionKey, channelId, 0, { secret: true });
|
|
254
|
+
const wrongKey = new Uint8Array(32);
|
|
255
|
+
crypto.getRandomValues(wrongKey);
|
|
256
|
+
expect(() => unsealPayload(wrongKey, channelId, sealed)).toThrow();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('fails to decrypt with wrong channel ID', () => {
|
|
260
|
+
const sealed = sealPayload(sessionKey, channelId, 0, { secret: true });
|
|
261
|
+
const wrongCh = generateChannelId();
|
|
262
|
+
expect(() => unsealPayload(sessionKey, wrongCh, sealed)).toThrow();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('fails to decrypt tampered ciphertext', () => {
|
|
266
|
+
const sealed = sealPayload(sessionKey, channelId, 0, { secret: true });
|
|
267
|
+
const bytes = b64urlDecode(sealed);
|
|
268
|
+
// Flip a byte in the ciphertext portion
|
|
269
|
+
bytes[10]! ^= 0xff;
|
|
270
|
+
const tampered = b64urlEncode(bytes);
|
|
271
|
+
expect(() => unsealPayload(sessionKey, channelId, tampered)).toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('different sequence numbers produce different ciphertexts', () => {
|
|
275
|
+
const data = { same: 'data' };
|
|
276
|
+
const s0 = sealPayload(sessionKey, channelId, 0, data);
|
|
277
|
+
const s1 = sealPayload(sessionKey, channelId, 1, data);
|
|
278
|
+
expect(s0).not.toBe(s1);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('sealJoin/unsealJoin', () => {
|
|
283
|
+
it('round-trips capabilities and metadata with a nonce-prefixed envelope', () => {
|
|
284
|
+
const joinKey = deriveJoinEncryptionKey(new Uint8Array(32).fill(7), '11'.repeat(32));
|
|
285
|
+
const capabilities = { methods: ['wallet_getAccounts'], events: ['accountsChanged'], chains: ['eip155:1'] };
|
|
286
|
+
const meta = { name: 'Test Wallet' };
|
|
287
|
+
|
|
288
|
+
const sealed = sealJoin(joinKey, '11'.repeat(32), capabilities, meta);
|
|
289
|
+
const envelope = b64urlDecode(sealed);
|
|
290
|
+
expect(envelope.length).toBeGreaterThan(12 + 16);
|
|
291
|
+
|
|
292
|
+
expect(unsealJoin(joinKey, '11'.repeat(32), sealed)).toEqual({ capabilities, meta });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('uses a fresh nonce for each sealed_join encryption', () => {
|
|
296
|
+
const joinKey = deriveJoinEncryptionKey(new Uint8Array(32).fill(9), '22'.repeat(32));
|
|
297
|
+
const capabilities = { methods: ['wallet_getAccounts'], events: [], chains: ['eip155:1'] };
|
|
298
|
+
|
|
299
|
+
const a = sealJoin(joinKey, '22'.repeat(32), capabilities, {});
|
|
300
|
+
const b = sealJoin(joinKey, '22'.repeat(32), capabilities, {});
|
|
301
|
+
|
|
302
|
+
expect(a).not.toBe(b);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Channel ID generation
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('generateChannelId', () => {
|
|
311
|
+
it('returns 64 hex characters (32 bytes)', () => {
|
|
312
|
+
const id = generateChannelId();
|
|
313
|
+
expect(id).toMatch(/^[0-9a-f]{64}$/);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('generates unique IDs', () => {
|
|
317
|
+
const ids = new Set(Array.from({ length: 10 }, () => generateChannelId()));
|
|
318
|
+
expect(ids.size).toBe(10);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Pairing URI
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
describe('buildPairingUri', () => {
|
|
327
|
+
it('builds URI with all parameters', () => {
|
|
328
|
+
const uri = buildPairingUri({
|
|
329
|
+
channelId: 'abcd1234',
|
|
330
|
+
pubkeyB64: 'AQID',
|
|
331
|
+
relayUrl: 'wss://relay.example.com/v1',
|
|
332
|
+
name: 'My dApp',
|
|
333
|
+
url: 'https://dapp.example.com',
|
|
334
|
+
icon: 'https://dapp.example.com/icon.png',
|
|
335
|
+
});
|
|
336
|
+
expect(uri).toContain('walletpair:?ch=abcd1234');
|
|
337
|
+
expect(uri).toContain('&pubkey=AQID');
|
|
338
|
+
expect(uri).toContain('&relay=');
|
|
339
|
+
expect(uri).toContain('&name=My%20dApp');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('omits relay when not provided', () => {
|
|
343
|
+
const uri = buildPairingUri({ channelId: 'abcd', pubkeyB64: 'XY', name: 'Test', url: 'https://test.com', icon: 'https://test.com/icon.png' });
|
|
344
|
+
expect(uri).not.toContain('relay');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Valid test fixtures for parsePairingUri
|
|
349
|
+
const TEST_CH = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2';
|
|
350
|
+
const TEST_PUBKEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; // 32 zero-bytes base64url (43 chars)
|
|
351
|
+
|
|
352
|
+
describe('parsePairingUri', () => {
|
|
353
|
+
it('parses a full URI', () => {
|
|
354
|
+
const uri = `walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&relay=wss%3A%2F%2Frelay.example.com%2Fv1&name=Test&url=https%3A%2F%2Ftest.com&icon=https%3A%2F%2Ftest.com%2Ficon.png`;
|
|
355
|
+
const params = parsePairingUri(uri);
|
|
356
|
+
expect(params.ch).toBe(TEST_CH);
|
|
357
|
+
expect(params.pubkey).toBe(TEST_PUBKEY);
|
|
358
|
+
expect(params.relay).toBe('wss://relay.example.com/v1');
|
|
359
|
+
expect(params.name).toBe('Test');
|
|
360
|
+
expect(params.url).toBe('https://test.com');
|
|
361
|
+
expect(params.icon).toBe('https://test.com/icon.png');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('parses BLE URI (no relay)', () => {
|
|
365
|
+
const uri = `walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&name=BLE%20Wallet&url=https%3A%2F%2Fble.example.com&icon=https%3A%2F%2Fble.example.com%2Ficon.png`;
|
|
366
|
+
const params = parsePairingUri(uri);
|
|
367
|
+
expect(params.ch).toBe(TEST_CH);
|
|
368
|
+
expect(params.pubkey).toBe(TEST_PUBKEY);
|
|
369
|
+
expect(params.relay).toBeUndefined();
|
|
370
|
+
expect(params.name).toBe('BLE Wallet');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('throws on missing ch', () => {
|
|
374
|
+
expect(() => parsePairingUri(`walletpair:?pubkey=${TEST_PUBKEY}`)).toThrow('missing ch or pubkey');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('throws on missing pubkey', () => {
|
|
378
|
+
expect(() => parsePairingUri(`walletpair:?ch=${TEST_CH}`)).toThrow('missing ch or pubkey');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('throws on invalid ch length', () => {
|
|
382
|
+
expect(() => parsePairingUri(`walletpair:?ch=abc123&pubkey=${TEST_PUBKEY}`)).toThrow('64 lowercase hex');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('throws on invalid pubkey length', () => {
|
|
386
|
+
expect(() => parsePairingUri(`walletpair:?ch=${TEST_CH}&pubkey=AQID`)).toThrow('32 bytes');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('round-trips with buildPairingUri', () => {
|
|
390
|
+
const original = {
|
|
391
|
+
channelId: generateChannelId(),
|
|
392
|
+
pubkeyB64: b64urlEncode(generateX25519KeyPair().publicKey),
|
|
393
|
+
relayUrl: 'wss://relay.walletpair.org/v1',
|
|
394
|
+
name: 'Test dApp',
|
|
395
|
+
url: 'https://dapp.example.com',
|
|
396
|
+
icon: 'https://dapp.example.com/icon.png',
|
|
397
|
+
};
|
|
398
|
+
const uri = buildPairingUri(original);
|
|
399
|
+
const parsed = parsePairingUri(uri);
|
|
400
|
+
expect(parsed.ch).toBe(original.channelId);
|
|
401
|
+
expect(parsed.pubkey).toBe(original.pubkeyB64);
|
|
402
|
+
expect(parsed.relay).toBe(original.relayUrl);
|
|
403
|
+
expect(parsed.name).toBe(original.name);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// hex helpers
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Pairing URI — url & icon fields
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
describe('buildPairingUri / parsePairingUri with url and icon', () => {
|
|
416
|
+
it('round-trips url and icon through build→parse', () => {
|
|
417
|
+
const original = {
|
|
418
|
+
channelId: generateChannelId(),
|
|
419
|
+
pubkeyB64: b64urlEncode(generateX25519KeyPair().publicKey),
|
|
420
|
+
relayUrl: 'wss://relay.walletpair.org/v1',
|
|
421
|
+
name: 'My dApp',
|
|
422
|
+
url: 'https://mydapp.com',
|
|
423
|
+
icon: 'https://mydapp.com/logo.png',
|
|
424
|
+
};
|
|
425
|
+
const uri = buildPairingUri(original);
|
|
426
|
+
const parsed = parsePairingUri(uri);
|
|
427
|
+
|
|
428
|
+
expect(parsed.ch).toBe(original.channelId);
|
|
429
|
+
expect(parsed.pubkey).toBe(original.pubkeyB64);
|
|
430
|
+
expect(parsed.relay).toBe(original.relayUrl);
|
|
431
|
+
expect(parsed.name).toBe(original.name);
|
|
432
|
+
expect(parsed.url).toBe(original.url);
|
|
433
|
+
expect(parsed.icon).toBe(original.icon);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('round-trips url and icon containing special characters', () => {
|
|
437
|
+
const original = {
|
|
438
|
+
channelId: generateChannelId(),
|
|
439
|
+
pubkeyB64: b64urlEncode(generateX25519KeyPair().publicKey),
|
|
440
|
+
name: 'Test',
|
|
441
|
+
url: 'https://example.com/path?q=1&b=2',
|
|
442
|
+
icon: 'https://cdn.example.com/icons/logo.png?size=64&format=webp',
|
|
443
|
+
};
|
|
444
|
+
const uri = buildPairingUri(original);
|
|
445
|
+
const parsed = parsePairingUri(uri);
|
|
446
|
+
|
|
447
|
+
expect(parsed.url).toBe(original.url);
|
|
448
|
+
expect(parsed.icon).toBe(original.icon);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('parsePairingUri extracts url and icon from a raw URI string', () => {
|
|
452
|
+
const uri =
|
|
453
|
+
`walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&relay=wss%3A%2F%2Frelay.example.com%2Fv1&name=Test&url=https%3A%2F%2Fexample.com&icon=https%3A%2F%2Fexample.com%2Ficon.png`;
|
|
454
|
+
const parsed = parsePairingUri(uri);
|
|
455
|
+
|
|
456
|
+
expect(parsed.ch).toBe(TEST_CH);
|
|
457
|
+
expect(parsed.pubkey).toBe(TEST_PUBKEY);
|
|
458
|
+
expect(parsed.url).toBe('https://example.com');
|
|
459
|
+
expect(parsed.icon).toBe('https://example.com/icon.png');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// computeSessionFingerprint — additional edge cases
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
describe('computeSessionFingerprint edge cases', () => {
|
|
468
|
+
it('zero-padding: output is always exactly 4 characters regardless of numeric value', () => {
|
|
469
|
+
// Run many iterations; every result must be exactly 4 digits
|
|
470
|
+
for (let i = 0; i < 50; i++) {
|
|
471
|
+
const ch = generateChannelId();
|
|
472
|
+
const kp = generateX25519KeyPair();
|
|
473
|
+
const code = computeSessionFingerprint(ch, kp.publicKeyB64);
|
|
474
|
+
expect(code).toMatch(/^\d{4}$/);
|
|
475
|
+
expect(code).toHaveLength(4);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('different channel IDs with same pubkey produce different fingerprints', () => {
|
|
480
|
+
const kp = generateX25519KeyPair();
|
|
481
|
+
const results = new Set<string>();
|
|
482
|
+
for (let i = 0; i < 15; i++) {
|
|
483
|
+
results.add(computeSessionFingerprint(generateChannelId(), kp.publicKeyB64));
|
|
484
|
+
}
|
|
485
|
+
// With 15 random channel IDs, collisions in a 10000-space are possible but very unlikely for all
|
|
486
|
+
expect(results.size).toBeGreaterThan(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('different dApp pubkeys with same channel ID produce different fingerprints', () => {
|
|
490
|
+
const ch = generateChannelId();
|
|
491
|
+
const results = new Set<string>();
|
|
492
|
+
for (let i = 0; i < 15; i++) {
|
|
493
|
+
const kp = generateX25519KeyPair();
|
|
494
|
+
results.add(computeSessionFingerprint(ch, kp.publicKeyB64));
|
|
495
|
+
}
|
|
496
|
+
expect(results.size).toBeGreaterThan(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('same inputs always produce the same output (deterministic)', () => {
|
|
500
|
+
const ch = generateChannelId();
|
|
501
|
+
const kp = generateX25519KeyPair();
|
|
502
|
+
const first = computeSessionFingerprint(ch, kp.publicKeyB64);
|
|
503
|
+
for (let i = 0; i < 10; i++) {
|
|
504
|
+
expect(computeSessionFingerprint(ch, kp.publicKeyB64)).toBe(first);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Snapshot HMAC integrity
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
describe('signSnapshot / verifySnapshot', () => {
|
|
514
|
+
const key = new Uint8Array(32).fill(42);
|
|
515
|
+
const json = JSON.stringify({ channelId: 'abc', sendSeq: 5, recvSeq: 3 });
|
|
516
|
+
|
|
517
|
+
it('sign produces <64hex>.<json> format', () => {
|
|
518
|
+
const signed = signSnapshot(key, json);
|
|
519
|
+
expect(signed[64]).toBe('.');
|
|
520
|
+
expect(signed.slice(65)).toBe(json);
|
|
521
|
+
expect(signed.slice(0, 64)).toMatch(/^[0-9a-f]{64}$/);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('verify round-trips successfully', () => {
|
|
525
|
+
const signed = signSnapshot(key, json);
|
|
526
|
+
const result = verifySnapshot(key, signed);
|
|
527
|
+
expect(result).toBe(json);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('verify rejects tampered JSON', () => {
|
|
531
|
+
const signed = signSnapshot(key, json);
|
|
532
|
+
const tampered = signed.slice(0, 65) + '{"channelId":"EVIL","sendSeq":0,"recvSeq":-1}';
|
|
533
|
+
expect(verifySnapshot(key, tampered)).toBeNull();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('verify rejects tampered HMAC', () => {
|
|
537
|
+
const signed = signSnapshot(key, json);
|
|
538
|
+
const tampered = '00'.repeat(32) + signed.slice(64);
|
|
539
|
+
expect(verifySnapshot(key, tampered)).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('verify rejects wrong key', () => {
|
|
543
|
+
const signed = signSnapshot(key, json);
|
|
544
|
+
const wrongKey = new Uint8Array(32).fill(99);
|
|
545
|
+
expect(verifySnapshot(wrongKey, signed)).toBeNull();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('verify rejects malformed input (no dot)', () => {
|
|
549
|
+
expect(verifySnapshot(key, json)).toBeNull();
|
|
550
|
+
expect(verifySnapshot(key, '')).toBeNull();
|
|
551
|
+
expect(verifySnapshot(key, 'short')).toBeNull();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('different keys produce different MACs', () => {
|
|
555
|
+
const s1 = signSnapshot(key, json);
|
|
556
|
+
const s2 = signSnapshot(new Uint8Array(32).fill(99), json);
|
|
557
|
+
expect(s1.slice(0, 64)).not.toBe(s2.slice(0, 64));
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('deterministic — same key+json always same MAC', () => {
|
|
561
|
+
const s1 = signSnapshot(key, json);
|
|
562
|
+
const s2 = signSnapshot(key, json);
|
|
563
|
+
expect(s1).toBe(s2);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// hex helpers
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
describe('hex helpers', () => {
|
|
572
|
+
it('bytesToHex / hexToBytes round-trip', () => {
|
|
573
|
+
const bytes = new Uint8Array([0, 1, 15, 16, 255]);
|
|
574
|
+
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('bytesToHex produces lowercase', () => {
|
|
578
|
+
expect(bytesToHex(new Uint8Array([0xff, 0x0a]))).toBe('ff0a');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// parsePairingUri — required param validation (protocol compliance)
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
describe('parsePairingUri required params', () => {
|
|
587
|
+
it('throws on missing required name param', () => {
|
|
588
|
+
const uri = `walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&url=https%3A%2F%2Fexample.com&icon=https%3A%2F%2Fexample.com%2Ficon.png`;
|
|
589
|
+
expect(() => parsePairingUri(uri)).toThrow('missing required param "name"');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('throws on missing required url param', () => {
|
|
593
|
+
const uri = `walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&name=Test&icon=https%3A%2F%2Fexample.com%2Ficon.png`;
|
|
594
|
+
expect(() => parsePairingUri(uri)).toThrow('missing required param "url"');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('throws on missing required icon param', () => {
|
|
598
|
+
const uri = `walletpair:?ch=${TEST_CH}&pubkey=${TEST_PUBKEY}&name=Test&url=https%3A%2F%2Fexample.com`;
|
|
599
|
+
expect(() => parsePairingUri(uri)).toThrow('missing required param "icon"');
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// constantTimeEqual
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
describe('constantTimeEqual', () => {
|
|
608
|
+
it('returns true for identical strings', () => {
|
|
609
|
+
expect(constantTimeEqual('abc', 'abc')).toBe(true);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('returns false for different strings', () => {
|
|
613
|
+
expect(constantTimeEqual('abc', 'abd')).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('returns false for different lengths', () => {
|
|
617
|
+
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('returns true for empty strings', () => {
|
|
621
|
+
expect(constantTimeEqual('', '')).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// canonicalJson — spec test vector
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
describe('canonicalJson', () => {
|
|
630
|
+
it('matches spec test vector', () => {
|
|
631
|
+
const input = {"methods":["wallet_signTransaction","wallet_signMessage"],"events":["accountsChanged","chainChanged"],"chains":["eip155:1","eip155:137"]};
|
|
632
|
+
const expected = '{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]}';
|
|
633
|
+
expect(canonicalJson(input)).toBe(expected);
|
|
634
|
+
});
|
|
635
|
+
});
|