@qrlwallet/connect 0.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,16 +1,121 @@
1
1
  import EventEmitter from 'eventemitter3';
2
2
 
3
+ /**
4
+ * Post-quantum handshake (v2).
5
+ *
6
+ * Flow:
7
+ * dApp relay wallet
8
+ * ────────────────────────────────────────────────────────────────────
9
+ * initiate() → (pk, sk)
10
+ * QR(cid, pk) rendered scan QR
11
+ * receiveQR(cid, pk)
12
+ * → SYNACK{ct, c0}
13
+ * ◄── SYNACK ─────── send
14
+ * onSynAck(cid, SYNACK)
15
+ * → ACK{c1}
16
+ * send ACK ──► ──► ack onAck(ACK)
17
+ *
18
+ * Data phase: encryptMessage / decryptMessage use direction-tagged counter
19
+ * nonces and AAD = H_tx || seq to bind every ciphertext to the transcript.
20
+ */
21
+
22
+ interface Session {
23
+ cid: Uint8Array;
24
+ key: CryptoKey;
25
+ htx: Uint8Array;
26
+ sendDir: Uint8Array;
27
+ recvDir: Uint8Array;
28
+ sendSeq: number;
29
+ recvSeq: number;
30
+ }
31
+ /** Wire-safe persisted form for localStorage. */
32
+ interface PersistedSession {
33
+ cid: string;
34
+ kAeadRaw: string;
35
+ htx: string;
36
+ sendDir: string;
37
+ recvDir: string;
38
+ sendSeq: number;
39
+ recvSeq: number;
40
+ }
41
+ interface SynAckMessage {
42
+ type: KeyExchangeMessageType.SYNACK;
43
+ ct: string;
44
+ c0: string;
45
+ v: number;
46
+ }
47
+ interface AckMessage {
48
+ type: KeyExchangeMessageType.ACK;
49
+ c1: string;
50
+ v: number;
51
+ }
52
+ interface KeyExchangeEvents {
53
+ keys_exchanged: () => void;
54
+ step_change: (step: KeyExchangeMessageType) => void;
55
+ }
56
+ declare class KeyExchange extends EventEmitter<KeyExchangeEvents> {
57
+ private isOriginator;
58
+ private keypair;
59
+ private session;
60
+ private step;
61
+ private awaitingSynAck;
62
+ private awaitingAck;
63
+ private keysExchanged;
64
+ constructor(isOriginator: boolean, restored?: Session);
65
+ /**
66
+ * Originator (dApp) side: begin a fresh handshake by generating an
67
+ * ephemeral ML-KEM-768 keypair. Returns pk to embed in the QR URI.
68
+ */
69
+ initiate(): Uint8Array;
70
+ /**
71
+ * Originator: handle a SYNACK received over the relay.
72
+ * Returns an ACK wire message to send, or null if the SYNACK was a
73
+ * duplicate (handshake already complete).
74
+ */
75
+ onSynAck(cid: Uint8Array, msg: SynAckMessage): Promise<AckMessage | null>;
76
+ /**
77
+ * Responder (wallet) side: begin handshake from a scanned QR.
78
+ * Returns the SYNACK wire message to emit on the relay.
79
+ */
80
+ receiveQR(cid: Uint8Array, pk: Uint8Array): Promise<SynAckMessage>;
81
+ /**
82
+ * Responder: verify an incoming ACK and finalize the session.
83
+ * Duplicates are silently ignored (handshake is idempotent).
84
+ */
85
+ onAck(msg: AckMessage): Promise<void>;
86
+ /** Encrypt a string for the counterparty. Returns base64. */
87
+ encryptMessage(data: string): Promise<string>;
88
+ /** Decrypt a base64 ciphertext from the counterparty. */
89
+ decryptMessage(b64: string): Promise<string>;
90
+ /** Reset all state for a fresh handshake. Zeroes any live secret-key buffer. */
91
+ reset(): void;
92
+ private resetInternal;
93
+ areKeysExchanged(): boolean;
94
+ getSession(): Session | null;
95
+ getCurrentStep(): KeyExchangeMessageType;
96
+ /** Export the current session as a JSON-safe persisted record. */
97
+ exportPersisted(): Promise<PersistedSession | null>;
98
+ /** Rehydrate a Session from its persisted form. */
99
+ static sessionFromPersisted(p: PersistedSession): Promise<Session>;
100
+ }
101
+
3
102
  /** dApp metadata shown to user in approval UI */
