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
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletPair Protocol v1 — crypto & protocol helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure JS (noble libraries v2), no native modules required.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import _canonicalize from 'canonicalize';
|
|
8
|
+
const canonicalize = (_canonicalize as { default: typeof _canonicalize.default }).default ?? _canonicalize.default;
|
|
9
|
+
import { x25519 } from '@noble/curves/ed25519';
|
|
10
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
12
|
+
import { hmac } from '@noble/hashes/hmac';
|
|
13
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha';
|
|
14
|
+
import {
|
|
15
|
+
bytesToHex,
|
|
16
|
+
hexToBytes,
|
|
17
|
+
utf8ToBytes,
|
|
18
|
+
concatBytes,
|
|
19
|
+
} from '@noble/hashes/utils';
|
|
20
|
+
|
|
21
|
+
import type { PairingParams } from './types.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Re-exports
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export { bytesToHex, hexToBytes };
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Base64url (no padding)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export function b64urlEncode(bytes: Uint8Array): string {
|
|
34
|
+
let binary = '';
|
|
35
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
36
|
+
return btoa(binary)
|
|
37
|
+
.replace(/\+/g, '-')
|
|
38
|
+
.replace(/\//g, '_')
|
|
39
|
+
.replace(/=/g, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function b64urlDecode(str: string): Uint8Array {
|
|
43
|
+
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
44
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
45
|
+
const binary = atob(padded);
|
|
46
|
+
const bytes = new Uint8Array(binary.length);
|
|
47
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
48
|
+
return bytes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Key generation
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export interface X25519KeyPair {
|
|
56
|
+
privateKey: Uint8Array;
|
|
57
|
+
publicKey: Uint8Array;
|
|
58
|
+
publicKeyB64: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function generateX25519KeyPair(): X25519KeyPair {
|
|
62
|
+
const privateKey = x25519.utils.randomPrivateKey();
|
|
63
|
+
const publicKey = x25519.getPublicKey(privateKey);
|
|
64
|
+
return { privateKey, publicKey, publicKeyB64: b64urlEncode(publicKey) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getPublicKey(privateKey: Uint8Array): Uint8Array {
|
|
68
|
+
return x25519.getPublicKey(privateKey);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Root and directional key derivation (protocol Section 7.2)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export function computeSharedSecret(
|
|
76
|
+
myPrivateKey: Uint8Array,
|
|
77
|
+
remotePubKey: Uint8Array,
|
|
78
|
+
): Uint8Array {
|
|
79
|
+
if (remotePubKey.length !== 32) {
|
|
80
|
+
throw new Error('Remote public key must be exactly 32 bytes');
|
|
81
|
+
}
|
|
82
|
+
const shared = x25519.getSharedSecret(myPrivateKey, remotePubKey);
|
|
83
|
+
// RFC 7748 §6: low-order points produce all-zero output — reject to
|
|
84
|
+
// prevent invalid key derivation.
|
|
85
|
+
let acc = 0;
|
|
86
|
+
for (let i = 0; i < shared.length; i++) acc |= shared[i]!;
|
|
87
|
+
if (acc === 0) {
|
|
88
|
+
throw new Error('X25519 produced all-zero shared secret (low-order public key)');
|
|
89
|
+
}
|
|
90
|
+
return shared;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function deriveSessionKey(
|
|
94
|
+
sharedSecret: Uint8Array,
|
|
95
|
+
channelIdHex: string,
|
|
96
|
+
): Uint8Array {
|
|
97
|
+
return hkdf(sha256, sharedSecret, hexToBytes(channelIdHex), 'walletpair-v1 root', 32);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SessionCryptoContext {
|
|
101
|
+
dappPubKeyB64: string;
|
|
102
|
+
walletPubKeyB64: string;
|
|
103
|
+
capabilities?: unknown;
|
|
104
|
+
walletMeta?: unknown;
|
|
105
|
+
dappName?: string | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DirectionalSessionKeys {
|
|
109
|
+
rootKey: Uint8Array;
|
|
110
|
+
dappToWalletKey: Uint8Array;
|
|
111
|
+
walletToDappKey: Uint8Array;
|
|
112
|
+
transcriptHash: Uint8Array;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function canonicalJson(value: unknown): string {
|
|
116
|
+
return canonicalize(value) ?? 'null';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function computeHandshakeTranscriptHash(
|
|
120
|
+
channelIdHex: string,
|
|
121
|
+
context: SessionCryptoContext,
|
|
122
|
+
): Uint8Array {
|
|
123
|
+
return sha256(concatBytes(
|
|
124
|
+
utf8ToBytes('walletpair-v1-transcript'),
|
|
125
|
+
hexToBytes(channelIdHex),
|
|
126
|
+
lp(context.dappPubKeyB64),
|
|
127
|
+
lp(context.walletPubKeyB64),
|
|
128
|
+
lp(canonicalJson(context.capabilities ?? null)),
|
|
129
|
+
lp(canonicalJson(context.walletMeta ?? null)),
|
|
130
|
+
lp(context.dappName ?? ''),
|
|
131
|
+
));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function deriveDirectionalSessionKeys(
|
|
135
|
+
rootKey: Uint8Array,
|
|
136
|
+
channelIdHex: string,
|
|
137
|
+
context: SessionCryptoContext,
|
|
138
|
+
): DirectionalSessionKeys {
|
|
139
|
+
const transcriptHash = computeHandshakeTranscriptHash(channelIdHex, context);
|
|
140
|
+
return {
|
|
141
|
+
rootKey,
|
|
142
|
+
transcriptHash,
|
|
143
|
+
dappToWalletKey: hkdf(sha256, rootKey, transcriptHash, 'walletpair-v1 dapp-to-wallet', 32),
|
|
144
|
+
walletToDappKey: hkdf(sha256, rootKey, transcriptHash, 'walletpair-v1 wallet-to-dapp', 32),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Session fingerprint (protocol Section 7.3)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export function computeSessionFingerprint(
|
|
153
|
+
channelIdHex: string,
|
|
154
|
+
dappPubKeyB64: string,
|
|
155
|
+
): string {
|
|
156
|
+
const hash = sha256(concatBytes(
|
|
157
|
+
utf8ToBytes('walletpair-v1-session-fingerprint'),
|
|
158
|
+
hexToBytes(channelIdHex),
|
|
159
|
+
b64urlDecode(dappPubKeyB64),
|
|
160
|
+
));
|
|
161
|
+
const view = new DataView(hash.buffer, hash.byteOffset, 4);
|
|
162
|
+
return (view.getUint32(0) % 10000).toString().padStart(4, '0');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Join encryption key (protocol Section 7.5 — private handshake)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export function deriveJoinEncryptionKey(
|
|
170
|
+
rootKey: Uint8Array,
|
|
171
|
+
channelIdHex: string,
|
|
172
|
+
): Uint8Array {
|
|
173
|
+
return hkdf(sha256, rootKey, hexToBytes(channelIdHex), 'walletpair-v1 join-encryption', 32);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Encrypt capabilities + meta for private handshake (§7.5).
|
|
178
|
+
* Returns base64url(nonce || ciphertext || tag).
|
|
179
|
+
*/
|
|
180
|
+
export function sealJoin(
|
|
181
|
+
joinEncryptionKey: Uint8Array,
|
|
182
|
+
channelIdHex: string,
|
|
183
|
+
capabilities: unknown,
|
|
184
|
+
meta?: unknown,
|
|
185
|
+
): string {
|
|
186
|
+
const plainObj: Record<string, unknown> = { capabilities, meta: meta ?? null };
|
|
187
|
+
const plaintext = utf8ToBytes(canonicalJson(plainObj));
|
|
188
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
189
|
+
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]));
|
|
190
|
+
const ciphertext = chacha20poly1305(joinEncryptionKey, nonce, aad).encrypt(plaintext);
|
|
191
|
+
return b64urlEncode(concatBytes(nonce, ciphertext));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decrypt sealed_join from a private handshake join message (§7.5).
|
|
196
|
+
* Returns { capabilities, meta }.
|
|
197
|
+
*/
|
|
198
|
+
export function unsealJoin(
|
|
199
|
+
joinEncryptionKey: Uint8Array,
|
|
200
|
+
channelIdHex: string,
|
|
201
|
+
sealedJoin: string,
|
|
202
|
+
): { capabilities: unknown; meta?: unknown } {
|
|
203
|
+
const envelope = b64urlDecode(sealedJoin);
|
|
204
|
+
if (envelope.length < 12 + 16) {
|
|
205
|
+
throw new Error('Invalid sealed_join envelope');
|
|
206
|
+
}
|
|
207
|
+
const nonce = envelope.slice(0, 12);
|
|
208
|
+
const ciphertext = envelope.slice(12);
|
|
209
|
+
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]));
|
|
210
|
+
const plaintext = chacha20poly1305(joinEncryptionKey, nonce, aad).decrypt(ciphertext);
|
|
211
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Encrypt / Decrypt (protocol Section 7.4)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* AAD header for authenticated encryption.
|
|
220
|
+
* Uses length-prefixed binary encoding per protocol §7.4.
|
|
221
|
+
*/
|
|
222
|
+
export type AadHeader =
|
|
223
|
+
| { type: 'req'; from: string; id: string }
|
|
224
|
+
| { type: 'res'; from: string; id: string }
|
|
225
|
+
| { type: 'evt'; from: string; id: string };
|
|
226
|
+
|
|
227
|
+
/** Length-prefix a UTF-8 string: uint16_be(byte_length) || utf8_bytes */
|
|
228
|
+
function lp(s: string): Uint8Array {
|
|
229
|
+
const bytes = utf8ToBytes(s);
|
|
230
|
+
if (bytes.length > 0xffff) {
|
|
231
|
+
throw new Error('AAD field exceeds 65535 bytes');
|
|
232
|
+
}
|
|
233
|
+
const len = new Uint8Array(2);
|
|
234
|
+
new DataView(len.buffer).setUint16(0, bytes.length);
|
|
235
|
+
return concatBytes(len, bytes);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build AEAD AAD = channel_id_bytes || type_byte || lp(fields...)
|
|
240
|
+
*/
|
|
241
|
+
function buildAad(channelIdHex: string, header?: AadHeader): Uint8Array {
|
|
242
|
+
const chBytes = hexToBytes(channelIdHex);
|
|
243
|
+
if (!header) return chBytes;
|
|
244
|
+
switch (header.type) {
|
|
245
|
+
case 'req':
|
|
246
|
+
return concatBytes(chBytes, new Uint8Array([0x01]), lp(header.from), lp(header.id));
|
|
247
|
+
case 'res':
|
|
248
|
+
return concatBytes(chBytes, new Uint8Array([0x02]), lp(header.from), lp(header.id));
|
|
249
|
+
case 'evt':
|
|
250
|
+
return concatBytes(chBytes, new Uint8Array([0x03]), lp(header.from), lp(header.id));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function sealPayload(
|
|
255
|
+
encryptionKey: Uint8Array,
|
|
256
|
+
channelIdHex: string,
|
|
257
|
+
seq: number,
|
|
258
|
+
data: unknown,
|
|
259
|
+
header?: AadHeader,
|
|
260
|
+
): string {
|
|
261
|
+
const seqBytes = new Uint8Array(4);
|
|
262
|
+
new DataView(seqBytes.buffer).setUint32(0, seq);
|
|
263
|
+
const nonce = hmac(sha256, encryptionKey, seqBytes).slice(0, 12);
|
|
264
|
+
const plaintext = utf8ToBytes(canonicalJson(data));
|
|
265
|
+
const aad = buildAad(channelIdHex, header);
|
|
266
|
+
const ciphertext = chacha20poly1305(encryptionKey, nonce, aad).encrypt(plaintext);
|
|
267
|
+
return b64urlEncode(concatBytes(seqBytes, ciphertext));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function unsealPayload(
|
|
271
|
+
encryptionKey: Uint8Array,
|
|
272
|
+
channelIdHex: string,
|
|
273
|
+
sealed: string,
|
|
274
|
+
header?: AadHeader,
|
|
275
|
+
): { seq: number; data: unknown; plaintext: Uint8Array; plaintextJson: string } {
|
|
276
|
+
const bytes = b64urlDecode(sealed);
|
|
277
|
+
const seqBytes = bytes.slice(0, 4);
|
|
278
|
+
const ciphertext = bytes.slice(4);
|
|
279
|
+
const nonce = hmac(sha256, encryptionKey, seqBytes).slice(0, 12);
|
|
280
|
+
const aad = buildAad(channelIdHex, header);
|
|
281
|
+
const plaintext = chacha20poly1305(encryptionKey, nonce, aad).decrypt(ciphertext);
|
|
282
|
+
const seq = new DataView(seqBytes.buffer, seqBytes.byteOffset, 4).getUint32(0);
|
|
283
|
+
const plaintextJson = new TextDecoder().decode(plaintext);
|
|
284
|
+
return { seq, data: JSON.parse(plaintextJson), plaintext, plaintextJson };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function sha256Hex(bytes: Uint8Array): string {
|
|
288
|
+
return bytesToHex(sha256(bytes));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Constant-time string comparison to prevent timing side-channels (§9.1). */
|
|
292
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
293
|
+
if (a.length !== b.length) return false;
|
|
294
|
+
let result = 0;
|
|
295
|
+
for (let i = 0; i < a.length; i++) {
|
|
296
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
297
|
+
}
|
|
298
|
+
return result === 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Snapshot integrity (HMAC for serialized session state)
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
const SNAPSHOT_HMAC_INFO = utf8ToBytes('walletpair-v1-snapshot-hmac');
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Derive a dedicated HMAC key from the send key so we don't reuse
|
|
309
|
+
* traffic keys for a different purpose.
|
|
310
|
+
*/
|
|
311
|
+
function deriveSnapshotHmacKey(sendKey: Uint8Array): Uint8Array {
|
|
312
|
+
return hmac(sha256, sendKey, SNAPSHOT_HMAC_INFO);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Sign a serialized session snapshot with HMAC-SHA256.
|
|
317
|
+
* Returns `<hex-mac>.<json-payload>`.
|
|
318
|
+
*/
|
|
319
|
+
export function signSnapshot(sendKey: Uint8Array, json: string): string {
|
|
320
|
+
const macKey = deriveSnapshotHmacKey(sendKey);
|
|
321
|
+
const mac = hmac(sha256, macKey, utf8ToBytes(json));
|
|
322
|
+
return bytesToHex(mac) + '.' + json;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Verify and extract a signed snapshot.
|
|
327
|
+
* Returns the JSON payload on success, or `null` if the HMAC is invalid.
|
|
328
|
+
*/
|
|
329
|
+
export function verifySnapshot(sendKey: Uint8Array, signed: string): string | null {
|
|
330
|
+
const dot = signed.indexOf('.');
|
|
331
|
+
if (dot !== 64) return null; // HMAC-SHA256 hex = 64 chars
|
|
332
|
+
const macHex = signed.slice(0, 64);
|
|
333
|
+
const json = signed.slice(65);
|
|
334
|
+
const macKey = deriveSnapshotHmacKey(sendKey);
|
|
335
|
+
const expected = bytesToHex(hmac(sha256, macKey, utf8ToBytes(json)));
|
|
336
|
+
return constantTimeEqual(macHex, expected) ? json : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Channel ID generation
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export function generateChannelId(): string {
|
|
344
|
+
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Pairing URI
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
export function buildPairingUri(params: {
|
|
352
|
+
channelId: string;
|
|
353
|
+
pubkeyB64: string;
|
|
354
|
+
relayUrl?: string | undefined;
|
|
355
|
+
name: string;
|
|
356
|
+
url: string;
|
|
357
|
+
icon: string;
|
|
358
|
+
/** Methods the dApp intends to call (§9.1). */
|
|
359
|
+
methods?: string[] | undefined;
|
|
360
|
+
/** CAIP-2 chains the dApp intends to use (§9.1). */
|
|
361
|
+
chains?: string[] | undefined;
|
|
362
|
+
}): string {
|
|
363
|
+
let uri = `walletpair:?ch=${params.channelId}&pubkey=${params.pubkeyB64}`;
|
|
364
|
+
if (params.relayUrl) uri += `&relay=${encodeURIComponent(params.relayUrl)}`;
|
|
365
|
+
uri += `&name=${encodeURIComponent(params.name)}`;
|
|
366
|
+
uri += `&url=${encodeURIComponent(params.url)}`;
|
|
367
|
+
uri += `&icon=${encodeURIComponent(params.icon)}`;
|
|
368
|
+
if (params.methods?.length) uri += `&methods=${params.methods.join(',')}`;
|
|
369
|
+
if (params.chains?.length) uri += `&chains=${params.chains.join(',')}`;
|
|
370
|
+
return uri;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function parsePairingUri(uri: string): PairingParams {
|
|
374
|
+
const qs = uri.replace(/^walletpair:\?/, '');
|
|
375
|
+
const params = new URLSearchParams(qs);
|
|
376
|
+
const ch = params.get('ch');
|
|
377
|
+
const pubkey = params.get('pubkey');
|
|
378
|
+
if (!ch || !pubkey) throw new Error('Invalid pairing URI: missing ch or pubkey');
|
|
379
|
+
// §8.1: ch must be 64 hex characters (32 bytes)
|
|
380
|
+
if (!/^[0-9a-f]{64}$/.test(ch)) throw new Error('Invalid pairing URI: ch must be 64 lowercase hex chars');
|
|
381
|
+
// §8.1: pubkey must decode to 32 bytes
|
|
382
|
+
const pubkeyBytes = b64urlDecode(pubkey);
|
|
383
|
+
if (pubkeyBytes.length !== 32) throw new Error('Invalid pairing URI: pubkey must be 32 bytes');
|
|
384
|
+
// §8.1: name, url, icon are required
|
|
385
|
+
const name = params.get('name');
|
|
386
|
+
const url = params.get('url');
|
|
387
|
+
const icon = params.get('icon');
|
|
388
|
+
if (!name) throw new Error('Invalid pairing URI: missing required param "name"');
|
|
389
|
+
if (!url) throw new Error('Invalid pairing URI: missing required param "url"');
|
|
390
|
+
if (!icon) throw new Error('Invalid pairing URI: missing required param "icon"');
|
|
391
|
+
// §8.1: icon MUST be https:
|
|
392
|
+
if (!icon.startsWith('https:')) throw new Error('Invalid pairing URI: icon must use https:');
|
|
393
|
+
const methodsStr = params.get('methods');
|
|
394
|
+
const chainsStr = params.get('chains');
|
|
395
|
+
return {
|
|
396
|
+
ch,
|
|
397
|
+
pubkey,
|
|
398
|
+
relay: params.get('relay') || undefined,
|
|
399
|
+
name,
|
|
400
|
+
url,
|
|
401
|
+
icon,
|
|
402
|
+
methods: methodsStr ? methodsStr.split(',').filter(Boolean) : undefined,
|
|
403
|
+
chains: chainsStr ? chainsStr.split(',').filter(Boolean) : undefined,
|
|
404
|
+
};
|
|
405
|
+
}
|