@qrlwallet/connect 0.1.0 → 2.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/dist/index.js CHANGED
@@ -30,18 +30,26 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BLOB_LEN: () => BLOB_LEN,
34
+ CID_LEN: () => CID_LEN,
33
35
  ConnectionManager: () => ConnectionManager,
34
36
  ConnectionStatus: () => ConnectionStatus,
35
37
  DEFAULT_RELAY_URL: () => DEFAULT_RELAY_URL,
36
- ECIESClient: () => ECIESClient,
38
+ FP_LEN: () => FP_LEN,
37
39
  KeyExchange: () => KeyExchange,
38
40
  KeyExchangeMessageType: () => KeyExchangeMessageType,
39
41
  MessageType: () => MessageType,
42
+ PROTOCOL_VERSION: () => PROTOCOL_VERSION,
40
43
  QRLConnect: () => QRLConnectProvider,
41
44
  QRLConnectProvider: () => QRLConnectProvider,
42
45
  RESTRICTED_METHODS: () => RESTRICTED_METHODS,
43
46
  SocketClient: () => SocketClient,
44
47
  UNRESTRICTED_METHODS: () => UNRESTRICTED_METHODS,
48
+ cidFromString: () => cidFromString,
49
+ cidRandom: () => cidRandom,
50
+ cidToString: () => cidToString,
51
+ computeFingerprint: () => computeFingerprint,
52
+ fingerprintEquals: () => fingerprintEquals,
45
53
  generateConnectionURI: () => generateConnectionURI,
46
54
  getAppStoreUrl: () => getAppStoreUrl,
47
55
  isMobileBrowser: () => isMobileBrowser,
@@ -56,81 +64,142 @@ var import_eventemitter34 = __toESM(require("eventemitter3"));
56
64
  var import_eventemitter33 = __toESM(require("eventemitter3"));
57
65
  var import_uuid = require("uuid");
58
66
 
59
- // src/ECIESClient.ts
60
- var import_eciesjs = require("eciesjs");
67
+ // src/KeyExchange.ts
68
+ var import_eventemitter3 = __toESM(require("eventemitter3"));
61
69
 
