walletpair-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DApp-side WalletPair session.
|
|
3
|
+
*
|
|
4
|
+
* Manages the full lifecycle: create channel → QR → wallet joins → accept →
|
|
5
|
+
* encrypted request/response + events.
|
|
6
|
+
*/
|
|
7
|
+
import { b64urlDecode, b64urlEncode, buildPairingUri, bytesToHex, canonicalJson, computeSessionFingerprint, computeSharedSecret, deriveDirectionalSessionKeys, deriveJoinEncryptionKey, deriveSessionKey, generateChannelId, generateX25519KeyPair, hexToBytes, sealPayload, signSnapshot, unsealJoin, unsealPayload, verifySnapshot, } from './crypto.js';
|
|
8
|
+
import { Emitter } from './emitter.js';
|
|
9
|
+
const BACKOFF = [1000, 2000, 5000, 10000, 30000];
|
|
10
|
+
const DEFAULT_REQUEST_TIMEOUT = 120_000;
|
|
11
|
+
const PENDING_ACCEPT_TIMEOUT = 60_000;
|
|
12
|
+
const MAX_SEND_SEQ = 2 ** 31;
|
|
13
|
+
const MAX_PENDING_REQUESTS = 32; // §15 rule 11
|
|
14
|
+
const MAX_MESSAGE_BYTES = 65536; // §15 rule 10: 64 KB
|
|
15
|
+
const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours (§16 rule 16)
|
|
16
|
+
function isPromiseLike(value) {
|
|
17
|
+
return !!value && typeof value.then === 'function';
|
|
18
|
+
}
|
|
19
|
+
function validateCapabilities(cap) {
|
|
20
|
+
if (cap == null || typeof cap !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const c = cap;
|
|
23
|
+
return Array.isArray(c.methods) && Array.isArray(c.events) && Array.isArray(c.chains);
|
|
24
|
+
}
|
|
25
|
+
export class DAppSession extends Emitter {
|
|
26
|
+
phase = 'idle';
|
|
27
|
+
/** Channel ID (hex). Available after createPairing(). */
|
|
28
|
+
channelId = '';
|
|
29
|
+
/** Pairing URI. Available after createPairing(). */
|
|
30
|
+
pairingUri = '';
|
|
31
|
+
/** 4-digit session fingerprint. Available after createPairing(). */
|
|
32
|
+
sessionFingerprint = '';
|
|
33
|
+
/** Remote wallet capabilities. Available after wallet joins. */
|
|
34
|
+
walletCapabilities = undefined;
|
|
35
|
+
/** Remote wallet metadata. Available after wallet joins. */
|
|
36
|
+
walletMeta = undefined;
|
|
37
|
+
approvedCapabilities = undefined;
|
|
38
|
+
approvedWalletMeta = undefined;
|
|
39
|
+
approvedWalletPubKeyB64 = undefined;
|
|
40
|
+
approvedScopeRecorded = false;
|
|
41
|
+
transport;
|
|
42
|
+
meta;
|
|
43
|
+
declaredMethods;
|
|
44
|
+
declaredChains;
|
|
45
|
+
requestTimeout;
|
|
46
|
+
autoAccept;
|
|
47
|
+
/** Session lifetime in ms (§16 rule 16). */
|
|
48
|
+
sessionTtl;
|
|
49
|
+
sessionTtlTimer = null;
|
|
50
|
+
sessionStartTime = null;
|
|
51
|
+
privKey;
|
|
52
|
+
pubKeyB64 = '';
|
|
53
|
+
remotePubKey = null;
|
|
54
|
+
sessionKey = null;
|
|
55
|
+
sendKey = null;
|
|
56
|
+
recvKey = null;
|
|
57
|
+
sendSeq = 0;
|
|
58
|
+
recvSeq = -1;
|
|
59
|
+
paired = false;
|
|
60
|
+
intentionalClose = false;
|
|
61
|
+
reqCounter = 0;
|
|
62
|
+
pendingRequests = new Map();
|
|
63
|
+
persistence;
|
|
64
|
+
pendingAcceptTimer = null;
|
|
65
|
+
reconnectTimer = null;
|
|
66
|
+
reconnectAttempt = 0;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
super();
|
|
69
|
+
this.transport = options.transport;
|
|
70
|
+
this.meta = options.meta;
|
|
71
|
+
this.declaredMethods = options.methods;
|
|
72
|
+
this.declaredChains = options.chains;
|
|
73
|
+
this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
74
|
+
this.autoAccept = options.autoAccept ?? true;
|
|
75
|
+
this.sessionTtl = options.sessionTtl ?? DEFAULT_SESSION_TTL;
|
|
76
|
+
this.persistence = options.persistence;
|
|
77
|
+
this.transport.onMessage((msg) => this.handleMessage(msg));
|
|
78
|
+
this.transport.onClose(() => this.handleTransportClose());
|
|
79
|
+
}
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
// Public API
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Create a new pairing channel and return the pairing URI for QR display.
|
|
85
|
+
*
|
|
86
|
+
* @param options.deferTransport - If true, don't connect the transport yet.
|
|
87
|
+
* Call `connectTransport()` later (e.g. after user scans QR in BLE mode).
|
|
88
|
+
*/
|
|
89
|
+
async createPairing(options) {
|
|
90
|
+
this.intentionalClose = false;
|
|
91
|
+
const kp = generateX25519KeyPair();
|
|
92
|
+
this.privKey = kp.privateKey;
|
|
93
|
+
this.pubKeyB64 = kp.publicKeyB64;
|
|
94
|
+
this.channelId = generateChannelId();
|
|
95
|
+
this.sendSeq = 0;
|
|
96
|
+
this.recvSeq = -1;
|
|
97
|
+
this.remotePubKey = null;
|
|
98
|
+
this.sessionKey = null;
|
|
99
|
+
this.sendKey = null;
|
|
100
|
+
this.recvKey = null;
|
|
101
|
+
this.sessionStartTime = null;
|
|
102
|
+
this.clearSessionTtl();
|
|
103
|
+
this.approvedCapabilities = undefined;
|
|
104
|
+
this.approvedWalletMeta = undefined;
|
|
105
|
+
this.approvedWalletPubKeyB64 = undefined;
|
|
106
|
+
this.approvedScopeRecorded = false;
|
|
107
|
+
this.reqCounter = 0;
|
|
108
|
+
this.paired = false;
|
|
109
|
+
// Build pairing URI first (before transport connect, so BLE can show QR first)
|
|
110
|
+
let relayUrl;
|
|
111
|
+
const transportWithUrl = this.transport;
|
|
112
|
+
if (typeof transportWithUrl.url === 'string') {
|
|
113
|
+
relayUrl = transportWithUrl.url;
|
|
114
|
+
}
|
|
115
|
+
this.pairingUri = buildPairingUri({
|
|
116
|
+
channelId: this.channelId,
|
|
117
|
+
pubkeyB64: this.pubKeyB64,
|
|
118
|
+
relayUrl,
|
|
119
|
+
name: this.meta.name,
|
|
120
|
+
url: this.meta.url,
|
|
121
|
+
icon: this.meta.icon,
|
|
122
|
+
methods: this.declaredMethods,
|
|
123
|
+
chains: this.declaredChains,
|
|
124
|
+
});
|
|
125
|
+
this.sessionFingerprint = computeSessionFingerprint(this.channelId, this.pubKeyB64);
|
|
126
|
+
this.emit('pairingUri', this.pairingUri);
|
|
127
|
+
this.emit('sessionFingerprint', this.sessionFingerprint);
|
|
128
|
+
if (!options?.deferTransport) {
|
|
129
|
+
await this.connectTransport();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.setPhase('waiting');
|
|
133
|
+
}
|
|
134
|
+
return this.pairingUri;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Connect the transport and send the `create` message.
|
|
138
|
+
* Call this after `createPairing({ deferTransport: true })` when the user
|
|
139
|
+
* is ready (e.g. after showing QR and before BLE scan).
|
|
140
|
+
*/
|
|
141
|
+
async connectTransport() {
|
|
142
|
+
this.setTransportChannelHint();
|
|
143
|
+
await this.transport.connect();
|
|
144
|
+
this.setPhase('waiting');
|
|
145
|
+
this.sendRaw({
|
|
146
|
+
v: 1,
|
|
147
|
+
t: 'create',
|
|
148
|
+
ch: this.channelId,
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
from: this.pubKeyB64,
|
|
151
|
+
body: { meta: this.meta },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Accept the wallet after sealed_join verification. */
|
|
155
|
+
acceptWallet() {
|
|
156
|
+
if (this.phase !== 'pending_accept' || !this.remotePubKey)
|
|
157
|
+
return;
|
|
158
|
+
this.clearPendingAcceptTimer();
|
|
159
|
+
this.doAccept();
|
|
160
|
+
}
|
|
161
|
+
/** Reject the wallet. */
|
|
162
|
+
rejectWallet() {
|
|
163
|
+
if (!this.remotePubKey)
|
|
164
|
+
return;
|
|
165
|
+
this.sendRaw({
|
|
166
|
+
v: 1,
|
|
167
|
+
t: 'close',
|
|
168
|
+
ch: this.channelId,
|
|
169
|
+
ts: Date.now(),
|
|
170
|
+
from: this.pubKeyB64,
|
|
171
|
+
body: { reason: 'user_rejected' },
|
|
172
|
+
});
|
|
173
|
+
this.close();
|
|
174
|
+
}
|
|
175
|
+
/** Send an encrypted request to the wallet. Returns the decrypted response. */
|
|
176
|
+
request(method, params) {
|
|
177
|
+
if (this.phase !== 'connected' || !this.sendKey) {
|
|
178
|
+
return Promise.reject(new Error('Not connected'));
|
|
179
|
+
}
|
|
180
|
+
// §15 rule 11: max 32 pending requests
|
|
181
|
+
if (this.pendingRequests.size >= MAX_PENDING_REQUESTS) {
|
|
182
|
+
return Promise.reject(new Error('Too many pending requests'));
|
|
183
|
+
}
|
|
184
|
+
const id = `req-${++this.reqCounter}`;
|
|
185
|
+
// Always seal: even parameterless requests must be authenticated via AEAD
|
|
186
|
+
// to prevent method injection by a malicious relay.
|
|
187
|
+
const send = (seq) => {
|
|
188
|
+
// AAD: no method field — real method goes inside sealed payload
|
|
189
|
+
const hdr = { type: 'req', from: this.pubKeyB64, id };
|
|
190
|
+
const sealedParams = {
|
|
191
|
+
_method: method,
|
|
192
|
+
...(params && typeof params === 'object'
|
|
193
|
+
? params
|
|
194
|
+
: { _params: params ?? {} }),
|
|
195
|
+
};
|
|
196
|
+
const sealed = sealPayload(this.sendKey, this.channelId, seq, sealedParams, hdr);
|
|
197
|
+
const msg = {
|
|
198
|
+
v: 1,
|
|
199
|
+
t: 'req',
|
|
200
|
+
ch: this.channelId,
|
|
201
|
+
ts: Date.now(),
|
|
202
|
+
from: this.pubKeyB64,
|
|
203
|
+
body: { id, sealed },
|
|
204
|
+
};
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
this.pendingRequests.delete(id);
|
|
208
|
+
reject(new Error(`Request ${method} timed out`));
|
|
209
|
+
}, this.requestTimeout);
|
|
210
|
+
this.pendingRequests.set(id, { id, method, resolve: resolve, reject, timer });
|
|
211
|
+
this.sendRaw(msg);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const seqOrPromise = this.nextSendSeq();
|
|
216
|
+
return isPromiseLike(seqOrPromise) ? seqOrPromise.then(send) : send(seqOrPromise);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return Promise.reject(error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Send ping. */
|
|
223
|
+
ping() {
|
|
224
|
+
if (this.phase !== 'connected')
|
|
225
|
+
return;
|
|
226
|
+
this.sendRaw({
|
|
227
|
+
v: 1,
|
|
228
|
+
t: 'ping',
|
|
229
|
+
ch: this.channelId,
|
|
230
|
+
ts: Date.now(),
|
|
231
|
+
from: this.pubKeyB64,
|
|
232
|
+
body: {},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** Gracefully close the session. */
|
|
236
|
+
close(reason = 'normal') {
|
|
237
|
+
this.intentionalClose = true;
|
|
238
|
+
this.clearPendingAcceptTimer();
|
|
239
|
+
this.clearSessionTtl();
|
|
240
|
+
this.stopReconnect();
|
|
241
|
+
for (const [, req] of this.pendingRequests) {
|
|
242
|
+
if (req.timer)
|
|
243
|
+
clearTimeout(req.timer);
|
|
244
|
+
req.reject(new Error('Session closed'));
|
|
245
|
+
}
|
|
246
|
+
this.pendingRequests.clear();
|
|
247
|
+
if (this.channelId) {
|
|
248
|
+
this.sendRaw({
|
|
249
|
+
v: 1,
|
|
250
|
+
t: 'close',
|
|
251
|
+
ch: this.channelId,
|
|
252
|
+
ts: Date.now(),
|
|
253
|
+
from: this.pubKeyB64,
|
|
254
|
+
body: { reason },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
this.clearPersistence();
|
|
258
|
+
this.transport.disconnect();
|
|
259
|
+
this.setPhase('closed');
|
|
260
|
+
}
|
|
261
|
+
/** Destroy the session and release all resources. */
|
|
262
|
+
destroy() {
|
|
263
|
+
this.close();
|
|
264
|
+
this.removeAll();
|
|
265
|
+
// Wipe sensitive key material (§20.7)
|
|
266
|
+
if (this.privKey)
|
|
267
|
+
this.privKey.fill(0);
|
|
268
|
+
if (this.sessionKey)
|
|
269
|
+
this.sessionKey.fill(0);
|
|
270
|
+
if (this.sendKey)
|
|
271
|
+
this.sendKey.fill(0);
|
|
272
|
+
if (this.recvKey)
|
|
273
|
+
this.recvKey.fill(0);
|
|
274
|
+
this.sessionKey = null;
|
|
275
|
+
this.sendKey = null;
|
|
276
|
+
this.recvKey = null;
|
|
277
|
+
}
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
// State serialization (for persistence across page reloads)
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
serialize() {
|
|
282
|
+
const json = JSON.stringify({
|
|
283
|
+
channelId: this.channelId,
|
|
284
|
+
privKey: bytesToHex(this.privKey),
|
|
285
|
+
pubKeyB64: this.pubKeyB64,
|
|
286
|
+
remotePubKeyB64: this.remotePubKey ? b64urlEncode(this.remotePubKey) : null,
|
|
287
|
+
sendKey: this.sendKey ? bytesToHex(this.sendKey) : null,
|
|
288
|
+
recvKey: this.recvKey ? bytesToHex(this.recvKey) : null,
|
|
289
|
+
sendSeq: this.sendSeq,
|
|
290
|
+
recvSeq: this.recvSeq,
|
|
291
|
+
reqCounter: this.reqCounter,
|
|
292
|
+
paired: this.paired,
|
|
293
|
+
dappMeta: this.meta,
|
|
294
|
+
approvedScopeRecorded: this.approvedScopeRecorded,
|
|
295
|
+
approvedCapabilities: this.approvedCapabilities ?? null,
|
|
296
|
+
approvedWalletMeta: this.approvedWalletMeta ?? null,
|
|
297
|
+
approvedWalletPubKeyB64: this.approvedWalletPubKeyB64 ?? null,
|
|
298
|
+
sessionStartTime: this.sessionStartTime,
|
|
299
|
+
});
|
|
300
|
+
// HMAC-sign the snapshot so tampered storage (e.g. via XSS) is detected on restore
|
|
301
|
+
return this.sendKey ? signSnapshot(this.sendKey, json) : json;
|
|
302
|
+
}
|
|
303
|
+
async restoreFromPersistence() {
|
|
304
|
+
if (!this.persistence?.load)
|
|
305
|
+
return false;
|
|
306
|
+
const json = await this.persistence.load();
|
|
307
|
+
return json ? this.restore(json) : false;
|
|
308
|
+
}
|
|
309
|
+
restore(signed) {
|
|
310
|
+
try {
|
|
311
|
+
// Try HMAC-verified format first: "<hex-mac>.<json>"
|
|
312
|
+
// Fall back to plain JSON for backward compatibility with unsigned snapshots
|
|
313
|
+
let json;
|
|
314
|
+
if (signed.length > 65 && signed[64] === '.') {
|
|
315
|
+
// Extract sendKey from the JSON to verify HMAC
|
|
316
|
+
const candidateJson = signed.slice(65);
|
|
317
|
+
const d0 = JSON.parse(candidateJson);
|
|
318
|
+
if (!d0.sendKey)
|
|
319
|
+
return false;
|
|
320
|
+
const sendKey = hexToBytes(d0.sendKey);
|
|
321
|
+
const verified = verifySnapshot(sendKey, signed);
|
|
322
|
+
if (!verified)
|
|
323
|
+
return false; // tampered
|
|
324
|
+
json = verified;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
json = signed;
|
|
328
|
+
}
|
|
329
|
+
const d = JSON.parse(json);
|
|
330
|
+
if (!d.channelId || !d.privKey)
|
|
331
|
+
return false;
|
|
332
|
+
this.channelId = d.channelId;
|
|
333
|
+
this.privKey = hexToBytes(d.privKey);
|
|
334
|
+
this.pubKeyB64 = d.pubKeyB64;
|
|
335
|
+
this.remotePubKey = d.remotePubKeyB64 ? b64urlDecode(d.remotePubKeyB64) : null;
|
|
336
|
+
this.sendKey = d.sendKey ? hexToBytes(d.sendKey) : null;
|
|
337
|
+
this.recvKey = d.recvKey ? hexToBytes(d.recvKey) : null;
|
|
338
|
+
if (!this.sendKey || !this.recvKey)
|
|
339
|
+
return false;
|
|
340
|
+
this.sendSeq = d.sendSeq ?? 0;
|
|
341
|
+
this.recvSeq = d.recvSeq ?? -1;
|
|
342
|
+
this.reqCounter = d.reqCounter || 0;
|
|
343
|
+
this.paired = d.paired || false;
|
|
344
|
+
this.approvedScopeRecorded = d.approvedScopeRecorded === true;
|
|
345
|
+
this.approvedCapabilities = d.approvedCapabilities ?? undefined;
|
|
346
|
+
this.approvedWalletMeta = d.approvedWalletMeta ?? undefined;
|
|
347
|
+
this.approvedWalletPubKeyB64 = d.approvedWalletPubKeyB64 ?? d.remotePubKeyB64 ?? undefined;
|
|
348
|
+
this.walletCapabilities = this.approvedCapabilities;
|
|
349
|
+
this.walletMeta = this.approvedWalletMeta;
|
|
350
|
+
this.meta =
|
|
351
|
+
d.dappMeta ??
|
|
352
|
+
(d.dappName ? { name: d.dappName, description: '', url: '', icon: '' } : this.meta);
|
|
353
|
+
this.sessionStartTime = d.sessionStartTime ?? null;
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** Reconnect after restoring state. */
|
|
361
|
+
async reconnect() {
|
|
362
|
+
this.intentionalClose = false;
|
|
363
|
+
this.stopReconnect();
|
|
364
|
+
this.setPhase('disconnected');
|
|
365
|
+
this.reconnectAttempt = 0;
|
|
366
|
+
await this.doReconnectAttempt();
|
|
367
|
+
}
|
|
368
|
+
// -------------------------------------------------------------------------
|
|
369
|
+
// Internal: message handling
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
handleMessage(msg) {
|
|
372
|
+
// §2: Peers MUST reject any peer-sent message where from equals "_adapter"
|
|
373
|
+
if (msg.t !== 'ready' && msg.t !== 'terminate' && msg.from === '_adapter') {
|
|
374
|
+
this.emit('error', new Error('Rejected message with spoofed _adapter from'));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// §15 rule 12: reject unsupported protocol version
|
|
378
|
+
if (msg.v !== 1) {
|
|
379
|
+
this.close('unsupported_version');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
switch (msg.t) {
|
|
383
|
+
case 'ready': {
|
|
384
|
+
const readyBody = msg.body;
|
|
385
|
+
this.stopReconnect();
|
|
386
|
+
if (readyBody.state === 'connected') {
|
|
387
|
+
const expectedRemote = this.remotePubKey ? b64urlEncode(this.remotePubKey) : null;
|
|
388
|
+
if (!expectedRemote || readyBody.remote !== expectedRemote) {
|
|
389
|
+
this.emit('error', new Error('Connected remote does not match paired wallet'));
|
|
390
|
+
this.close();
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (readyBody.state === 'waiting') {
|
|
395
|
+
this.setPhase('waiting');
|
|
396
|
+
}
|
|
397
|
+
else if (readyBody.state === 'connected') {
|
|
398
|
+
this.setPhase('connected');
|
|
399
|
+
this.startSessionTtl();
|
|
400
|
+
this.persistSnapshotAsync();
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'join': {
|
|
405
|
+
const joinBody = msg.body;
|
|
406
|
+
const joinPubKey = msg.from;
|
|
407
|
+
if (!joinPubKey) {
|
|
408
|
+
this.emit('error', new Error('Malformed wallet join: missing from'));
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
let remoteBytes;
|
|
412
|
+
try {
|
|
413
|
+
remoteBytes = b64urlDecode(joinPubKey);
|
|
414
|
+
if (remoteBytes.length !== 32)
|
|
415
|
+
throw new Error('Invalid wallet public key length');
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
this.sendRaw({
|
|
419
|
+
v: 1,
|
|
420
|
+
t: 'close',
|
|
421
|
+
ch: this.channelId,
|
|
422
|
+
ts: Date.now(),
|
|
423
|
+
from: this.pubKeyB64,
|
|
424
|
+
body: { reason: 'protocol_error' },
|
|
425
|
+
});
|
|
426
|
+
this.emit('error', new Error('Malformed wallet public key'));
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
this.remotePubKey = remoteBytes;
|
|
430
|
+
const shared = computeSharedSecret(this.privKey, this.remotePubKey);
|
|
431
|
+
const rootKey = deriveSessionKey(shared, this.channelId);
|
|
432
|
+
// §20.7: erase shared_secret immediately
|
|
433
|
+
shared.fill(0);
|
|
434
|
+
// For reconnect (sealed_join is null), skip sealed_join decryption
|
|
435
|
+
let joinCapabilities;
|
|
436
|
+
let joinMeta;
|
|
437
|
+
if (joinBody.sealed_join === null && this.paired) {
|
|
438
|
+
// Reconnect — capabilities/meta come from previously approved scope
|
|
439
|
+
joinCapabilities = this.approvedCapabilities;
|
|
440
|
+
joinMeta = this.approvedWalletMeta;
|
|
441
|
+
}
|
|
442
|
+
else if (joinBody.sealed_join && typeof joinBody.sealed_join === 'string') {
|
|
443
|
+
// Decrypt sealed_join (private handshake §7.5)
|
|
444
|
+
let joinKey = null;
|
|
445
|
+
try {
|
|
446
|
+
joinKey = deriveJoinEncryptionKey(rootKey, this.channelId);
|
|
447
|
+
const decrypted = unsealJoin(joinKey, this.channelId, joinBody.sealed_join);
|
|
448
|
+
joinCapabilities = decrypted.capabilities;
|
|
449
|
+
joinMeta = decrypted.meta;
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
this.sendRaw({
|
|
453
|
+
v: 1,
|
|
454
|
+
t: 'close',
|
|
455
|
+
ch: this.channelId,
|
|
456
|
+
ts: Date.now(),
|
|
457
|
+
from: this.pubKeyB64,
|
|
458
|
+
body: { reason: 'decryption_failed' },
|
|
459
|
+
});
|
|
460
|
+
this.emit('error', new Error('Failed to decrypt sealed_join'));
|
|
461
|
+
rootKey.fill(0);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
// §20.7: erase join_encryption_key after one-shot use.
|
|
466
|
+
if (joinKey)
|
|
467
|
+
joinKey.fill(0);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
this.sendRaw({
|
|
472
|
+
v: 1,
|
|
473
|
+
t: 'close',
|
|
474
|
+
ch: this.channelId,
|
|
475
|
+
ts: Date.now(),
|
|
476
|
+
from: this.pubKeyB64,
|
|
477
|
+
body: { reason: 'protocol_error' },
|
|
478
|
+
});
|
|
479
|
+
this.emit('error', new Error('Initial wallet join missing sealed_join'));
|
|
480
|
+
rootKey.fill(0);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
// Validate capabilities shape
|
|
484
|
+
if (joinCapabilities != null && !validateCapabilities(joinCapabilities)) {
|
|
485
|
+
this.sendRaw({
|
|
486
|
+
v: 1,
|
|
487
|
+
t: 'close',
|
|
488
|
+
ch: this.channelId,
|
|
489
|
+
ts: Date.now(),
|
|
490
|
+
from: this.pubKeyB64,
|
|
491
|
+
body: { reason: 'protocol_error' },
|
|
492
|
+
});
|
|
493
|
+
this.emit('error', new Error('Malformed wallet capabilities'));
|
|
494
|
+
rootKey.fill(0);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
// Warn if wallet omits any of the 5 MUST-support methods (backwards-compatible)
|
|
498
|
+
if (joinCapabilities) {
|
|
499
|
+
const requiredMethods = [
|
|
500
|
+
'wallet_getAccounts',
|
|
501
|
+
'wallet_signTransaction',
|
|
502
|
+
'wallet_signMessage',
|
|
503
|
+
'wallet_signTypedData',
|
|
504
|
+
'wallet_switchChain',
|
|
505
|
+
];
|
|
506
|
+
const declared = new Set(joinCapabilities.methods);
|
|
507
|
+
const absent = requiredMethods.filter((m) => !declared.has(m));
|
|
508
|
+
if (absent.length > 0) {
|
|
509
|
+
console.warn(`[WalletPair] Wallet missing MUST-support methods: ${absent.join(', ')}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const knownWallet = this.isSameApprovedWallet(joinPubKey, joinCapabilities, joinMeta);
|
|
513
|
+
this.walletCapabilities = joinCapabilities;
|
|
514
|
+
this.walletMeta = joinMeta;
|
|
515
|
+
const context = this.sessionContext(joinPubKey, joinCapabilities, joinMeta);
|
|
516
|
+
const keys = deriveDirectionalSessionKeys(rootKey, this.channelId, context);
|
|
517
|
+
this.sendKey = keys.dappToWalletKey;
|
|
518
|
+
this.recvKey = keys.walletToDappKey;
|
|
519
|
+
// §20.7: erase root_key and transcript_hash after all derivations
|
|
520
|
+
rootKey.fill(0);
|
|
521
|
+
keys.rootKey.fill(0);
|
|
522
|
+
keys.transcriptHash.fill(0);
|
|
523
|
+
// §7.1 DApp-side scope enforcement: check granted capabilities
|
|
524
|
+
if (joinCapabilities && this.declaredMethods?.length) {
|
|
525
|
+
const granted = new Set(joinCapabilities.methods);
|
|
526
|
+
const missing = this.declaredMethods.filter((m) => !granted.has(m));
|
|
527
|
+
if (missing.length > 0) {
|
|
528
|
+
this.sendRaw({
|
|
529
|
+
v: 1,
|
|
530
|
+
t: 'close',
|
|
531
|
+
ch: this.channelId,
|
|
532
|
+
ts: Date.now(),
|
|
533
|
+
from: this.pubKeyB64,
|
|
534
|
+
body: { reason: 'unsupported_capability' },
|
|
535
|
+
});
|
|
536
|
+
this.emit('error', new Error(`Wallet missing required methods: ${missing.join(', ')}`));
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (joinCapabilities && this.declaredChains?.length) {
|
|
541
|
+
const granted = new Set(joinCapabilities.chains);
|
|
542
|
+
const missing = this.declaredChains.filter((c) => !granted.has(c));
|
|
543
|
+
if (missing.length > 0) {
|
|
544
|
+
this.sendRaw({
|
|
545
|
+
v: 1,
|
|
546
|
+
t: 'close',
|
|
547
|
+
ch: this.channelId,
|
|
548
|
+
ts: Date.now(),
|
|
549
|
+
from: this.pubKeyB64,
|
|
550
|
+
body: { reason: 'unsupported_capability' },
|
|
551
|
+
});
|
|
552
|
+
this.emit('error', new Error(`Wallet missing required chains: ${missing.join(', ')}`));
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
this.emit('walletJoined', {
|
|
557
|
+
capabilities: joinCapabilities,
|
|
558
|
+
meta: joinMeta,
|
|
559
|
+
});
|
|
560
|
+
this.setPhase('pending_accept');
|
|
561
|
+
// Auto-accept for known wallets on reconnect, or when autoAccept is enabled
|
|
562
|
+
if (this.autoAccept || knownWallet) {
|
|
563
|
+
this.doAccept();
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
// Application must call acceptWallet() or rejectWallet()
|
|
567
|
+
this.pendingAcceptTimer = setTimeout(() => {
|
|
568
|
+
this.emit('error', new Error('Pending accept timed out'));
|
|
569
|
+
this.close('timeout');
|
|
570
|
+
}, PENDING_ACCEPT_TIMEOUT);
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
case 'res': {
|
|
575
|
+
const resBody = msg.body;
|
|
576
|
+
if (this.remotePubKey && msg.from !== b64urlEncode(this.remotePubKey))
|
|
577
|
+
break;
|
|
578
|
+
if (!resBody.id)
|
|
579
|
+
break;
|
|
580
|
+
const responseId = resBody.id;
|
|
581
|
+
const pending = this.pendingRequests.get(responseId);
|
|
582
|
+
if (!pending)
|
|
583
|
+
break;
|
|
584
|
+
this.pendingRequests.delete(responseId);
|
|
585
|
+
if (pending.timer)
|
|
586
|
+
clearTimeout(pending.timer);
|
|
587
|
+
// All responses MUST be sealed — reject unsealed to prevent forgery.
|
|
588
|
+
if (!resBody.sealed || !this.recvKey) {
|
|
589
|
+
pending.reject(new Error('Response must be encrypted'));
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const resHdr = { type: 'res', from: msg.from, id: responseId };
|
|
594
|
+
const { seq, data } = unsealPayload(this.recvKey, this.channelId, resBody.sealed, resHdr);
|
|
595
|
+
if (seq <= this.recvSeq) {
|
|
596
|
+
pending.reject(new Error('Replay detected'));
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
const prevRecvSeq = this.recvSeq;
|
|
600
|
+
this.recvSeq = seq;
|
|
601
|
+
const afterPersist = () => {
|
|
602
|
+
// Per protocol §5.3: _ok is inside the decrypted sealed payload
|
|
603
|
+
const envelope = data;
|
|
604
|
+
if (typeof envelope._ok !== 'boolean') {
|
|
605
|
+
pending.reject(new Error('Response missing _ok field'));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (envelope._ok) {
|
|
609
|
+
pending.resolve(envelope._result);
|
|
610
|
+
this.emit('response', { id: responseId, ok: true, result: envelope._result });
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const error = new Error(envelope.message ?? 'Request rejected');
|
|
614
|
+
error.code = envelope.code;
|
|
615
|
+
pending.reject(error);
|
|
616
|
+
this.emit('response', {
|
|
617
|
+
id: responseId,
|
|
618
|
+
ok: false,
|
|
619
|
+
result: { code: envelope.code, message: envelope.message },
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const persisted = this.persistSnapshot();
|
|
624
|
+
if (isPromiseLike(persisted)) {
|
|
625
|
+
void persisted.then(afterPersist).catch((e) => {
|
|
626
|
+
this.recvSeq = prevRecvSeq; // rollback on persist failure
|
|
627
|
+
pending.reject(this.persistenceError(e));
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
afterPersist();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
pending.reject(new Error('Decryption failed'));
|
|
636
|
+
}
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case 'evt': {
|
|
640
|
+
const evtBody = msg.body;
|
|
641
|
+
if (this.remotePubKey && msg.from !== b64urlEncode(this.remotePubKey))
|
|
642
|
+
break;
|
|
643
|
+
// Events MUST be sealed — drop unsealed events to prevent forgery.
|
|
644
|
+
if (!evtBody.sealed || !evtBody.id || !this.recvKey)
|
|
645
|
+
break;
|
|
646
|
+
try {
|
|
647
|
+
const evtHdr = { type: 'evt', from: msg.from, id: evtBody.id };
|
|
648
|
+
const { seq, data } = unsealPayload(this.recvKey, this.channelId, evtBody.sealed, evtHdr);
|
|
649
|
+
if (seq <= this.recvSeq)
|
|
650
|
+
break; // replay — silently drop
|
|
651
|
+
const prevRecvSeqEvt = this.recvSeq;
|
|
652
|
+
this.recvSeq = seq;
|
|
653
|
+
const afterPersist = () => {
|
|
654
|
+
// Privacy mode (§7.4): real event name is inside the encrypted payload
|
|
655
|
+
let event;
|
|
656
|
+
let eventData = data;
|
|
657
|
+
if (data && typeof data === 'object' && '_event' in data) {
|
|
658
|
+
event = data._event;
|
|
659
|
+
const { _event: _, ...rest } = data;
|
|
660
|
+
eventData = Object.keys(rest).length === 1 && '_data' in rest ? rest._data : rest;
|
|
661
|
+
}
|
|
662
|
+
if (event) {
|
|
663
|
+
this.emit('event', { event, data: eventData });
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
this.emit('error', new Error('Event payload missing _event'));
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
const persisted = this.persistSnapshot();
|
|
670
|
+
if (isPromiseLike(persisted)) {
|
|
671
|
+
void persisted
|
|
672
|
+
.then(afterPersist)
|
|
673
|
+
.catch((e) => {
|
|
674
|
+
this.recvSeq = prevRecvSeqEvt; // rollback on persist failure
|
|
675
|
+
this.emit('error', this.persistenceError(e));
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
afterPersist();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
/* drop events that fail decryption */
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
case 'ping':
|
|
688
|
+
this.sendRaw({
|
|
689
|
+
v: 1,
|
|
690
|
+
t: 'pong',
|
|
691
|
+
ch: this.channelId,
|
|
692
|
+
ts: Date.now(),
|
|
693
|
+
from: this.pubKeyB64,
|
|
694
|
+
body: {},
|
|
695
|
+
});
|
|
696
|
+
break;
|
|
697
|
+
case 'pong':
|
|
698
|
+
break;
|
|
699
|
+
case 'close': {
|
|
700
|
+
if (this.phase !== 'disconnected') {
|
|
701
|
+
this.clearPersistence();
|
|
702
|
+
this.setPhase('closed');
|
|
703
|
+
this.intentionalClose = true;
|
|
704
|
+
}
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case 'terminate': {
|
|
708
|
+
const termBody = msg.body;
|
|
709
|
+
// Race condition: relay sends channel_exists when we re-create during reconnect
|
|
710
|
+
if (termBody.reason === 'channel_exists' && this.phase === 'disconnected') {
|
|
711
|
+
this.startReconnect();
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
// Adapter-sent termination — treat like close
|
|
715
|
+
if (this.phase !== 'disconnected') {
|
|
716
|
+
this.clearPersistence();
|
|
717
|
+
this.setPhase('closed');
|
|
718
|
+
this.intentionalClose = true;
|
|
719
|
+
}
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
doAccept() {
|
|
725
|
+
this.paired = true;
|
|
726
|
+
this.approvedCapabilities = this.walletCapabilities;
|
|
727
|
+
this.approvedWalletMeta = this.walletMeta;
|
|
728
|
+
this.approvedWalletPubKeyB64 = b64urlEncode(this.remotePubKey);
|
|
729
|
+
this.approvedScopeRecorded = true;
|
|
730
|
+
const walletPubB64 = b64urlEncode(this.remotePubKey);
|
|
731
|
+
const sendAccept = () => {
|
|
732
|
+
this.sendRaw({
|
|
733
|
+
v: 1,
|
|
734
|
+
t: 'accept',
|
|
735
|
+
ch: this.channelId,
|
|
736
|
+
ts: Date.now(),
|
|
737
|
+
from: this.pubKeyB64,
|
|
738
|
+
body: { target: walletPubB64 },
|
|
739
|
+
});
|
|
740
|
+
};
|
|
741
|
+
const persisted = this.persistSnapshot();
|
|
742
|
+
if (isPromiseLike(persisted)) {
|
|
743
|
+
void persisted.then(sendAccept).catch((e) => this.persistenceError(e));
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
sendAccept();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
sessionContext(walletPubKeyB64, capabilities, walletMeta) {
|
|
750
|
+
return {
|
|
751
|
+
dappPubKeyB64: this.pubKeyB64,
|
|
752
|
+
walletPubKeyB64,
|
|
753
|
+
capabilities: capabilities ?? null,
|
|
754
|
+
walletMeta: walletMeta ?? null,
|
|
755
|
+
dappName: this.meta.name,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
isSameApprovedWallet(walletPubKeyB64, capabilities, walletMeta) {
|
|
759
|
+
return (this.paired &&
|
|
760
|
+
this.approvedWalletPubKeyB64 === walletPubKeyB64 &&
|
|
761
|
+
this.approvedScopeRecorded &&
|
|
762
|
+
canonicalJson(this.approvedCapabilities) === canonicalJson(capabilities ?? null) &&
|
|
763
|
+
canonicalJson(this.approvedWalletMeta ?? null) === canonicalJson(walletMeta ?? null));
|
|
764
|
+
}
|
|
765
|
+
nextSendSeq() {
|
|
766
|
+
if (this.sendSeq >= MAX_SEND_SEQ) {
|
|
767
|
+
const error = new Error('Send sequence overflow/limit reached — session invalidated');
|
|
768
|
+
this.emit('error', error);
|
|
769
|
+
this.close();
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
const seq = this.sendSeq;
|
|
773
|
+
this.sendSeq += 1;
|
|
774
|
+
const persisted = this.persistSnapshot();
|
|
775
|
+
if (isPromiseLike(persisted)) {
|
|
776
|
+
return persisted
|
|
777
|
+
.then(() => seq)
|
|
778
|
+
.catch((e) => {
|
|
779
|
+
throw this.persistenceError(e);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return seq;
|
|
783
|
+
}
|
|
784
|
+
persistSnapshot() {
|
|
785
|
+
if (!this.persistence)
|
|
786
|
+
return;
|
|
787
|
+
return this.persistence.save(this.serialize());
|
|
788
|
+
}
|
|
789
|
+
persistSnapshotAsync() {
|
|
790
|
+
const persisted = this.persistSnapshot();
|
|
791
|
+
if (isPromiseLike(persisted)) {
|
|
792
|
+
void persisted.catch((e) => this.persistenceError(e));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
clearPersistence() {
|
|
796
|
+
if (!this.persistence?.clear)
|
|
797
|
+
return;
|
|
798
|
+
const cleared = this.persistence.clear();
|
|
799
|
+
if (isPromiseLike(cleared)) {
|
|
800
|
+
void cleared.catch((e) => {
|
|
801
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
802
|
+
this.emit('error', err);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
persistenceError(error) {
|
|
807
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
808
|
+
const wrapped = new Error(`Session persistence failed: ${err.message}`);
|
|
809
|
+
this.emit('error', wrapped);
|
|
810
|
+
this.close('protocol_error');
|
|
811
|
+
return wrapped;
|
|
812
|
+
}
|
|
813
|
+
// -------------------------------------------------------------------------
|
|
814
|
+
// Internal: transport
|
|
815
|
+
// -------------------------------------------------------------------------
|
|
816
|
+
/** Append ?ch= to transport URL for CF Worker relay routing. Rust relay ignores it. */
|
|
817
|
+
setTransportChannelHint() {
|
|
818
|
+
const t = this.transport;
|
|
819
|
+
if (typeof t.setUrl === 'function') {
|
|
820
|
+
const base = t.url ?? '';
|
|
821
|
+
if (base && !base.includes('?ch=')) {
|
|
822
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
823
|
+
t.setUrl(`${base}${sep}ch=${this.channelId}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
sendRaw(msg) {
|
|
828
|
+
// §15 rule 10: max 64 KB on the wire
|
|
829
|
+
const json = JSON.stringify(msg);
|
|
830
|
+
if (new TextEncoder().encode(json).length > MAX_MESSAGE_BYTES) {
|
|
831
|
+
this.emit('error', new Error('Message exceeds 64 KB limit'));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
this.transport.send(msg);
|
|
835
|
+
}
|
|
836
|
+
handleTransportClose() {
|
|
837
|
+
if (this.intentionalClose || this.phase === 'closed')
|
|
838
|
+
return;
|
|
839
|
+
this.startReconnect();
|
|
840
|
+
}
|
|
841
|
+
// -------------------------------------------------------------------------
|
|
842
|
+
// Internal: reconnect
|
|
843
|
+
// -------------------------------------------------------------------------
|
|
844
|
+
startReconnect() {
|
|
845
|
+
this.setPhase('disconnected');
|
|
846
|
+
this.reconnectAttempt = 0;
|
|
847
|
+
this.scheduleReconnect();
|
|
848
|
+
}
|
|
849
|
+
scheduleReconnect() {
|
|
850
|
+
if (this.intentionalClose || this.phase === 'closed')
|
|
851
|
+
return;
|
|
852
|
+
const base = BACKOFF[Math.min(this.reconnectAttempt, BACKOFF.length - 1)] ?? 1000;
|
|
853
|
+
const delay = base + Math.floor(Math.random() * base * 0.3); // ±30% jitter
|
|
854
|
+
this.reconnectTimer = setTimeout(() => {
|
|
855
|
+
this.doReconnectAttempt();
|
|
856
|
+
this.reconnectAttempt++;
|
|
857
|
+
}, delay);
|
|
858
|
+
}
|
|
859
|
+
async doReconnectAttempt() {
|
|
860
|
+
if (this.intentionalClose || this.phase === 'closed')
|
|
861
|
+
return;
|
|
862
|
+
try {
|
|
863
|
+
this.setTransportChannelHint();
|
|
864
|
+
await this.transport.connect();
|
|
865
|
+
this.sendRaw({
|
|
866
|
+
v: 1,
|
|
867
|
+
t: 'create',
|
|
868
|
+
ch: this.channelId,
|
|
869
|
+
ts: Date.now(),
|
|
870
|
+
from: this.pubKeyB64,
|
|
871
|
+
body: { meta: this.meta },
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
this.scheduleReconnect();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
stopReconnect() {
|
|
879
|
+
if (this.reconnectTimer) {
|
|
880
|
+
clearTimeout(this.reconnectTimer);
|
|
881
|
+
this.reconnectTimer = null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
clearPendingAcceptTimer() {
|
|
885
|
+
if (this.pendingAcceptTimer) {
|
|
886
|
+
clearTimeout(this.pendingAcceptTimer);
|
|
887
|
+
this.pendingAcceptTimer = null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// -------------------------------------------------------------------------
|
|
891
|
+
// Internal: session TTL (§16 rule 16)
|
|
892
|
+
// -------------------------------------------------------------------------
|
|
893
|
+
startSessionTtl() {
|
|
894
|
+
this.clearSessionTtl();
|
|
895
|
+
if (this.sessionStartTime == null) {
|
|
896
|
+
this.sessionStartTime = Date.now();
|
|
897
|
+
}
|
|
898
|
+
const elapsed = Date.now() - this.sessionStartTime;
|
|
899
|
+
const remaining = Math.max(0, this.sessionTtl - elapsed);
|
|
900
|
+
this.sessionTtlTimer = setTimeout(() => {
|
|
901
|
+
this.emit('error', new Error('Session lifetime expired'));
|
|
902
|
+
this.close('timeout');
|
|
903
|
+
}, remaining);
|
|
904
|
+
}
|
|
905
|
+
clearSessionTtl() {
|
|
906
|
+
if (this.sessionTtlTimer) {
|
|
907
|
+
clearTimeout(this.sessionTtlTimer);
|
|
908
|
+
this.sessionTtlTimer = null;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
setPhase(phase) {
|
|
912
|
+
if (this.phase === phase)
|
|
913
|
+
return;
|
|
914
|
+
this.phase = phase;
|
|
915
|
+
this.emit('phase', phase);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
//# sourceMappingURL=dapp-session.js.map
|