4
103
  interface DAppMetadata {
5
104
  name: string;
6
105
  url: string;
7
106
  icon?: string;
8
107
  }
9
- /** Stored session for reconnection */
108
+ /**
109
+ * Stored session for reconnection.
110
+ *
111
+ * v2 persists the derived AES-256 session key (not the ML-KEM secret key) —
112
+ * the ML-KEM keypair is ephemeral and zeroized after the handshake.
113
+ * Re-pair (generate a new QR) to rotate the session key.
114
+ */
10
115
  interface DAppSession {
116
+ version: 2;
11
117
  channelId: string;
12
- privateKey: string;
13
- otherPublicKey: string | null;
118
+ keyExchange: PersistedSession;
14
119
  dappMetadata: DAppMetadata;
15
120
  connectedAccounts: string[];
16
121
  chainId: string;
@@ -26,7 +131,13 @@ interface PendingRequest {
26
131
  reject: (error: Error) => void;
27
132
  timestamp: number;
28
133
  }
29
- /** Key exchange message types */
134
+ /**
135
+ * Key-exchange wire-message types.
136
+ *
137
+ * v2 note: only SYNACK (wallet→dApp) and ACK (dApp→wallet) are transmitted
138
+ * over the relay. The SYN step is carried by the QR code itself — the dApp's
139
+ * ML-KEM-768 encapsulation key is embedded in the URI, not sent on the wire.
140
+ */
30
141
  declare enum KeyExchangeMessageType {
31
142
  SYN = "key_handshake_SYN",
32
143
  SYNACK = "key_handshake_SYNACK",
@@ -164,14 +275,18 @@ declare class QRLConnectProvider extends EventEmitter<ProviderEvents> {
164
275
  */
165
276
  newConnection(): Promise<string>;
166
277
  /**
167
- * Disconnect from wallet and clean up.
278
+ * Disconnect from wallet and clean up. Returns once the TERMINATE has
279
+ * either been flushed to the relay or the 800ms best-effort window has
280
+ * elapsed — the wallet gets an instant disconnect instead of landing in
281
+ * its stale-session grace period.
168
282
  */
169
- disconnect(): void;
283
+ disconnect(): Promise<void>;
170
284
  }
171
285
 
172
286
  /**
173
- * Connection Manager - Orchestrates socket connection, key exchange,
174
- * and encrypted message routing. Manages session persistence and reconnection.
287
+ * Connection Manager — orchestrates socket lifecycle, the post-quantum
288
+ * handshake, encrypted message routing, and session persistence for the
289
+ * dApp side of QRL Connect v2.
175
290
  */
176
291
 
177
292
  interface ConnectionManagerEvents {
@@ -188,7 +303,6 @@ interface ConnectionManagerEvents {
188
303
  }
189
304
  declare class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
190
305
  private socketClient;
191
- private ecies;
192
306
  private keyExchange;
193
307
  private status;
194
308
  private channelId;
@@ -198,14 +312,11 @@ declare class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
198
312
  private connectedAccounts;
199
313
  private storageKey;
200
314
  private unresponsiveTimer;
201
- private synRetryTimer;
202
315
  private failedReconnects;
203
- private synSent;
204
- private synAttempts;
205
316
  private walletPresent;
317
+ private pendingRestore;
318
+ private messageQueue;
206
319
  private static MAX_RECONNECT_FAILURES;
207
- private static SYN_RETRY_MS;
208
- private static MAX_SYN_ATTEMPTS;
209
320
  constructor(options: {
210
321
  dappMetadata: DAppMetadata;
211
322
  relayUrl?: string;
@@ -215,110 +326,56 @@ declare class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
215
326
  private setupSocketListeners;
216
327
  private setupKeyExchangeListeners;
217
328
  /**
218
- * Generate the connection URI and start listening for wallet connection.
329
+ * Generate a new v2 connection URI. Rotates channel id and keypair.
330
+ * Returns a `qrlconnect://?q=…` URI safe for QR-rendering or deep-link.
219
331
  */
220
332
  getConnectionURI(retryOnConflict?: boolean): Promise<string>;
221
333
  /**
222
334
  * Reconnect to an existing session.
335
+ * Returns false if there is nothing to restore.
223
336
  */
224
337
  reconnect(): Promise<boolean>;
225
338
  /**
226
- * Send a JSON-RPC request to the wallet.
339
+ * Send a JSON-RPC request to the wallet (async, fire-and-forget).
227
340
  */
228
341
  sendJsonRpc(request: JsonRpcRequest): void;
342
+ getStatus(): ConnectionStatus;
343
+ getAccounts(): string[];
344
+ getChainId(): string;
345
+ getChannelId(): string;
346
+ /** Check (sync) if a persisted session exists and has not expired. */
347
+ hasStoredSession(): boolean;
348
+ /**
349
+ * Best-effort TERMINATE delivery before we tear the socket down. Without
350
+ * awaiting, the socket.disconnect() below would win the race against the
351
+ * outbound emit, the wallet would only see `participants_changed:
352
+ * disconnect` and enter its stale-session grace period instead of an
353
+ * instant disconnect. Mirrors the wallet side's pattern.
354
+ */
355
+ private flushTerminate;
229
356
  /**
230
- * Handle an incoming relay message.
357
+ * Reset to a fresh channel + keypair. Sends TERMINATE to any live peer,
358
+ * drops the persisted session, and prepares a clean state for
359
+ * getConnectionURI() to be called again.
231
360
  */
361
+ resetForNewChannel(): Promise<void>;
362
+ disconnect(): Promise<void>;
363
+ private enqueueRelayMessage;
232
364
  private handleRelayMessage;
233
- private handleKeyExchangeMessage;
365
+ private handleSynAck;
234
366
  private handleDecryptedMessage;
235
- /** Send a plaintext (unencrypted) message through the relay. */
236
367
  private sendPlaintext;
237
- /** Send an encrypted message through the relay. */
238
368
  private sendEncrypted;
239
369
  private startUnresponsiveTimer;
240
370
  private clearUnresponsiveTimer;
241
- private sendSYN;
242
- private scheduleSynRetry;
243
- private clearSynRetryTimer;
244
371
  private setStatus;
245
372
  private isDappParticipantConflictError;
246
373
  private areArraysEqual;
247
- getStatus(): ConnectionStatus;
248
- getAccounts(): string[];
249
- getChainId(): string;
250
- getChannelId(): string;
251
- /** Check if a stored session exists (without loading/restoring it). */
252
- hasStoredSession(): boolean;
253
- /**
254
- * Reset the connection and create a new channel for a fresh pairing.
255
- * Disconnects any existing session, generates a new channel ID and keys.
256
- */
257
- resetForNewChannel(): void;
258
- /** Disconnect and clean up. */
259
- disconnect(): void;
260
- private saveSession;
261
- private loadSession;
374
+ private persistSession;
375
+ private readStoredSession;
262
376
  private clearSession;
263
377
  }
264
378
 
265
- /**
266
- * ECIES encryption/decryption client.
267
- * Adapted from MetaMask SDK's ECIES.ts pattern.
268
- */
269
- declare class ECIESClient {
270
- private privateKey;
271
- constructor(existingPrivateKey?: string);
272
- /** Get public key as hex string */
273
- getPublicKey(): string;
274
- /** Get private key as hex string (for session persistence) */
275
- getPrivateKeyHex(): string;
276
- /** Encrypt a string message for the given public key. Returns base64. */
277
- encrypt(data: string, otherPublicKey: string): string;
278
- /** Decrypt a base64-encoded message using our private key. */
279
- decrypt(encryptedBase64: string): string;
280
- }
281
-
282
- /**
283
- * Key Exchange - 3-step SYN/SYNACK/ACK handshake protocol.
284
- * Adapted from MetaMask SDK's KeyExchange.ts.
285
- */
286
-
287
- interface KeyExchangeEvents {
288
- keys_exchanged: () => void;
289
- step_change: (step: KeyExchangeMessageType) => void;
290
- }
291
- declare class KeyExchange extends EventEmitter<KeyExchangeEvents> {
292
- private ecies;
293
- private otherPublicKey;
294
- private keysExchanged;
295
- private awaitingSynAck;
296
- private awaitingAck;
297
- private step;
298
- private isOriginator;
299
- constructor(ecies: ECIESClient, isOriginator: boolean, otherPublicKey?: string);
300
- /** Get the SYN message to start the handshake (originator/dApp side). */
301
- createSYN(): object;
302
- /**
303
- * Process an incoming key exchange message.
304
- * Returns a response message to send back, or null if handshake is complete.
305
- */
306
- onMessage(message: {
307
- type: KeyExchangeMessageType;
308
- pubkey?: string;
309
- v?: number;
310
- }): object | null;
311
- /** Encrypt a message for the counterparty. */
312
- encryptMessage(data: string): string;
313
- /** Decrypt a message from the counterparty. */
314
- decryptMessage(encryptedBase64: string): string;
315
- /** Reset key exchange state for a fresh handshake (e.g. new QR code). */
316
- reset(): void;
317
- areKeysExchanged(): boolean;
318
- getOtherPublicKey(): string | null;
319
- getCurrentStep(): KeyExchangeMessageType;
320
- }
321
-
322
379
  /**
323
380
  * Socket.IO client wrapper for relay communication.
324
381
  */
@@ -334,24 +391,44 @@ interface SocketClientEvents {
334
391
  }) => void;
335
392
  error: (err: Error) => void;
336
393
  }
394
+ interface JoinResult {
395
+ bufferedMessages: unknown[];
396
+ /**
397
+ * Base64-encoded KEM public key the relay has bound to this channel.
398
+ * Populated for wallet joins (v2 protocol). `null` if the dApp hasn't
399
+ * registered a PK yet — wallet callers must treat this as a retry signal.
400
+ */
401
+ channelPublicKey: string | null;
402
+ }
337
403
  declare class SocketClient extends EventEmitter<SocketClientEvents> {
338
404
  private socket;
339
405
  private relayUrl;
340
406
  private channelId;
341
407
  private clientType;
408
+ private publicKeyBase64;
342
409
  private seq;
343
410
  private pendingJoin;
344
411
  constructor(relayUrl: string, clientType: 'dapp' | 'wallet');
412
+ /**
413
+ * dApp-only: stash the PK so it gets uploaded on every join_channel
414
+ * attempt (including auto-rejoins after reconnect).
415
+ */
416
+ setPublicKey(publicKeyBase64: string): void;
345
417
  /**
346
418
  * Connect to the relay server.
347
419
  */
348
420
  connect(): void;
349
421
  /**
350
- * Join a relay channel.
422
+ * Resolve or reject the currently-pending `joinChannel` promise, if it
423
+ * matches the given channelId. Clears the watchdog timer on settle so
424
+ * it doesn't fire after the fact.
351
425
  */
352
- joinChannel(channelId: string): Promise<{
353
- bufferedMessages: unknown[];
354
- }>;
426
+ private settlePendingJoin;
427
+ /**
428
+ * Join a relay channel. Resolves with any buffered messages plus the
429
+ * dApp's public key bound to the channel (for wallet joins).
430
+ */
431
+ joinChannel(channelId: string): Promise<JoinResult>;
355
432
  private joinChannelNow;
356
433
  /**
357
434
  * Send a message through the relay.
@@ -372,23 +449,67 @@ declare class SocketClient extends EventEmitter<SocketClientEvents> {
372
449
  getChannelId(): string | null;
373
450
  }
374
451
 
375
- interface QRConnectParams {
376
- channelId: string;
377
- pubKey: string;
378
- name: string;
379
- url: string;
380
- icon?: string;
381
- chainId: string;
382
- relayUrl: string;
383
- }
384
452
  /**
385
- * Generate a qrlconnect:// URI for QR code or deep link.
453
+ * v2 QR URI codec (PQP2).
454
+ *
455
+ * Format: qrlconnect://?q=<base45(PQP2 || cid || fp)>[&r=<relayUrl>]
456
+ *
457
+ * PQP2 = "PQP2" magic (4 B)
458
+ * cid = 16 bytes (UUIDv4 raw) (16 B)
459
+ * fp = SHA-256("pq-fp/v2" || cid || pk) (full 32B) (32 B)
460
+ * total (52 B)
461
+ *
462
+ * 52 bytes → ~78 alphanumeric chars after base45 → URI under 150 chars →
463
+ * fits a version-5 QR at ECC-M. Scannable from any 2015-vintage camera.
464
+ *
465
+ * Security: the public key is NOT carried in the QR. It's uploaded by the
466
+ * dApp to the relay at channel creation, the relay binds it to the cid, and
467
+ * the wallet fetches it via the join_channel ack. The 32-byte fp acts as
468
+ * the out-of-band commitment: the wallet rejects the PK served by the relay
469
+ * unless SHA-256("pq-fp/v2" || cid || pk) matches the fp from the QR.
470
+ * Full-width SHA-256 (2^128 collision resistance) rules out brute-force
471
+ * substitution of a maliciously-crafted PK with the same fingerprint.
472
+ *
473
+ * Legacy v2.0 URIs (magic=PQP1, 1208-byte blob with embedded PK) are
474
+ * rejected with a clear error by parseConnectionURI — this is a hard
475
+ * break before 2.0.0 ships, no backcompat.
386
476
  */
387
- declare function generateConnectionURI(params: QRConnectParams): string;
477
+ declare const CID_LEN = 16;
478
+ declare const FP_LEN = 32;
479
+ declare const BLOB_LEN: number;
388
480
  /**
389
- * Parse a qrlconnect:// URI back into its components.
481
+ * Compute the full 32-byte fingerprint binding (label || cid || pk).
482
+ * Exported so the wallet side can re-derive and verify.
390
483
  */
391
- declare function parseConnectionURI(uri: string): QRConnectParams | null;
484
+ declare function computeFingerprint(cid: Uint8Array, pk: Uint8Array): Promise<Uint8Array>;
485
+ /**
486
+ * Constant-time comparison of two byte arrays. Both must be the same length.
487
+ * The verifier MUST use this rather than `===` or `indexOf` so timing signals
488
+ * don't leak which byte of `fp` diverged first.
489
+ */
490
+ declare function fingerprintEquals(a: Uint8Array, b: Uint8Array): boolean;
491
+ /**
492
+ * Encode (cid, pk) as a qrlconnect:// URI. Note the PK is not stored in the
493
+ * URI — we compute its fingerprint and embed only that. The caller must
494
+ * upload the PK to the relay separately before publishing this URI.
495
+ *
496
+ * The optional `relayUrl` rides as a sibling query param (not inside the
497
+ * fp-bound blob) so a tampered relay can cause the pairing to fail but
498
+ * cannot substitute the PK — the fp still pins that.
499
+ */
500
+ declare function generateConnectionURI(cid: Uint8Array, pk: Uint8Array, relayUrl?: string): Promise<string>;
501
+ interface ParsedURI {
502
+ cid: Uint8Array;
503
+ fp: Uint8Array;
504
+ relayUrl?: string;
505
+ }
506
+ declare function parseConnectionURI(uri: string): Promise<ParsedURI>;
507
+ /** Convert 16 raw cid bytes to RFC 4122 UUID hex string. */
508
+ declare function cidToString(cid: Uint8Array): string;
509
+ /** Convert RFC 4122 UUID string to 16 raw cid bytes. */
510
+ declare function cidFromString(s: string): Uint8Array;
511
+ /** Generate 16 random bytes for a fresh channel ID. */
512
+ declare function cidRandom(): Uint8Array;
392
513
 
393
514
  /**
394
515
  * Detect if the current browser is on a mobile device.
@@ -401,9 +522,10 @@ declare function isMobileBrowser(): boolean;
401
522
  declare function getAppStoreUrl(): string;
402
523
 
403
524
  declare const DEFAULT_RELAY_URL = "https://qrlwallet.com";
525
+ declare const PROTOCOL_VERSION = 2;
404
526
  /** RPC methods that require user approval in the wallet */
405
527
  declare const RESTRICTED_METHODS: Set<string>;
406
528
  /** RPC methods that can be auto-proxied without approval */
407
529
  declare const UNRESTRICTED_METHODS: Set<string>;
408
530
 
409
- export { ConnectionManager, ConnectionStatus, type DAppMetadata, type DAppSession, DEFAULT_RELAY_URL, ECIESClient, type JsonRpcRequest, type JsonRpcResponse, KeyExchange, KeyExchangeMessageType, MessageType, type PendingRequest, type ProviderEvents, QRLConnectProvider as QRLConnect, type QRLConnectOptions, QRLConnectProvider, RESTRICTED_METHODS, type RelayMessage, SocketClient, UNRESTRICTED_METHODS, generateConnectionURI, getAppStoreUrl, isMobileBrowser, parseConnectionURI };
531
+ export { type AckMessage, BLOB_LEN, CID_LEN, ConnectionManager, ConnectionStatus, type DAppMetadata, type DAppSession, DEFAULT_RELAY_URL, FP_LEN, type JsonRpcRequest, type JsonRpcResponse, KeyExchange, KeyExchangeMessageType, MessageType, PROTOCOL_VERSION, type ParsedURI, type PendingRequest, type PersistedSession, type ProviderEvents, QRLConnectProvider as QRLConnect, type QRLConnectOptions, QRLConnectProvider, RESTRICTED_METHODS, type RelayMessage, type Session, SocketClient, type SynAckMessage, UNRESTRICTED_METHODS, cidFromString, cidRandom, cidToString, computeFingerprint, fingerprintEquals, generateConnectionURI, getAppStoreUrl, isMobileBrowser, parseConnectionURI };