@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/README.md +10 -31
- package/dist/index.d.mts +235 -113
- package/dist/index.d.ts +235 -113
- package/dist/index.js +806 -435
- package/dist/index.mjs +797 -434
- package/package.json +2 -3
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
|
-
|
|
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/
|
|
60
|
-
var
|
|
67
|
+
// src/KeyExchange.ts
|
|
68
|
+
var import_eventemitter3 = __toESM(require("eventemitter3"));
|
|
61
69
|
|
|
62
|
-
// src/
|
|
63
|
-
var
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
console.log(`[QRLConnect:${tag}]`, ...args);
|
|
70
|
-
}
|
|
86
|
+
function bs(u) {
|
|
87
|
+
return u;
|
|
71
88
|
}
|
|
72
|
-
function
|
|
73
|
-
|
|
89
|
+
function kemKeygen() {
|
|
90
|
+
const { publicKey, secretKey } = import_ml_kem.ml_kem768.keygen();
|
|
91
|
+
return { pk: publicKey, sk: secretKey };
|
|
74
92
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
184
|
+
return btoa(bin);
|
|
86
185
|
}
|
|
87
|
-
function fromBase64(
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
for (let i = 0; i <
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
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(
|
|
311
|
+
constructor(isOriginator, restored) {
|
|
222
312
|
super();
|
|
223
|
-
this.
|
|
224
|
-
this.
|
|
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.
|
|
228
|
-
this.ecies = ecies;
|
|
318
|
+
this.keysExchanged = false;
|
|
229
319
|
this.isOriginator = isOriginator;
|
|
230
|
-
if (
|
|
231
|
-
this.
|
|
320
|
+
if (restored) {
|
|
321
|
+
this.session = restored;
|
|
232
322
|
this.keysExchanged = true;
|
|
233
|
-
|
|
323
|
+
this.step = "key_handshake_ACK" /* ACK */;
|
|
324
|
+
log("KeyExchange", "Hydrated from persisted session");
|
|
234
325
|
}
|
|
235
326
|
}
|
|
236
|
-
/**
|
|
237
|
-
|
|
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: "
|
|
243
|
-
|
|
393
|
+
type: "key_handshake_ACK" /* ACK */,
|
|
394
|
+
c1: toBase64(c1),
|
|
244
395
|
v: PROTOCOL_VERSION
|
|
245
396
|
};
|
|
246
397
|
}
|
|
247
398
|
/**
|
|
248
|
-
*
|
|
249
|
-
* Returns
|
|
399
|
+
* Responder (wallet) side: begin handshake from a scanned QR.
|
|
400
|
+
* Returns the SYNACK wire message to emit on the relay.
|
|
250
401
|
*/
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
/**
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
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
|
-
|
|
507
|
+
if (emit) this.emit("step_change", this.step);
|
|
337
508
|
}
|
|
338
509
|
areKeysExchanged() {
|
|
339
510
|
return this.keysExchanged;
|
|
340
511
|
}
|
|
341
|
-
|
|
342
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
690
|
+
payload,
|
|
457
691
|
(response) => {
|
|
458
692
|
if (response.success) {
|
|
459
693
|
log("Socket", `Joined channel ${channelId}`);
|
|
460
|
-
resolve({
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
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
|
-
|
|
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
|
|
547
|
-
|
|
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
|
-
|
|
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
|
|
589
|
-
if (
|
|
590
|
-
this.channelId =
|
|
591
|
-
this.
|
|
592
|
-
this.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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.
|
|
1013
|
+
this.setStatus("waiting" /* WAITING */);
|
|
628
1014
|
}
|
|
629
1015
|
});
|
|
630
|
-
this.socketClient.on("disconnected", (
|
|
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.
|
|
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
|
|
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 (
|
|
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.
|
|
680
|
-
this.
|
|
681
|
-
this.
|
|
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
|
|
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.
|
|
1077
|
+
this.pendingRestore = null;
|
|
698
1078
|
this.walletPresent = false;
|
|
699
|
-
this.
|
|
700
|
-
|
|
701
|
-
|
|
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.
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
735
|
-
if (!session) return false;
|
|
1117
|
+
if (!this.pendingRestore) return false;
|
|
736
1118
|
this.setStatus("reconnecting" /* RECONNECTING */);
|
|
737
1119
|
this.walletPresent = false;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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.
|
|
1135
|
+
this.enqueueRelayMessage(msg);
|
|
746
1136
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
792
|
-
this.
|
|
793
|
-
|
|
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
|
|
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
|
-
|
|
1271
|
+
async handleSynAck(msg) {
|
|
1272
|
+
if (!this.keyExchange) return;
|
|
809
1273
|
this.setStatus("key_exchange" /* KEY_EXCHANGE */);
|
|
810
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1385
|
+
try {
|
|
1386
|
+
localStorage.setItem(this.storageKey, JSON.stringify(session));
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
warn("ConnectionManager", "localStorage.setItem failed:", err);
|
|
1389
|
+
}
|
|
1033
1390
|
}
|
|
1034
|
-
|
|
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 (
|
|
1041
|
-
|
|
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
|
-
|
|
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,
|