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