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