62
- // src/utils/logger.ts
63
- var debugEnabled = false;
64
- function setDebug(enabled) {
65
- debugEnabled = enabled;
70
+ // src/PQCrypto.ts
71
+ var import_ml_kem = require("@noble/post-quantum/ml-kem.js");
72
+ var textEncoder = new TextEncoder();
73
+ var LABEL = textEncoder.encode("pq-pair/v1");
74
+ var LABEL_AEAD_SUFFIX = textEncoder.encode(" aead");
75
+ var DIR_DAPP_TX = new Uint8Array([0, 0, 0, 1]);
76
+ var DIR_WALLET_TX = new Uint8Array([0, 0, 0, 2]);
77
+ var ML_KEM_768_CT_LEN = 1088;
78
+ var AEAD_KEY_LEN = 32;
79
+ function subtle() {
80
+ const c = globalThis.crypto;
81
+ if (!c || !c.subtle) {
82
+ throw new Error("PQCrypto: WebCrypto SubtleCrypto is not available in this environment");
83
+ }
84
+ return c.subtle;
66
85
  }
67
- function log(tag, ...args) {
68
- if (debugEnabled) {
69
- console.log(`[QRLConnect:${tag}]`, ...args);
70
- }
86
+ function bs(u) {
87
+ return u;
71
88
  }
72
- function warn(tag, ...args) {
73
- console.warn(`[QRLConnect:${tag}]`, ...args);
89
+ function kemKeygen() {
90
+ const { publicKey, secretKey } = import_ml_kem.ml_kem768.keygen();
91
+ return { pk: publicKey, sk: secretKey };
74
92
  }
75
- function error(tag, ...args) {
76
- console.error(`[QRLConnect:${tag}]`, ...args);
93
+ function kemEncaps(pk) {
94
+ const { cipherText, sharedSecret } = import_ml_kem.ml_kem768.encapsulate(pk);
95
+ return { ct: cipherText, ss: sharedSecret };
96
+ }
97
+ function kemDecaps(sk, ct) {
98
+ return import_ml_kem.ml_kem768.decapsulate(ct, sk);
99
+ }
100
+ async function transcriptHash(cid, pk, ct) {
101
+ const buf = concat(LABEL, cid, pk, ct);
102
+ return new Uint8Array(await subtle().digest("SHA-256", bs(buf)));
103
+ }
104
+ async function deriveAeadKey(ss, htx) {
105
+ const ikm = await subtle().importKey("raw", bs(ss), "HKDF", false, ["deriveKey"]);
106
+ const info = concat(LABEL, LABEL_AEAD_SUFFIX, htx);
107
+ return subtle().deriveKey(
108
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: bs(info) },
109
+ ikm,
110
+ { name: "AES-GCM", length: 256 },
111
+ true,
112
+ ["encrypt", "decrypt"]
113
+ );
114
+ }
115
+ async function importRawAeadKey(raw) {
116
+ if (raw.length !== AEAD_KEY_LEN) {
117
+ throw new Error(`PQCrypto: raw AEAD key must be ${AEAD_KEY_LEN} bytes`);
118
+ }
119
+ return subtle().importKey("raw", bs(raw), { name: "AES-GCM", length: 256 }, true, [
120
+ "encrypt",
121
+ "decrypt"
122
+ ]);
123
+ }
124
+ async function exportRawAeadKey(key) {
125
+ const buf = await subtle().exportKey("raw", key);
126
+ return new Uint8Array(buf);
127
+ }
128
+ function nonce(dir, seq) {
129
+ if (dir.length !== 4) {
130
+ throw new Error("PQCrypto: direction tag must be 4 bytes");
131
+ }
132
+ const n = new Uint8Array(12);
133
+ n.set(dir, 0);
134
+ new DataView(n.buffer, n.byteOffset, n.byteLength).setBigUint64(4, BigInt(seq), true);
135
+ return n;
136
+ }
137
+ function aad(htx, seq) {
138
+ const out = new Uint8Array(htx.length + 8);
139
+ out.set(htx, 0);
140
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setBigUint64(
141
+ htx.length,
142
+ BigInt(seq),
143
+ true
144
+ );
145
+ return out;
146
+ }
147
+ async function seal(key, dir, seq, htx, pt) {
148
+ const ct = await subtle().encrypt(
149
+ { name: "AES-GCM", iv: bs(nonce(dir, seq)), additionalData: bs(aad(htx, seq)) },
150
+ key,
151
+ bs(pt)
152
+ );
153
+ return new Uint8Array(ct);
154
+ }
155
+ async function open(key, dir, seq, htx, ct) {
156
+ const pt = await subtle().decrypt(
157
+ { name: "AES-GCM", iv: bs(nonce(dir, seq)), additionalData: bs(aad(htx, seq)) },
158
+ key,
159
+ bs(ct)
160
+ );
161
+ return new Uint8Array(pt);
162
+ }
163
+ function zeroize(b) {
164
+ b.fill(0);
165
+ }
166
+ function concat(...parts) {
167
+ let total = 0;
168
+ for (const p of parts) total += p.length;
169
+ const out = new Uint8Array(total);
170
+ let offset = 0;
171
+ for (const p of parts) {
172
+ out.set(p, offset);
173
+ offset += p.length;
174
+ }
175
+ return out;
77
176
  }
78
-
79
- // src/ECIESClient.ts
80
177
  function toBase64(bytes) {
81
- let binary = "";
82
- for (let i = 0; i < bytes.length; i++) {
83
- binary += String.fromCharCode(bytes[i]);
178
+ const CHUNK = 32768;
179
+ let bin = "";
180
+ for (let i = 0; i < bytes.length; i += CHUNK) {
181
+ const slice = bytes.subarray(i, i + CHUNK);
182
+ bin += String.fromCharCode.apply(null, slice);
84
183
  }
85
- return btoa(binary);
184
+ return btoa(bin);
86
185
  }
87
- function fromBase64(base64) {
88
- const binary = atob(base64);
89
- const bytes = new Uint8Array(binary.length);
90
- for (let i = 0; i < binary.length; i++) {
91
- bytes[i] = binary.charCodeAt(i);
92
- }
93
- return bytes;
186
+ function fromBase64(b64) {
187
+ const bin = atob(b64);
188
+ const out = new Uint8Array(bin.length);
189
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
190
+ return out;
191
+ }
192
+ function constantTimeEquals(a, b) {
193
+ if (a.length !== b.length) return false;
194
+ let d = 0;
195
+ for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
196
+ return d === 0;
94
197
  }
95
- var ECIESClient = class {
96
- constructor(existingPrivateKey) {
97
- if (existingPrivateKey) {
98
- this.privateKey = import_eciesjs.PrivateKey.fromHex(existingPrivateKey);
99
- log("ECIES", "Restored from existing private key");
100
- } else {
101
- this.privateKey = new import_eciesjs.PrivateKey();
102
- log("ECIES", "Generated new ECIES keypair");
103
- }
104
- }
105
- /** Get public key as hex string */
106
- getPublicKey() {
107
- return this.privateKey.publicKey.toHex();
108
- }
109
- /** Get private key as hex string (for session persistence) */
110
- getPrivateKeyHex() {
111
- return this.privateKey.toHex();
112
- }
113
- /** Encrypt a string message for the given public key. Returns base64. */
114
- encrypt(data, otherPublicKey) {
115
- const encoded = new TextEncoder().encode(data);
116
- const encrypted = (0, import_eciesjs.encrypt)(otherPublicKey, encoded);
117
- return toBase64(encrypted);
118
- }
119
- /** Decrypt a base64-encoded message using our private key. */
120
- decrypt(encryptedBase64) {
121
- const encryptedBytes = fromBase64(encryptedBase64);
122
- const decrypted = (0, import_eciesjs.decrypt)(this.privateKey.toHex(), encryptedBytes);
123
- return new TextDecoder().decode(decrypted);
124
- }
125
- };
126
-
127
- // src/KeyExchange.ts
128
- var import_eventemitter3 = __toESM(require("eventemitter3"));
129
198
 
130
199
  // src/config.ts
131
200
  var DEFAULT_RELAY_URL = "https://qrlwallet.com";
132
201
  var RELAY_PATH = "/relay";
133
- var PROTOCOL_VERSION = 1;
202
+ var PROTOCOL_VERSION = 2;
134
203
  var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
135
204
  var STORAGE_KEY_PREFIX = "@qrlwallet/connect";
136
205
  var REQUEST_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -216,149 +285,292 @@ var ConnectionStatus = /* @__PURE__ */ ((ConnectionStatus2) => {
216
285
  return ConnectionStatus2;
217
286
  })(ConnectionStatus || {});
218
287
 
288
+ // src/utils/logger.ts
289
+ var debugEnabled = false;
290
+ function setDebug(enabled) {
291
+ debugEnabled = enabled;
292
+ }
293
+ function log(tag, ...args) {
294
+ if (debugEnabled) {
295
+ console.log(`[QRLConnect:${tag}]`, ...args);
296
+ }
297
+ }
298
+ function warn(tag, ...args) {
299
+ console.warn(`[QRLConnect:${tag}]`, ...args);
300
+ }
301
+ function error(tag, ...args) {
302
+ console.error(`[QRLConnect:${tag}]`, ...args);
303
+ }
304
+
219
305
  // src/KeyExchange.ts
306
+ var textEncoder2 = new TextEncoder();
307
+ var textDecoder = new TextDecoder();
308
+ var HELLO_WALLET = textEncoder2.encode("hello/wallet/v1");
309
+ var HELLO_DAPP = textEncoder2.encode("hello/dapp/v1");
220
310
  var KeyExchange = class extends import_eventemitter3.default {
221
- constructor(ecies, isOriginator, otherPublicKey) {
311
+ constructor(isOriginator, restored) {
222
312
  super();
223
- this.otherPublicKey = null;
224
- this.keysExchanged = false;
313
+ this.keypair = null;
314
+ this.session = null;
315
+ this.step = "key_handshake_SYN" /* SYN */;
225
316
  this.awaitingSynAck = false;
226
317
  this.awaitingAck = false;
227
- this.step = "key_handshake_SYN" /* SYN */;
228
- this.ecies = ecies;
318
+ this.keysExchanged = false;
229
319
  this.isOriginator = isOriginator;
230
- if (otherPublicKey) {
231
- this.otherPublicKey = otherPublicKey;
320
+ if (restored) {
321
+ this.session = restored;
232
322
  this.keysExchanged = true;
233
- log("KeyExchange", "Restored with existing other public key");
323
+ this.step = "key_handshake_ACK" /* ACK */;
324
+ log("KeyExchange", "Hydrated from persisted session");
234
325
  }
235
326
  }
236
- /** Get the SYN message to start the handshake (originator/dApp side). */
237
- createSYN() {
327
+ /**
328
+ * Originator (dApp) side: begin a fresh handshake by generating an
329
+ * ephemeral ML-KEM-768 keypair. Returns pk to embed in the QR URI.
330
+ */
331
+ initiate() {
332
+ if (!this.isOriginator) {
333
+ throw new Error("KeyExchange: responder cannot initiate");
334
+ }
335
+ this.resetInternal(false);
336
+ this.keypair = kemKeygen();
238
337
  this.step = "key_handshake_SYN" /* SYN */;
239
338
  this.awaitingSynAck = true;
240
339
  this.emit("step_change", this.step);
340
+ return this.keypair.pk;
341
+ }
342
+ /**
343
+ * Originator: handle a SYNACK received over the relay.
344
+ * Returns an ACK wire message to send, or null if the SYNACK was a
345
+ * duplicate (handshake already complete).
346
+ */
347
+ async onSynAck(cid, msg) {
348
+ if (!this.isOriginator) {
349
+ warn("KeyExchange", "Responder received SYNACK \u2014 ignoring");
350
+ return null;
351
+ }
352
+ if (!this.awaitingSynAck || !this.keypair) {
353
+ return null;
354
+ }
355
+ this.awaitingSynAck = false;
356
+ const ct = fromBase64(msg.ct);
357
+ const c0 = fromBase64(msg.c0);
358
+ if (ct.length !== ML_KEM_768_CT_LEN) {
359
+ throw new Error(`KeyExchange: bad ct length ${ct.length}`);
360
+ }
361
+ const ss = kemDecaps(this.keypair.sk, ct);
362
+ const htx = await transcriptHash(cid, this.keypair.pk, ct);
363
+ const key = await deriveAeadKey(ss, htx);
364
+ let hello;
365
+ try {
366
+ hello = await open(key, DIR_WALLET_TX, 0, htx, c0);
367
+ } catch {
368
+ throw new Error(
369
+ "KeyExchange: wallet hello AEAD tag failed (tampered SYNACK, wrong QR, or protocol mismatch)"
370
+ );
371
+ }
372
+ if (!constantTimeEquals(hello, HELLO_WALLET)) {
373
+ throw new Error("KeyExchange: wallet hello mismatch");
374
+ }
375
+ zeroize(ss);
376
+ zeroize(this.keypair.sk);
377
+ this.keypair = null;
378
+ const c1 = await seal(key, DIR_DAPP_TX, 0, htx, HELLO_DAPP);
379
+ this.session = {
380
+ cid,
381
+ key,
382
+ htx,
383
+ sendDir: DIR_DAPP_TX,
384
+ recvDir: DIR_WALLET_TX,
385
+ sendSeq: 1,
386
+ recvSeq: 1
387
+ };
388
+ this.keysExchanged = true;
389
+ this.step = "key_handshake_ACK" /* ACK */;
390
+ this.emit("keys_exchanged");
391
+ this.emit("step_change", this.step);
241
392
  return {
242
- type: "key_handshake_SYN" /* SYN */,
243
- pubkey: this.ecies.getPublicKey(),
393
+ type: "key_handshake_ACK" /* ACK */,
394
+ c1: toBase64(c1),
244
395
  v: PROTOCOL_VERSION
245
396
  };
246
397
  }
247
398
  /**
248
- * Process an incoming key exchange message.
249
- * Returns a response message to send back, or null if handshake is complete.
399
+ * Responder (wallet) side: begin handshake from a scanned QR.
400
+ * Returns the SYNACK wire message to emit on the relay.
250
401
  */
251
- onMessage(message) {
252
- log("KeyExchange", `Received ${message.type}, isOriginator=${this.isOriginator}`);
253
- switch (message.type) {
254
- case "key_handshake_SYN" /* SYN */: {
255
- if (this.isOriginator) {
256
- warn("KeyExchange", "Originator received SYN - ignoring");
257
- return null;
258
- }
259
- if (message.pubkey) {
260
- this.otherPublicKey = message.pubkey;
261
- }
262
- this.step = "key_handshake_SYNACK" /* SYNACK */;
263
- this.awaitingAck = true;
264
- this.emit("step_change", this.step);
265
- return {
266
- type: "key_handshake_SYNACK" /* SYNACK */,
267
- pubkey: this.ecies.getPublicKey(),
268
- v: PROTOCOL_VERSION
269
- };
270
- }
271
- case "key_handshake_SYNACK" /* SYNACK */: {
272
- if (!this.isOriginator) {
273
- warn("KeyExchange", "Non-originator received SYNACK - ignoring");
274
- return null;
275
- }
276
- if (message.pubkey) {
277
- this.otherPublicKey = message.pubkey;
278
- }
279
- if (!this.awaitingSynAck) {
280
- return {
281
- type: "key_handshake_ACK" /* ACK */,
282
- v: PROTOCOL_VERSION
283
- };
284
- }
285
- this.awaitingSynAck = false;
286
- this.step = "key_handshake_ACK" /* ACK */;
287
- if (!this.keysExchanged) {
288
- this.keysExchanged = true;
289
- this.emit("keys_exchanged");
290
- }
291
- this.emit("step_change", this.step);
292
- return {
293
- type: "key_handshake_ACK" /* ACK */,
294
- v: PROTOCOL_VERSION
295
- };
296
- }
297
- case "key_handshake_ACK" /* ACK */: {
298
- if (this.isOriginator) {
299
- warn("KeyExchange", "Originator received ACK - ignoring");
300
- return null;
301
- }
302
- if (!this.awaitingAck) {
303
- return null;
304
- }
305
- this.awaitingAck = false;
306
- if (!this.keysExchanged) {
307
- this.keysExchanged = true;
308
- this.emit("keys_exchanged");
309
- }
310
- log("KeyExchange", "Key exchange complete (wallet side)");
311
- return null;
312
- }
313
- default:
314
- warn("KeyExchange", `Unknown key exchange type: ${message.type}`);
315
- return null;
402
+ async receiveQR(cid, pk) {
403
+ if (this.isOriginator) {
404
+ throw new Error("KeyExchange: originator cannot consume a QR");
316
405
  }
406
+ this.resetInternal(false);
407
+ const { ct, ss } = kemEncaps(pk);
408
+ const htx = await transcriptHash(cid, pk, ct);
409
+ const key = await deriveAeadKey(ss, htx);
410
+ const c0 = await seal(key, DIR_WALLET_TX, 0, htx, HELLO_WALLET);
411
+ zeroize(ss);
412
+ this.session = {
413
+ cid,
414
+ key,
415
+ htx,
416
+ sendDir: DIR_WALLET_TX,
417
+ recvDir: DIR_DAPP_TX,
418
+ sendSeq: 1,
419
+ recvSeq: 1
420
+ };
421
+ this.awaitingAck = true;
422
+ this.step = "key_handshake_SYNACK" /* SYNACK */;
423
+ this.emit("step_change", this.step);
424
+ return {
425
+ type: "key_handshake_SYNACK" /* SYNACK */,
426
+ ct: toBase64(ct),
427
+ c0: toBase64(c0),
428
+ v: PROTOCOL_VERSION
429
+ };
317
430
  }
318
- /** Encrypt a message for the counterparty. */
319
- encryptMessage(data) {
320
- if (!this.otherPublicKey) {
321
- throw new Error("Cannot encrypt: key exchange not complete");
431
+ /**
432
+ * Responder: verify an incoming ACK and finalize the session.
433
+ * Duplicates are silently ignored (handshake is idempotent).
434
+ */
435
+ async onAck(msg) {
436
+ if (this.isOriginator) {
437
+ warn("KeyExchange", "Originator received ACK \u2014 ignoring");
438
+ return;
322
439
  }
323
- return this.ecies.encrypt(data, this.otherPublicKey);
324
- }
325
- /** Decrypt a message from the counterparty. */
326
- decryptMessage(encryptedBase64) {
327
- return this.ecies.decrypt(encryptedBase64);
328
- }
329
- /** Reset key exchange state for a fresh handshake (e.g. new QR code). */
440
+ if (!this.awaitingAck) {
441
+ return;
442
+ }
443
+ if (!this.session) {
444
+ throw new Error("KeyExchange: onAck called without a session");
445
+ }
446
+ this.awaitingAck = false;
447
+ const c1 = fromBase64(msg.c1);
448
+ let hello;
449
+ try {
450
+ hello = await open(this.session.key, DIR_DAPP_TX, 0, this.session.htx, c1);
451
+ } catch {
452
+ throw new Error("KeyExchange: dApp hello AEAD tag failed");
453
+ }
454
+ if (!constantTimeEquals(hello, HELLO_DAPP)) {
455
+ throw new Error("KeyExchange: dApp hello mismatch");
456
+ }
457
+ this.keysExchanged = true;
458
+ this.step = "key_handshake_ACK" /* ACK */;
459
+ this.emit("keys_exchanged");
460
+ }
461
+ /** Encrypt a string for the counterparty. Returns base64. */
462
+ async encryptMessage(data) {
463
+ if (!this.session) {
464
+ throw new Error("KeyExchange: cannot encrypt \u2014 session not established");
465
+ }
466
+ const pt = textEncoder2.encode(data);
467
+ const ct = await seal(
468
+ this.session.key,
469
+ this.session.sendDir,
470
+ this.session.sendSeq,
471
+ this.session.htx,
472
+ pt
473
+ );
474
+ this.session.sendSeq++;
475
+ return toBase64(ct);
476
+ }
477
+ /** Decrypt a base64 ciphertext from the counterparty. */
478
+ async decryptMessage(b64) {
479
+ if (!this.session) {
480
+ throw new Error("KeyExchange: cannot decrypt \u2014 session not established");
481
+ }
482
+ const ct = fromBase64(b64);
483
+ const pt = await open(
484
+ this.session.key,
485
+ this.session.recvDir,
486
+ this.session.recvSeq,
487
+ this.session.htx,
488
+ ct
489
+ );
490
+ this.session.recvSeq++;
491
+ return textDecoder.decode(pt);
492
+ }
493
+ /** Reset all state for a fresh handshake. Zeroes any live secret-key buffer. */
330
494
  reset() {
331
- this.otherPublicKey = null;
495
+ this.resetInternal(true);
496
+ }
497
+ resetInternal(emit) {
498
+ if (this.keypair) {
499
+ zeroize(this.keypair.sk);
500
+ this.keypair = null;
501
+ }
502
+ this.session = null;
332
503
  this.keysExchanged = false;
333
504
  this.awaitingSynAck = false;
334
505
  this.awaitingAck = false;
335
506
  this.step = "key_handshake_SYN" /* SYN */;
336
- log("KeyExchange", "Reset for fresh handshake");
507
+ if (emit) this.emit("step_change", this.step);
337
508
  }
338
509
  areKeysExchanged() {
339
510
  return this.keysExchanged;
340
511
  }
341
- getOtherPublicKey() {
342
- return this.otherPublicKey;
512
+ getSession() {
513
+ return this.session;
343
514
  }
344
515
  getCurrentStep() {
345
516
  return this.step;
346
517
  }
518
+ /** Export the current session as a JSON-safe persisted record. */
519
+ async exportPersisted() {
520
+ if (!this.session) return null;
521
+ return {
522
+ cid: toBase64(this.session.cid),
523
+ kAeadRaw: toBase64(await exportRawAeadKey(this.session.key)),
524
+ htx: toBase64(this.session.htx),
525
+ sendDir: toBase64(this.session.sendDir),
526
+ recvDir: toBase64(this.session.recvDir),
527
+ sendSeq: this.session.sendSeq,
528
+ recvSeq: this.session.recvSeq
529
+ };
530
+ }
531
+ /** Rehydrate a Session from its persisted form. */
532
+ static async sessionFromPersisted(p) {
533
+ const key = await importRawAeadKey(fromBase64(p.kAeadRaw));
534
+ return {
535
+ cid: fromBase64(p.cid),
536
+ key,
537
+ htx: fromBase64(p.htx),
538
+ sendDir: fromBase64(p.sendDir),
539
+ recvDir: fromBase64(p.recvDir),
540
+ sendSeq: p.sendSeq,
541
+ recvSeq: p.recvSeq
542
+ };
543
+ }
347
544
  };
348
545
 
349
546
  // src/SocketClient.ts
350
547
  var import_socket = require("socket.io-client");
351
548
  var import_eventemitter32 = __toESM(require("eventemitter3"));
549
+ var PENDING_JOIN_TIMEOUT_MS = 2e4;
352
550
  var SocketClient = class extends import_eventemitter32.default {
353
551
  constructor(relayUrl, clientType) {
354
552
  super();
355
553
  this.socket = null;
356
554
  this.channelId = null;
555
+ // The dApp's own PK (base64) that must be uploaded with every join
556
+ // attempt so the relay can bind it to the channel. Wallet-side leaves
557
+ // this undefined.
558
+ this.publicKeyBase64 = null;
357
559
  this.seq = 0;
358
560
  this.pendingJoin = null;
359
561
  this.relayUrl = relayUrl;
360
562
  this.clientType = clientType;
361
563
  }
564
+ /**
565
+ * dApp-only: stash the PK so it gets uploaded on every join_channel
566
+ * attempt (including auto-rejoins after reconnect).
567
+ */
568
+ setPublicKey(publicKeyBase64) {
569
+ if (this.clientType !== "dapp") {
570
+ throw new Error("SocketClient.setPublicKey is only valid for dApp clients");
571
+ }
572
+ this.publicKeyBase64 = publicKeyBase64;
573
+ }
362
574
  /**
363
575
  * Connect to the relay server.
364
576
  */
@@ -380,17 +592,11 @@ var SocketClient = class extends import_eventemitter32.default {
380
592
  if (this.channelId) {
381
593
  const reconnectChannelId = this.channelId;
382
594
  this.joinChannelNow(reconnectChannelId).then((result) => {
383
- if (this.pendingJoin && this.pendingJoin.channelId === reconnectChannelId) {
384
- this.pendingJoin.resolve(result);
385
- this.pendingJoin = null;
386
- }
595
+ this.settlePendingJoin(reconnectChannelId, { result });
387
596
  this.emit("reconnected");
388
597
  }).catch((err) => {
389
598
  const reconnectErr = err instanceof Error ? err : new Error(String(err));
390
- if (this.pendingJoin && this.pendingJoin.channelId === reconnectChannelId) {
391
- this.pendingJoin.reject(reconnectErr);
392
- this.pendingJoin = null;
393
- }
599
+ this.settlePendingJoin(reconnectChannelId, { error: reconnectErr });
394
600
  error("Socket", `Rejoin failed: ${reconnectErr.message}`);
395
601
  this.emit("error", reconnectErr);
396
602
  });
@@ -414,7 +620,24 @@ var SocketClient = class extends import_eventemitter32.default {
414
620
  });
415
621
  }
416
622
  /**
417
- * Join a relay channel.
623
+ * Resolve or reject the currently-pending `joinChannel` promise, if it
624
+ * matches the given channelId. Clears the watchdog timer on settle so
625
+ * it doesn't fire after the fact.
626
+ */
627
+ settlePendingJoin(channelId, outcome) {
628
+ if (!this.pendingJoin || this.pendingJoin.channelId !== channelId) return;
629
+ clearTimeout(this.pendingJoin.watchdog);
630
+ const pending = this.pendingJoin;
631
+ this.pendingJoin = null;
632
+ if ("result" in outcome) {
633
+ pending.resolve(outcome.result);
634
+ } else {
635
+ pending.reject(outcome.error);
636
+ }
637
+ }
638
+ /**
639
+ * Join a relay channel. Resolves with any buffered messages plus the
640
+ * dApp's public key bound to the channel (for wallet joins).
418
641
  */
419
642
  async joinChannel(channelId) {
420
643
  if (!this.socket) {
@@ -428,6 +651,7 @@ var SocketClient = class extends import_eventemitter32.default {
428
651
  if (this.pendingJoin.channelId === channelId) {
429
652
  return this.pendingJoin.promise;
430
653
  }
654
+ clearTimeout(this.pendingJoin.watchdog);
431
655
  this.pendingJoin.reject(new Error("Join request superseded by a newer channel"));
432
656
  this.pendingJoin = null;
433
657
  }
@@ -437,11 +661,17 @@ var SocketClient = class extends import_eventemitter32.default {
437
661
  resolvePending = resolve;
438
662
  rejectPending = reject;
439
663
  });
664
+ const watchdog = setTimeout(() => {
665
+ this.settlePendingJoin(channelId, {
666
+ error: new Error(`joinChannel timed out after ${PENDING_JOIN_TIMEOUT_MS}ms`)
667
+ });
668
+ }, PENDING_JOIN_TIMEOUT_MS);
440
669
  this.pendingJoin = {
441
670
  channelId,
442
671
  promise,
443
672
  resolve: resolvePending,
444
- reject: rejectPending
673
+ reject: rejectPending,
674
+ watchdog
445
675
  };
446
676
  return promise;
447
677
  }
@@ -451,13 +681,20 @@ var SocketClient = class extends import_eventemitter32.default {
451
681
  reject(new Error("Socket not connected"));
452
682
  return;
453
683
  }
684
+ const payload = { channelId, clientType: this.clientType };
685
+ if (this.clientType === "dapp" && this.publicKeyBase64) {
686
+ payload.publicKey = this.publicKeyBase64;
687
+ }
454
688
  this.socket.emit(
455
689
  "join_channel",
456
- { channelId, clientType: this.clientType },
690
+ payload,
457
691
  (response) => {
458
692
  if (response.success) {
459
693
  log("Socket", `Joined channel ${channelId}`);
460
- resolve({ bufferedMessages: response.bufferedMessages || [] });
694
+ resolve({
695
+ bufferedMessages: response.bufferedMessages || [],
696
+ channelPublicKey: response.channelPublicKey ?? null
697
+ });
461
698
  } else {
462
699
  error("Socket", `Failed to join channel: ${response.error}`);
463
700
  reject(new Error(response.error || "Failed to join channel"));
@@ -494,6 +731,7 @@ var SocketClient = class extends import_eventemitter32.default {
494
731
  */
495
732
  leaveChannel() {
496
733
  if (this.pendingJoin) {
734
+ clearTimeout(this.pendingJoin.watchdog);
497
735
  this.pendingJoin.reject(new Error("Channel left before join completed"));
498
736
  this.pendingJoin = null;
499
737
  }
@@ -507,6 +745,7 @@ var SocketClient = class extends import_eventemitter32.default {
507
745
  */
508
746
  disconnect() {
509
747
  if (this.pendingJoin) {
748
+ clearTimeout(this.pendingJoin.watchdog);
510
749
  this.pendingJoin.reject(new Error("Socket disconnected before join completed"));
511
750
  this.pendingJoin = null;
512
751
  }
@@ -526,111 +765,256 @@ var SocketClient = class extends import_eventemitter32.default {
526
765
  }
527
766
  };
528
767
 
768
+ // src/utils/base45.ts
769
+ var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
770
+ var DECODE = (() => {
771
+ const table = new Int8Array(128).fill(-1);
772
+ for (let i = 0; i < ALPHABET.length; i++) {
773
+ table[ALPHABET.charCodeAt(i)] = i;
774
+ }
775
+ return table;
776
+ })();
777
+ function decodeChar(charCode) {
778
+ if (charCode < 0 || charCode >= 128) return -1;
779
+ return DECODE[charCode];
780
+ }
781
+ function base45Encode(bytes) {
782
+ const n = bytes.length;
783
+ let out = "";
784
+ let i = 0;
785
+ while (i + 2 <= n) {
786
+ const v = bytes[i] << 8 | bytes[i + 1];
787
+ const c2 = Math.floor(v / 2025);
788
+ const r = v - c2 * 2025;
789
+ const c1 = Math.floor(r / 45);
790
+ const c0 = r - c1 * 45;
791
+ out += ALPHABET[c0] + ALPHABET[c1] + ALPHABET[c2];
792
+ i += 2;
793
+ }
794
+ if (i < n) {
795
+ const v = bytes[i];
796
+ const c1 = Math.floor(v / 45);
797
+ const c0 = v - c1 * 45;
798
+ out += ALPHABET[c0] + ALPHABET[c1];
799
+ }
800
+ return out;
801
+ }
802
+ function base45Decode(s) {
803
+ const len = s.length;
804
+ if (len === 0) return new Uint8Array(0);
805
+ const full = Math.floor(len / 3);
806
+ const tail = len - full * 3;
807
+ if (tail !== 0 && tail !== 2) {
808
+ throw new Error("base45: invalid input length");
809
+ }
810
+ const outLen = full * 2 + (tail === 2 ? 1 : 0);
811
+ const out = new Uint8Array(outLen);
812
+ for (let g = 0; g < full; g++) {
813
+ const d0 = decodeChar(s.charCodeAt(g * 3));
814
+ const d1 = decodeChar(s.charCodeAt(g * 3 + 1));
815
+ const d2 = decodeChar(s.charCodeAt(g * 3 + 2));
816
+ if (d0 < 0 || d1 < 0 || d2 < 0) {
817
+ throw new Error("base45: invalid character");
818
+ }
819
+ const v = d0 + d1 * 45 + d2 * 2025;
820
+ if (v > 65535) {
821
+ throw new Error("base45: decoded group exceeds 0xFFFF");
822
+ }
823
+ out[g * 2] = v >> 8 & 255;
824
+ out[g * 2 + 1] = v & 255;
825
+ }
826
+ if (tail === 2) {
827
+ const d0 = decodeChar(s.charCodeAt(full * 3));
828
+ const d1 = decodeChar(s.charCodeAt(full * 3 + 1));
829
+ if (d0 < 0 || d1 < 0) {
830
+ throw new Error("base45: invalid character");
831
+ }
832
+ const v = d0 + d1 * 45;
833
+ if (v > 255) {
834
+ throw new Error("base45: decoded byte exceeds 0xFF");
835
+ }
836
+ out[outLen - 1] = v;
837
+ }
838
+ return out;
839
+ }
840
+
529
841
  // src/utils/qrUri.ts
530
- function generateConnectionURI(params) {
531
- const searchParams = new URLSearchParams({
532
- channelId: params.channelId,
533
- pubKey: params.pubKey,
534
- name: params.name,
535
- url: params.url,
536
- chainId: params.chainId,
537
- relay: params.relayUrl
538
- });
539
- if (params.icon) {
540
- searchParams.set("icon", params.icon);
541
- }
542
- return `qrlconnect://?${searchParams.toString()}`;
842
+ var MAGIC = new Uint8Array([80, 81, 80, 50]);
843
+ var FP_LABEL = new TextEncoder().encode("pq-fp/v2");
844
+ var CID_LEN = 16;
845
+ var FP_LEN = 32;
846
+ var BLOB_LEN = 4 + CID_LEN + FP_LEN;
847
+ function bs2(u) {
848
+ return u;
849
+ }
850
+ async function computeFingerprint(cid, pk) {
851
+ if (cid.length !== CID_LEN) {
852
+ throw new Error(`qrUri: cid must be ${CID_LEN} bytes`);
853
+ }
854
+ const c = globalThis.crypto;
855
+ if (!c || !c.subtle) {
856
+ throw new Error("qrUri: WebCrypto SubtleCrypto is not available");
857
+ }
858
+ const buf = new Uint8Array(FP_LABEL.length + cid.length + pk.length);
859
+ buf.set(FP_LABEL, 0);
860
+ buf.set(cid, FP_LABEL.length);
861
+ buf.set(pk, FP_LABEL.length + cid.length);
862
+ const digest = await c.subtle.digest("SHA-256", bs2(buf));
863
+ return new Uint8Array(digest);
864
+ }
865
+ function fingerprintEquals(a, b) {
866
+ if (a.length !== b.length) return false;
867
+ let diff = 0;
868
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
869
+ return diff === 0;
870
+ }
871
+ async function generateConnectionURI(cid, pk, relayUrl) {
872
+ if (cid.length !== CID_LEN) {
873
+ throw new Error(`qrUri: cid must be ${CID_LEN} bytes (got ${cid.length})`);
874
+ }
875
+ const fp = await computeFingerprint(cid, pk);
876
+ const blob = new Uint8Array(BLOB_LEN);
877
+ blob.set(MAGIC, 0);
878
+ blob.set(cid, 4);
879
+ blob.set(fp, 4 + CID_LEN);
880
+ const params = new URLSearchParams({ q: base45Encode(blob) });
881
+ if (relayUrl) params.set("r", relayUrl);
882
+ return `qrlconnect://?${params.toString()}`;
543
883
  }
544
- function parseConnectionURI(uri) {
884
+ var PQP1_MAGIC = new Uint8Array([80, 81, 80, 49]);
885
+ function startsWith(buf, prefix) {
886
+ if (buf.length < prefix.length) return false;
887
+ for (let i = 0; i < prefix.length; i++) {
888
+ if (buf[i] !== prefix[i]) return false;
889
+ }
890
+ return true;
891
+ }
892
+ async function parseConnectionURI(uri) {
893
+ if (typeof uri !== "string" || uri.length === 0) {
894
+ throw new Error("qrUri: empty URI");
895
+ }
896
+ if (!/^qrlconnect:/i.test(uri)) {
897
+ throw new Error("qrUri: not a qrlconnect URI");
898
+ }
899
+ let params;
545
900
  try {
546
- const queryString = uri.replace(/^qrlconnect:\/?\/?/, "");
547
- const params = new URLSearchParams(queryString);
548
- const channelId = params.get("channelId");
549
- const pubKey = params.get("pubKey");
550
- const name = params.get("name");
551
- const url = params.get("url");
552
- const chainId = params.get("chainId");
553
- const relayUrl = params.get("relay");
554
- if (!channelId || !pubKey || !name || !url || !chainId || !relayUrl) {
555
- return null;
556
- }
557
- return {
558
- channelId,
559
- pubKey,
560
- name,
561
- url,
562
- icon: params.get("icon") || void 0,
563
- chainId,
564
- relayUrl
565
- };
901
+ const swapped = new URL(uri.replace(/^qrlconnect:\/?\/?/i, "https://qrlconnect/"));
902
+ params = swapped.searchParams;
566
903
  } catch {
567
- return null;
904
+ throw new Error("qrUri: malformed URI");
905
+ }
906
+ if (params.has("channelId") || params.has("pubKey")) {
907
+ throw new Error(
908
+ "qrUri: legacy v1 URI detected \u2014 this wallet and this dApp must both run protocol v2"
909
+ );
568
910
  }
911
+ const q = params.get("q");
912
+ if (!q) {
913
+ throw new Error("qrUri: missing q parameter");
914
+ }
915
+ let blob;
916
+ try {
917
+ blob = base45Decode(q);
918
+ } catch (err) {
919
+ const msg = err instanceof Error ? err.message : String(err);
920
+ throw new Error(`qrUri: base45 decode failed: ${msg}`);
921
+ }
922
+ if (blob.length !== BLOB_LEN) {
923
+ if (blob.length === 1208 && startsWith(blob, PQP1_MAGIC)) {
924
+ throw new Error("qrUri: legacy PQP1 URI detected \u2014 regenerate the QR with a v2.0+ dApp SDK");
925
+ }
926
+ throw new Error(`qrUri: expected ${BLOB_LEN}-byte blob, got ${blob.length}`);
927
+ }
928
+ if (!startsWith(blob, MAGIC)) {
929
+ throw new Error("qrUri: bad PQP2 magic");
930
+ }
931
+ const cid = blob.slice(4, 4 + CID_LEN);
932
+ const fp = blob.slice(4 + CID_LEN, 4 + CID_LEN + FP_LEN);
933
+ const relayUrl = params.get("r") || void 0;
934
+ return { cid, fp, relayUrl };
935
+ }
936
+ function cidToString(cid) {
937
+ if (cid.length !== CID_LEN) {
938
+ throw new Error(`cidToString: expected ${CID_LEN}-byte cid`);
939
+ }
940
+ let hex = "";
941
+ for (let i = 0; i < CID_LEN; i++) {
942
+ hex += cid[i].toString(16).padStart(2, "0");
943
+ }
944
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
945
+ }
946
+ function cidFromString(s) {
947
+ const hex = s.replace(/-/g, "");
948
+ if (!/^[0-9a-f]{32}$/i.test(hex)) {
949
+ throw new Error("cidFromString: not a 128-bit hex string");
950
+ }
951
+ const out = new Uint8Array(CID_LEN);
952
+ for (let i = 0; i < CID_LEN; i++) {
953
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
954
+ }
955
+ return out;
956
+ }
957
+ function cidRandom() {
958
+ const c = globalThis.crypto;
959
+ if (!c || typeof c.getRandomValues !== "function") {
960
+ throw new Error("qrUri: crypto.getRandomValues is not available");
961
+ }
962
+ return c.getRandomValues(new Uint8Array(CID_LEN));
569
963
  }
570
964
 
571
965
  // src/ConnectionManager.ts
572
966
  var DAPP_PARTICIPANT_CONFLICT_ERROR_MSG = "dapp participant is already connected";
967
+ var TERMINATE_SEND_TIMEOUT_MS = 800;
573
968
  var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.default {
574
969
  constructor(options) {
575
970
  super();
971
+ this.keyExchange = null;
576
972
  this.status = "disconnected" /* DISCONNECTED */;
577
973
  this.connectedAccounts = [];
578
974
  this.unresponsiveTimer = null;
579
- this.synRetryTimer = null;
580
975
  this.failedReconnects = 0;
581
- this.synSent = false;
582
- this.synAttempts = 0;
583
976
  this.walletPresent = false;
977
+ this.pendingRestore = null;
978
+ this.messageQueue = Promise.resolve();
584
979
  this.dappMetadata = options.dappMetadata;
585
980
  this.relayUrl = options.relayUrl || DEFAULT_RELAY_URL;
586
981
  this.chainId = options.chainId || "0x0";
587
982
  this.storageKey = options.storageKey || `${STORAGE_KEY_PREFIX}:session`;
588
- const session = this.loadSession();
589
- if (session) {
590
- this.channelId = session.channelId;
591
- this.ecies = new ECIESClient(session.privateKey);
592
- this.keyExchange = new KeyExchange(
593
- this.ecies,
594
- true,
595
- // dApp is always originator
596
- session.otherPublicKey || void 0
597
- );
598
- this.connectedAccounts = session.connectedAccounts;
599
- log("ConnectionManager", `Restored session for channel ${this.channelId}`);
983
+ const stored = this.readStoredSession();
984
+ if (stored) {
985
+ this.channelId = stored.channelId;
986
+ this.connectedAccounts = stored.connectedAccounts;
987
+ this.chainId = stored.chainId;
988
+ this.dappMetadata = stored.dappMetadata;
989
+ this.pendingRestore = stored;
990
+ log("ConnectionManager", `Found persisted session for channel ${this.channelId}`);
600
991
  } else {
601
992
  this.channelId = (0, import_uuid.v4)();
602
- this.ecies = new ECIESClient();
603
- this.keyExchange = new KeyExchange(this.ecies, true);
604
993
  }
605
994
  this.socketClient = new SocketClient(this.relayUrl, "dapp");
606
995
  this.setupSocketListeners();
607
- this.setupKeyExchangeListeners();
608
996
  }
997
+ // ── Setup ──────────────────────────────────────────────────
609
998
  setupSocketListeners() {
610
999
  this.socketClient.on("connected", () => {
611
1000
  if (this.status === "reconnecting" /* RECONNECTING */) {
612
1001
  this.failedReconnects = 0;
613
- log("ConnectionManager", "Reconnected to relay");
614
1002
  }
615
1003
  });
616
1004
  this.socketClient.on("reconnected", () => {
617
- if (this.keyExchange.areKeysExchanged()) {
1005
+ if (this.keyExchange?.areKeysExchanged()) {
618
1006
  if (this.walletPresent) {
619
1007
  this.setStatus("connected" /* CONNECTED */);
620
1008
  this.failedReconnects = 0;
621
1009
  } else {
622
1010
  this.setStatus("waiting" /* WAITING */);
623
1011
  }
624
- this.synAttempts = 0;
625
- this.clearSynRetryTimer();
626
1012
  } else {
627
- this.sendSYN();
1013
+ this.setStatus("waiting" /* WAITING */);
628
1014
  }
629
1015
  });
630
- this.socketClient.on("disconnected", (_reason) => {
1016
+ this.socketClient.on("disconnected", () => {
631
1017
  this.walletPresent = false;
632
- this.synSent = false;
633
- this.clearSynRetryTimer();
634
1018
  if (this.status === "connected" /* CONNECTED */) {
635
1019
  this.setStatus("reconnecting" /* RECONNECTING */);
636
1020
  this.failedReconnects++;
@@ -640,7 +1024,7 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
640
1024
  }
641
1025
  });
642
1026
  this.socketClient.on("message", (data) => {
643
- this.handleRelayMessage(data);
1027
+ this.enqueueRelayMessage(data);
644
1028
  });
645
1029
  this.socketClient.on("error", (err) => {
646
1030
  this.emit("error", err);
@@ -650,20 +1034,15 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
650
1034
  this.walletPresent = true;
651
1035
  log("ConnectionManager", "Wallet joined channel");
652
1036
  this.clearUnresponsiveTimer();
653
- if (this.keyExchange.areKeysExchanged()) {
1037
+ if (this.keyExchange?.areKeysExchanged()) {
654
1038
  this.failedReconnects = 0;
655
1039
  this.setStatus("connected" /* CONNECTED */);
656
- } else if (!this.synSent) {
657
- this.sendSYN();
658
1040
  }
659
1041
  }
660
1042
  if (data.event === "disconnect" || data.event === "leave") {
661
1043
  if (data.clientType === "wallet" || !data.clientType) {
662
1044
  this.walletPresent = false;
663
- if (!this.keyExchange.areKeysExchanged()) {
664
- this.synSent = false;
665
- this.clearSynRetryTimer();
666
- } else if (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */) {
1045
+ if (this.keyExchange?.areKeysExchanged() && (this.status === "connected" /* CONNECTED */ || this.status === "reconnecting" /* RECONNECTING */)) {
667
1046
  this.setStatus("waiting" /* WAITING */);
668
1047
  }
669
1048
  log("ConnectionManager", "Wallet left channel");
@@ -672,15 +1051,14 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
672
1051
  });
673
1052
  }
674
1053
  setupKeyExchangeListeners() {
1054
+ if (!this.keyExchange) return;
675
1055
  this.keyExchange.on("keys_exchanged", () => {
676
1056
  log("ConnectionManager", "Key exchange complete");
677
1057
  this.setStatus("connected" /* CONNECTED */);
678
1058
  this.walletPresent = true;
679
- this.synSent = false;
680
- this.synAttempts = 0;
681
- this.clearSynRetryTimer();
682
- this.saveSession();
683
- this.sendEncrypted({
1059
+ this.failedReconnects = 0;
1060
+ void this.persistSession();
1061
+ void this.sendEncrypted({
684
1062
  type: "originator_info" /* ORIGINATOR_INFO */,
685
1063
  originatorInfo: {
686
1064
  ...this.dappMetadata,
@@ -689,16 +1067,24 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
689
1067
  });
690
1068
  });
691
1069
  }
1070
+ // ── Public API ─────────────────────────────────────────────
692
1071
  /**
693
- * Generate the connection URI and start listening for wallet connection.
1072
+ * Generate a new v2 connection URI. Rotates channel id and keypair.
1073
+ * Returns a `qrlconnect://?q=…` URI safe for QR-rendering or deep-link.
694
1074
  */
695
1075
  async getConnectionURI(retryOnConflict = true) {
696
1076
  this.setStatus("connecting" /* CONNECTING */);
697
- this.keyExchange.reset();
1077
+ this.pendingRestore = null;
698
1078
  this.walletPresent = false;
699
- this.synSent = false;
700
- this.synAttempts = 0;
701
- this.clearSynRetryTimer();
1079
+ if (this.keyExchange) {
1080
+ this.keyExchange.reset();
1081
+ } else {
1082
+ this.keyExchange = new KeyExchange(true);
1083
+ this.setupKeyExchangeListeners();
1084
+ }
1085
+ const pk = this.keyExchange.initiate();
1086
+ this.channelId = (0, import_uuid.v4)();
1087
+ this.socketClient.setPublicKey(toBase64(pk));
702
1088
  this.socketClient.connect();
703
1089
  try {
704
1090
  await this.socketClient.joinChannel(this.channelId);
@@ -708,48 +1094,51 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
708
1094
  "ConnectionManager",
709
1095
  "Channel already has an active dApp participant. Rotating to a fresh channel."
710
1096
  );
711
- this.resetForNewChannel();
1097
+ this.channelId = (0, import_uuid.v4)();
712
1098
  return this.getConnectionURI(false);
713
1099
  }
714
1100
  this.setStatus("disconnected" /* DISCONNECTED */);
715
1101
  throw err;
716
1102
  }
717
1103
  this.setStatus("waiting" /* WAITING */);
718
- const uri = generateConnectionURI({
719
- channelId: this.channelId,
720
- pubKey: this.ecies.getPublicKey(),
721
- name: this.dappMetadata.name,
722
- url: this.dappMetadata.url,
723
- icon: this.dappMetadata.icon,
724
- chainId: this.chainId,
725
- relayUrl: this.relayUrl
726
- });
727
- log("ConnectionManager", `Generated connection URI for channel ${this.channelId}`);
1104
+ const uri = await generateConnectionURI(
1105
+ cidFromString(this.channelId),
1106
+ pk,
1107
+ this.relayUrl === DEFAULT_RELAY_URL ? void 0 : this.relayUrl
1108
+ );
1109
+ log("ConnectionManager", `Generated v2 connection URI for channel ${this.channelId}`);
728
1110
  return uri;
729
1111
  }
730
1112
  /**
731
1113
  * Reconnect to an existing session.
1114
+ * Returns false if there is nothing to restore.
732
1115
  */
733
1116
  async reconnect() {
734
- const session = this.loadSession();
735
- if (!session) return false;
1117
+ if (!this.pendingRestore) return false;
736
1118
  this.setStatus("reconnecting" /* RECONNECTING */);
737
1119
  this.walletPresent = false;
738
- this.synSent = false;
739
- this.synAttempts = 0;
740
- this.clearSynRetryTimer();
1120
+ try {
1121
+ const session = await KeyExchange.sessionFromPersisted(this.pendingRestore.keyExchange);
1122
+ this.keyExchange = new KeyExchange(true, session);
1123
+ this.setupKeyExchangeListeners();
1124
+ } catch (err) {
1125
+ error("ConnectionManager", "Failed to hydrate persisted session:", err);
1126
+ this.clearSession();
1127
+ this.pendingRestore = null;
1128
+ this.setStatus("disconnected" /* DISCONNECTED */);
1129
+ return false;
1130
+ }
741
1131
  this.socketClient.connect();
742
1132
  try {
743
1133
  const { bufferedMessages } = await this.socketClient.joinChannel(this.channelId);
744
1134
  for (const msg of bufferedMessages) {
745
- this.handleRelayMessage(msg);
1135
+ this.enqueueRelayMessage(msg);
746
1136
  }
747
- if (this.keyExchange.areKeysExchanged() && this.walletPresent) {
748
- this.setStatus("connected" /* CONNECTED */);
749
- } else if (this.keyExchange.areKeysExchanged()) {
750
- this.setStatus("waiting" /* WAITING */);
1137
+ await this.messageQueue;
1138
+ if (this.keyExchange?.areKeysExchanged()) {
1139
+ this.setStatus(this.walletPresent ? "connected" /* CONNECTED */ : "waiting" /* WAITING */);
751
1140
  } else {
752
- this.sendSYN();
1141
+ this.setStatus("waiting" /* WAITING */);
753
1142
  }
754
1143
  return true;
755
1144
  } catch (err) {
@@ -759,10 +1148,10 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
759
1148
  }
760
1149
  }
761
1150
  /**
762
- * Send a JSON-RPC request to the wallet.
1151
+ * Send a JSON-RPC request to the wallet (async, fire-and-forget).
763
1152
  */
764
1153
  sendJsonRpc(request) {
765
- if (!this.keyExchange.areKeysExchanged()) {
1154
+ if (!this.keyExchange?.areKeysExchanged()) {
766
1155
  throw new Error("Not connected: key exchange not complete");
767
1156
  }
768
1157
  this.sendEncrypted({
@@ -771,16 +1160,88 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
771
1160
  id: request.id,
772
1161
  method: request.method,
773
1162
  params: request.params
1163
+ }).catch((err) => {
1164
+ error("ConnectionManager", "Failed to send JSON-RPC:", err);
774
1165
  });
775
1166
  this.startUnresponsiveTimer();
776
1167
  }
1168
+ getStatus() {
1169
+ return this.status;
1170
+ }
1171
+ getAccounts() {
1172
+ return this.connectedAccounts;
1173
+ }
1174
+ getChainId() {
1175
+ return this.chainId;
1176
+ }
1177
+ getChannelId() {
1178
+ return this.channelId;
1179
+ }
1180
+ /** Check (sync) if a persisted session exists and has not expired. */
1181
+ hasStoredSession() {
1182
+ if (typeof localStorage === "undefined") return false;
1183
+ try {
1184
+ const raw = localStorage.getItem(this.storageKey);
1185
+ if (!raw) return false;
1186
+ const session = JSON.parse(raw);
1187
+ if (session.version !== 2) return false;
1188
+ return Date.now() - session.createdAt <= SESSION_TTL_MS;
1189
+ } catch {
1190
+ return false;
1191
+ }
1192
+ }
1193
+ /**
1194
+ * Best-effort TERMINATE delivery before we tear the socket down. Without
1195
+ * awaiting, the socket.disconnect() below would win the race against the
1196
+ * outbound emit, the wallet would only see `participants_changed:
1197
+ * disconnect` and enter its stale-session grace period instead of an
1198
+ * instant disconnect. Mirrors the wallet side's pattern.
1199
+ */
1200
+ async flushTerminate() {
1201
+ if (!this.keyExchange?.areKeysExchanged()) return;
1202
+ const send = this.sendEncrypted({ type: "terminate" /* TERMINATE */ }).catch(() => {
1203
+ });
1204
+ const timeout = new Promise((resolve) => setTimeout(resolve, TERMINATE_SEND_TIMEOUT_MS));
1205
+ await Promise.race([send, timeout]);
1206
+ }
777
1207
  /**
778
- * Handle an incoming relay message.
1208
+ * Reset to a fresh channel + keypair. Sends TERMINATE to any live peer,
1209
+ * drops the persisted session, and prepares a clean state for
1210
+ * getConnectionURI() to be called again.
779
1211
  */
780
- handleRelayMessage(data) {
1212
+ async resetForNewChannel() {
1213
+ this.clearUnresponsiveTimer();
1214
+ this.walletPresent = false;
1215
+ await this.flushTerminate();
1216
+ this.socketClient.leaveChannel();
1217
+ this.socketClient.disconnect();
1218
+ this.clearSession();
1219
+ this.connectedAccounts = [];
1220
+ this.pendingRestore = null;
1221
+ this.channelId = (0, import_uuid.v4)();
1222
+ this.keyExchange = null;
1223
+ this.socketClient = new SocketClient(this.relayUrl, "dapp");
1224
+ this.setupSocketListeners();
1225
+ this.setStatus("disconnected" /* DISCONNECTED */);
1226
+ }
1227
+ async disconnect() {
1228
+ this.clearUnresponsiveTimer();
1229
+ this.walletPresent = false;
1230
+ await this.flushTerminate();
1231
+ this.socketClient.leaveChannel();
1232
+ this.socketClient.disconnect();
1233
+ this.setStatus("disconnected" /* DISCONNECTED */);
1234
+ this.clearSession();
1235
+ this.connectedAccounts = [];
1236
+ }
1237
+ // ── Internals ──────────────────────────────────────────────
1238
+ enqueueRelayMessage(data) {
1239
+ this.messageQueue = this.messageQueue.then(() => this.handleRelayMessage(data)).catch((err) => error("ConnectionManager", "messageQueue handler error:", err));
1240
+ }
1241
+ async handleRelayMessage(data) {
781
1242
  if (data.clientType === "wallet") {
782
1243
  this.walletPresent = true;
783
- if (this.keyExchange.areKeysExchanged() && this.status !== "connected" /* CONNECTED */) {
1244
+ if (this.keyExchange?.areKeysExchanged() && this.status !== "connected" /* CONNECTED */) {
784
1245
  this.failedReconnects = 0;
785
1246
  this.setStatus("connected" /* CONNECTED */);
786
1247
  }
@@ -788,16 +1249,18 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
788
1249
  const message = data.message;
789
1250
  if (typeof message === "object" && message !== null) {
790
1251
  const msg = message;
791
- if (msg.type === "key_handshake_SYN" /* SYN */ || msg.type === "key_handshake_SYNACK" /* SYNACK */ || msg.type === "key_handshake_ACK" /* ACK */) {
792
- this.handleKeyExchangeMessage(
793
- msg
794
- );
1252
+ if (msg.type === "key_handshake_SYNACK" /* SYNACK */) {
1253
+ await this.handleSynAck(message);
1254
+ return;
1255
+ }
1256
+ if (msg.type === "key_handshake_SYN" /* SYN */ || msg.type === "key_handshake_ACK" /* ACK */) {
1257
+ warn("ConnectionManager", `Unexpected ${msg.type} on dApp side \u2014 ignoring`);
795
1258
  return;
796
1259
  }
797
1260
  }
798
- if (typeof message === "string" && this.keyExchange.areKeysExchanged()) {
1261
+ if (typeof message === "string" && this.keyExchange?.areKeysExchanged()) {
799
1262
  try {
800
- const decrypted = this.keyExchange.decryptMessage(message);
1263
+ const decrypted = await this.keyExchange.decryptMessage(message);
801
1264
  const parsed = JSON.parse(decrypted);
802
1265
  this.handleDecryptedMessage(parsed);
803
1266
  } catch (err) {
@@ -805,9 +1268,18 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
805
1268
  }
806
1269
  }
807
1270
  }
808
- handleKeyExchangeMessage(msg) {
1271
+ async handleSynAck(msg) {
1272
+ if (!this.keyExchange) return;
809
1273
  this.setStatus("key_exchange" /* KEY_EXCHANGE */);
810
- const response = this.keyExchange.onMessage(msg);
1274
+ let response;
1275
+ try {
1276
+ response = await this.keyExchange.onSynAck(cidFromString(this.channelId), msg);
1277
+ } catch (err) {
1278
+ const e = err instanceof Error ? err : new Error(String(err));
1279
+ error("ConnectionManager", "SYNACK processing failed:", e);
1280
+ this.emit("error", e);
1281
+ return;
1282
+ }
811
1283
  if (response) {
812
1284
  this.sendPlaintext(response);
813
1285
  }
@@ -824,17 +1296,13 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
824
1296
  const chainChanged = this.chainId !== nextChainId;
825
1297
  this.connectedAccounts = nextAccounts;
826
1298
  this.chainId = nextChainId;
827
- this.saveSession();
1299
+ void this.persistSession();
828
1300
  this.emit("wallet_info", {
829
1301
  accounts: this.connectedAccounts,
830
1302
  chainId: this.chainId
831
1303
  });
832
- if (accountsChanged) {
833
- this.emit("accounts_changed", this.connectedAccounts);
834
- }
835
- if (chainChanged) {
836
- this.emit("chain_changed", this.chainId);
837
- }
1304
+ if (accountsChanged) this.emit("accounts_changed", this.connectedAccounts);
1305
+ if (chainChanged) this.emit("chain_changed", this.chainId);
838
1306
  break;
839
1307
  }
840
1308
  case "jsonrpc" /* JSONRPC */: {
@@ -844,14 +1312,13 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
844
1312
  }
845
1313
  case "terminate" /* TERMINATE */: {
846
1314
  log("ConnectionManager", "Received terminate from wallet");
847
- this.disconnect();
1315
+ void this.disconnect();
848
1316
  break;
849
1317
  }
850
1318
  default:
851
1319
  log("ConnectionManager", `Unhandled message type: ${type}`);
852
1320
  }
853
1321
  }
854
- /** Send a plaintext (unencrypted) message through the relay. */
855
1322
  sendPlaintext(message) {
856
1323
  this.socketClient.sendMessage({
857
1324
  id: this.channelId,
@@ -861,20 +1328,16 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
861
1328
  error("ConnectionManager", "Failed to send plaintext:", err);
862
1329
  });
863
1330
  }
864
- /** Send an encrypted message through the relay. */
865
- sendEncrypted(message) {
866
- try {
867
- const encrypted = this.keyExchange.encryptMessage(JSON.stringify(message));
868
- this.socketClient.sendMessage({
869
- id: this.channelId,
870
- clientType: "dapp",
871
- message: encrypted
872
- }).catch((err) => {
873
- error("ConnectionManager", "Failed to send encrypted:", err);
874
- });
875
- } catch (err) {
876
- error("ConnectionManager", "Encryption failed:", err);
1331
+ async sendEncrypted(message) {
1332
+ if (!this.keyExchange?.areKeysExchanged()) {
1333
+ throw new Error("sendEncrypted: not connected");
877
1334
  }
1335
+ const encrypted = await this.keyExchange.encryptMessage(JSON.stringify(message));
1336
+ await this.socketClient.sendMessage({
1337
+ id: this.channelId,
1338
+ clientType: "dapp",
1339
+ message: encrypted
1340
+ });
878
1341
  }
879
1342
  startUnresponsiveTimer() {
880
1343
  this.clearUnresponsiveTimer();
@@ -888,46 +1351,6 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
888
1351
  this.unresponsiveTimer = null;
889
1352
  }
890
1353
  }
891
- sendSYN() {
892
- if (!this.socketClient.isConnected()) {
893
- return;
894
- }
895
- if (this.synAttempts >= _ConnectionManager.MAX_SYN_ATTEMPTS) {
896
- this.synSent = false;
897
- this.emit("error", new Error("Key exchange timed out waiting for wallet response"));
898
- return;
899
- }
900
- try {
901
- const syn = this.keyExchange.createSYN();
902
- this.sendPlaintext(syn);
903
- this.synSent = true;
904
- this.synAttempts++;
905
- this.scheduleSynRetry();
906
- log("ConnectionManager", "Sent SYN key exchange message");
907
- } catch (err) {
908
- error("ConnectionManager", "Failed to send SYN:", err);
909
- }
910
- }
911
- scheduleSynRetry() {
912
- this.clearSynRetryTimer();
913
- this.synRetryTimer = setTimeout(() => {
914
- this.synRetryTimer = null;
915
- if (this.keyExchange.areKeysExchanged()) {
916
- return;
917
- }
918
- this.synSent = false;
919
- if (!this.walletPresent || !this.socketClient.isConnected()) {
920
- return;
921
- }
922
- this.sendSYN();
923
- }, _ConnectionManager.SYN_RETRY_MS);
924
- }
925
- clearSynRetryTimer() {
926
- if (this.synRetryTimer) {
927
- clearTimeout(this.synRetryTimer);
928
- this.synRetryTimer = null;
929
- }
930
- }
931
1354
  setStatus(status) {
932
1355
  if (this.status !== status) {
933
1356
  this.status = status;
@@ -940,105 +1363,44 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
940
1363
  }
941
1364
  areArraysEqual(a, b) {
942
1365
  if (a.length !== b.length) return false;
943
- for (let i = 0; i < a.length; i++) {
944
- if (a[i] !== b[i]) return false;
945
- }
1366
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
946
1367
  return true;
947
1368
  }
948
- getStatus() {
949
- return this.status;
950
- }
951
- getAccounts() {
952
- return this.connectedAccounts;
953
- }
954
- getChainId() {
955
- return this.chainId;
956
- }
957
- getChannelId() {
958
- return this.channelId;
959
- }
960
- /** Check if a stored session exists (without loading/restoring it). */
961
- hasStoredSession() {
962
- if (typeof localStorage === "undefined") return false;
963
- try {
964
- const raw = localStorage.getItem(this.storageKey);
965
- if (!raw) return false;
966
- const session = JSON.parse(raw);
967
- return Date.now() - session.createdAt <= SESSION_TTL_MS;
968
- } catch {
969
- return false;
970
- }
971
- }
972
- /**
973
- * Reset the connection and create a new channel for a fresh pairing.
974
- * Disconnects any existing session, generates a new channel ID and keys.
975
- */
976
- resetForNewChannel() {
977
- this.clearUnresponsiveTimer();
978
- this.clearSynRetryTimer();
979
- this.synSent = false;
980
- this.synAttempts = 0;
981
- this.walletPresent = false;
982
- if (this.keyExchange.areKeysExchanged()) {
983
- try {
984
- this.sendEncrypted({ type: "terminate" /* TERMINATE */ });
985
- } catch {
986
- }
987
- }
988
- this.socketClient.leaveChannel();
989
- this.socketClient.disconnect();
990
- this.clearSession();
991
- this.connectedAccounts = [];
992
- this.channelId = (0, import_uuid.v4)();
993
- this.ecies = new ECIESClient();
994
- this.keyExchange = new KeyExchange(this.ecies, true);
995
- this.socketClient = new SocketClient(this.relayUrl, "dapp");
996
- this.setupSocketListeners();
997
- this.setupKeyExchangeListeners();
998
- this.setStatus("disconnected" /* DISCONNECTED */);
999
- }
1000
- /** Disconnect and clean up. */
1001
- disconnect() {
1002
- this.clearUnresponsiveTimer();
1003
- this.clearSynRetryTimer();
1004
- this.synSent = false;
1005
- this.synAttempts = 0;
1006
- this.walletPresent = false;
1007
- if (this.keyExchange.areKeysExchanged()) {
1008
- try {
1009
- this.sendEncrypted({ type: "terminate" /* TERMINATE */ });
1010
- } catch {
1011
- }
1012
- }
1013
- this.socketClient.leaveChannel();
1014
- this.socketClient.disconnect();
1015
- this.setStatus("disconnected" /* DISCONNECTED */);
1016
- this.clearSession();
1017
- this.connectedAccounts = [];
1018
- }
1019
- // --- Session persistence ---
1020
- saveSession() {
1369
+ // ── Persistence ────────────────────────────────────────────
1370
+ async persistSession() {
1021
1371
  if (typeof localStorage === "undefined") return;
1372
+ if (!this.keyExchange) return;
1373
+ const persistedKex = await this.keyExchange.exportPersisted();
1374
+ if (!persistedKex) return;
1022
1375
  const session = {
1376
+ version: 2,
1023
1377
  channelId: this.channelId,
1024
- privateKey: this.ecies.getPrivateKeyHex(),
1025
- otherPublicKey: this.keyExchange.getOtherPublicKey(),
1378
+ keyExchange: persistedKex,
1026
1379
  dappMetadata: this.dappMetadata,
1027
1380
  connectedAccounts: this.connectedAccounts,
1028
1381
  chainId: this.chainId,
1029
- createdAt: Date.now(),
1382
+ createdAt: this.pendingRestore?.createdAt ?? Date.now(),
1030
1383
  lastActivity: Date.now()
1031
1384
  };
1032
- localStorage.setItem(this.storageKey, JSON.stringify(session));
1385
+ try {
1386
+ localStorage.setItem(this.storageKey, JSON.stringify(session));
1387
+ } catch (err) {
1388
+ warn("ConnectionManager", "localStorage.setItem failed:", err);
1389
+ }
1033
1390
  }
1034
- loadSession() {
1391
+ readStoredSession() {
1035
1392
  if (typeof localStorage === "undefined") return null;
1036
1393
  try {
1037
1394
  const raw = localStorage.getItem(this.storageKey);
1038
1395
  if (!raw) return null;
1039
1396
  const session = JSON.parse(raw);
1040
- if (Date.now() - session.createdAt > SESSION_TTL_MS) {
1041
- this.clearSession();
1397
+ if (session.version !== 2) {
1398
+ log("ConnectionManager", "Dropping legacy (pre-v2) session from storage");
1399
+ localStorage.removeItem(this.storageKey);
1400
+ return null;
1401
+ }
1402
+ if (!session.createdAt || Date.now() - session.createdAt > SESSION_TTL_MS) {
1403
+ localStorage.removeItem(this.storageKey);
1042
1404
  return null;
1043
1405
  }
1044
1406
  return session;
@@ -1052,8 +1414,6 @@ var _ConnectionManager = class _ConnectionManager extends import_eventemitter33.
1052
1414
  }
1053
1415
  };
1054
1416
  _ConnectionManager.MAX_RECONNECT_FAILURES = 5;
1055
- _ConnectionManager.SYN_RETRY_MS = 5e3;
1056
- _ConnectionManager.MAX_SYN_ATTEMPTS = 6;
1057
1417
  var ConnectionManager = _ConnectionManager;
1058
1418
 
1059
1419
  // src/utils/platform.ts
@@ -1253,34 +1613,45 @@ var QRLConnectProvider = class extends import_eventemitter34.default {
1253
1613
  pending.reject(new Error("Connection reset"));
1254
1614
  }
1255
1615
  this.pendingRequests.clear();
1256
- this.connectionManager.resetForNewChannel();
1616
+ await this.connectionManager.resetForNewChannel();
1257
1617
  return this.getConnectionURI();
1258
1618
  }
1259
1619
  /**
1260
- * Disconnect from wallet and clean up.
1620
+ * Disconnect from wallet and clean up. Returns once the TERMINATE has
1621
+ * either been flushed to the relay or the 800ms best-effort window has
1622
+ * elapsed — the wallet gets an instant disconnect instead of landing in
1623
+ * its stale-session grace period.
1261
1624
  */
1262
- disconnect() {
1625
+ async disconnect() {
1263
1626
  for (const [, pending] of this.pendingRequests) {
1264
1627
  pending.reject(new Error("Disconnected"));
1265
1628
  }
1266
1629
  this.pendingRequests.clear();
1267
- this.connectionManager.disconnect();
1630
+ await this.connectionManager.disconnect();
1268
1631
  }
1269
1632
  };
1270
1633
  // Annotate the CommonJS export names for ESM import in node:
1271
1634
  0 && (module.exports = {
1635
+ BLOB_LEN,
1636
+ CID_LEN,
1272
1637
  ConnectionManager,
1273
1638
  ConnectionStatus,
1274
1639
  DEFAULT_RELAY_URL,
1275
- ECIESClient,
1640
+ FP_LEN,
1276
1641
  KeyExchange,
1277
1642
  KeyExchangeMessageType,
1278
1643
  MessageType,
1644
+ PROTOCOL_VERSION,
1279
1645
  QRLConnect,
1280
1646
  QRLConnectProvider,
1281
1647
  RESTRICTED_METHODS,
1282
1648
  SocketClient,
1283
1649
  UNRESTRICTED_METHODS,
1650
+ cidFromString,
1651
+ cidRandom,
1652
+ cidToString,
1653
+ computeFingerprint,
1654
+ fingerprintEquals,
1284
1655
  generateConnectionURI,
1285
1656
  getAppStoreUrl,
1286
1657
  isMobileBrowser,