@wacando/agent 2.0.1 → 2.1.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.
@@ -0,0 +1,2488 @@
1
+ // Embedded from lbframe/wacando@9f82881d4ee6cfd655727a08883877230917c955 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
2
+ import {
3
+ authenticatedMessageOrigin,
4
+ pepFetchItems,
5
+ pepPublish
6
+ } from "./chunks/chunk-GASYW3SG.mjs";
7
+ import {
8
+ BODY_MARKER,
9
+ GCM_IV_BYTES,
10
+ GCM_TAG_BYTES,
11
+ MESSAGE_KEY_BYTES,
12
+ NS_DEVICELIST,
13
+ NS_EME,
14
+ NS_HINTS,
15
+ NS_OMEMO,
16
+ NS_RECOVERY,
17
+ NS_SCE,
18
+ NS_WACANDO_OMEMO,
19
+ OmemoError,
20
+ OmemoReceiveError,
21
+ PEP_ITEM_ID,
22
+ RECOVERY_DOMAIN,
23
+ UnsupportedSignalFormatError,
24
+ b64decode,
25
+ b64encode,
26
+ buildEncryptedMessageEl,
27
+ buildRecoveryEl,
28
+ buildSceEnvelope,
29
+ bundleNode,
30
+ encodeRecord,
31
+ parseEncrypted,
32
+ parseRecord,
33
+ parseRecoveryEl,
34
+ parseSceEnvelope,
35
+ parseXmlDocument,
36
+ randomBytes,
37
+ signerJid,
38
+ signerRole,
39
+ utf8Encode
40
+ } from "./chunks/chunk-OI3RF7TS.mjs";
41
+ import "./chunks/chunk-EIGFLZ2Y.mjs";
42
+
43
+ // packages/omemo/src/libsignal.ts
44
+ import { jid as parseJid2 } from "@xmpp/jid";
45
+
46
+ // packages/omemo/src/libsignal-backend.ts
47
+ import {
48
+ CiphertextMessageType,
49
+ PreKeySignalMessage,
50
+ ProtocolAddress,
51
+ processPreKeyBundle,
52
+ SignalMessage,
53
+ signalDecrypt,
54
+ signalDecryptPreKey,
55
+ signalEncrypt
56
+ } from "@signalapp/libsignal-client";
57
+
58
+ // packages/omemo/src/bundle.ts
59
+ import { KEMPublicKey, PreKeyBundle, PublicKey } from "@signalapp/libsignal-client";
60
+ import xml2 from "@xmpp/xml";
61
+
62
+ // packages/omemo/src/devicelist.ts
63
+ import xml from "@xmpp/xml";
64
+ function deviceListEl(deviceIds) {
65
+ return xml(
66
+ "list",
67
+ { xmlns: NS_OMEMO },
68
+ ...deviceIds.map((id) => xml("device", { id: String(id) }))
69
+ );
70
+ }
71
+ function parseDeviceList(el) {
72
+ if (!el || el.getName() !== "list") return [];
73
+ return el.getChildren("device").map((d) => Number(d.getAttr("id"))).filter((n) => Number.isInteger(n) && n > 0);
74
+ }
75
+ function pickSlot(prekeys, pickerKey) {
76
+ if (prekeys.length === 0) return void 0;
77
+ let h = 2166136261;
78
+ for (let i = 0; i < pickerKey.length; i++) {
79
+ h ^= pickerKey.charCodeAt(i);
80
+ h = Math.imul(h, 16777619);
81
+ }
82
+ return prekeys[(h >>> 0) % prekeys.length];
83
+ }
84
+
85
+ // packages/omemo/src/bundle.ts
86
+ function b64(bytes) {
87
+ return Buffer.from(bytes).toString("base64");
88
+ }
89
+ function unb64(s) {
90
+ return new Uint8Array(Buffer.from(s, "base64"));
91
+ }
92
+ function bundleEl(state) {
93
+ const spk = state.signedPreKey;
94
+ const kpk = state.kyberPreKey;
95
+ return xml2(
96
+ "bundle",
97
+ { xmlns: NS_OMEMO },
98
+ xml2(
99
+ "signedPreKeyPublic",
100
+ { signedPreKeyId: String(spk.id()) },
101
+ b64(spk.publicKey().serialize())
102
+ ),
103
+ xml2("signedPreKeySignature", {}, b64(spk.signature())),
104
+ xml2("identityKey", {}, b64(state.identityKeyPair.publicKey.serialize())),
105
+ xml2(
106
+ "prekeys",
107
+ {},
108
+ ...[...state.preKeys].map(
109
+ ([id, rec]) => xml2("preKeyPublic", { preKeyId: String(id) }, b64(rec.publicKey().serialize()))
110
+ )
111
+ ),
112
+ xml2(
113
+ "kyberPreKeys",
114
+ { xmlns: NS_WACANDO_OMEMO },
115
+ xml2(
116
+ "kyberPreKeyPublic",
117
+ { kyberPreKeyId: String(kpk.id()) },
118
+ b64(kpk.publicKey().serialize())
119
+ ),
120
+ xml2("kyberPreKeySignature", {}, b64(kpk.signature()))
121
+ )
122
+ );
123
+ }
124
+ function requiredText(el, name) {
125
+ const text = el?.getChildText(name);
126
+ if (!text) throw new OmemoError(`bundle missing <${name}>`);
127
+ return text;
128
+ }
129
+ function parseBundle(bundleElement, expectedDeviceId, pickerKey = "", explicitPreKeyId) {
130
+ if (bundleElement.getName() !== "bundle") {
131
+ throw new OmemoError("PEP item is not an OMEMO bundle");
132
+ }
133
+ try {
134
+ const spkPubEl = bundleElement.getChild("signedPreKeyPublic");
135
+ const spkId = Number(spkPubEl?.getAttr("signedPreKeyId"));
136
+ const spkPub = PublicKey.deserialize(unb64(requiredText(bundleElement, "signedPreKeyPublic")));
137
+ const spkSig = unb64(requiredText(bundleElement, "signedPreKeySignature"));
138
+ const identityKey = PublicKey.deserialize(unb64(requiredText(bundleElement, "identityKey")));
139
+ const prekeys = bundleElement.getChild("prekeys")?.getChildren("preKeyPublic") ?? [];
140
+ const picked = explicitPreKeyId === void 0 ? pickSlot(prekeys, pickerKey) ?? prekeys[0] : prekeys.find((k) => k.getAttr("preKeyId") === String(explicitPreKeyId));
141
+ if (explicitPreKeyId !== void 0 && !picked) {
142
+ throw new OmemoError("bundle lacks the selected one-time prekey");
143
+ }
144
+ const preKeyId = picked ? Number(picked.getAttr("preKeyId")) : null;
145
+ const preKeyPub = picked ? PublicKey.deserialize(unb64(picked.text())) : null;
146
+ const kyber = bundleElement.getChild("kyberPreKeys", NS_WACANDO_OMEMO);
147
+ const kpkPubEl = kyber?.getChild("kyberPreKeyPublic");
148
+ const kyberPreKeyId = Number(kpkPubEl?.getAttr("kyberPreKeyId"));
149
+ const kpkPubText = kpkPubEl?.text();
150
+ const kpkSigText = kyber?.getChild("kyberPreKeySignature")?.text();
151
+ if (!Number.isInteger(kyberPreKeyId) || !kpkPubText || !kpkSigText) {
152
+ throw new OmemoError("bundle missing kyber prekey material");
153
+ }
154
+ const kyberPub = KEMPublicKey.deserialize(unb64(kpkPubText));
155
+ const bundle = PreKeyBundle.new(
156
+ expectedDeviceId,
157
+ // registrationId === deviceId (documented convention)
158
+ expectedDeviceId,
159
+ typeof preKeyId === "number" && Number.isInteger(preKeyId) && preKeyId > 0 ? preKeyId : null,
160
+ preKeyPub,
161
+ spkId,
162
+ spkPub,
163
+ spkSig,
164
+ identityKey,
165
+ kyberPreKeyId,
166
+ kyberPub,
167
+ unb64(kpkSigText)
168
+ );
169
+ return { bundle, registrationId: expectedDeviceId, deviceId: expectedDeviceId };
170
+ } catch (err) {
171
+ if (err instanceof OmemoError) throw err;
172
+ throw new OmemoError(`bundle parse failed: ${err.message}`);
173
+ }
174
+ }
175
+
176
+ // packages/omemo/src/prekey-header.ts
177
+ var MAX_MESSAGE_BYTES = 8192;
178
+ function readVarint(buf, at) {
179
+ let value = 0;
180
+ let shift = 0;
181
+ let i = at;
182
+ while (i < buf.length && shift <= 28) {
183
+ const byte = buf[i];
184
+ value += (byte & 127) * 2 ** shift;
185
+ i++;
186
+ if ((byte & 128) === 0) return [value, i];
187
+ shift += 7;
188
+ }
189
+ return null;
190
+ }
191
+ function preKeyHeaderIdentity(raw) {
192
+ if (raw.length < 2 || raw.length > MAX_MESSAGE_BYTES) return void 0;
193
+ let at = 1;
194
+ let identity;
195
+ while (at < raw.length) {
196
+ const tag = readVarint(raw, at);
197
+ if (!tag) return void 0;
198
+ const [key2, next] = tag;
199
+ at = next;
200
+ const field = Math.floor(key2 / 8);
201
+ const wire = key2 % 8;
202
+ if (wire === 0) {
203
+ const v = readVarint(raw, at);
204
+ if (!v) return void 0;
205
+ at = v[1];
206
+ } else if (wire === 2) {
207
+ const len = readVarint(raw, at);
208
+ if (!len) return void 0;
209
+ const [size, start] = len;
210
+ if (start + size > raw.length) return void 0;
211
+ if (field === 3) {
212
+ if (identity !== void 0) return void 0;
213
+ identity = raw.slice(start, start + size);
214
+ }
215
+ at = start + size;
216
+ } else {
217
+ return void 0;
218
+ }
219
+ }
220
+ return identity !== void 0 && identity.length === 33 && identity[0] === 5 ? identity : void 0;
221
+ }
222
+
223
+ // packages/omemo/src/stores.ts
224
+ import {
225
+ IdentityChange,
226
+ IdentityKeyStore,
227
+ KyberPreKeyStore,
228
+ PreKeyStore,
229
+ SessionStore,
230
+ SignedPreKeyStore
231
+ } from "@signalapp/libsignal-client";
232
+ function key(addr) {
233
+ return `${addr.name()}.${addr.deviceId()}`;
234
+ }
235
+ var FileSessionStore = class extends SessionStore {
236
+ constructor(state) {
237
+ super();
238
+ this.state = state;
239
+ }
240
+ state;
241
+ async saveSession(name, record) {
242
+ this.state.sessions.set(key(name), record);
243
+ this.state.persist();
244
+ }
245
+ async getSession(name) {
246
+ return this.state.sessions.get(key(name)) ?? null;
247
+ }
248
+ async getExistingSessions(addresses) {
249
+ const out = [];
250
+ for (const addr of addresses) {
251
+ const rec = this.state.sessions.get(key(addr));
252
+ if (rec) out.push(rec);
253
+ }
254
+ return out;
255
+ }
256
+ };
257
+ var ReceiveSessionStore = class extends FileSessionStore {
258
+ async saveSession(name, record) {
259
+ this.state.sessions.set(key(name), record);
260
+ this.state.bumpSessionGeneration(key(name));
261
+ this.state.persist();
262
+ }
263
+ };
264
+ var FileIdentityStore = class extends IdentityKeyStore {
265
+ constructor(state) {
266
+ super();
267
+ this.state = state;
268
+ }
269
+ state;
270
+ async getIdentityKey() {
271
+ return this.state.identityKeyPair.privateKey;
272
+ }
273
+ async getLocalRegistrationId() {
274
+ return this.state.deviceId;
275
+ }
276
+ async saveIdentity(name, pub) {
277
+ const k = key(name);
278
+ const existing = this.state.identities.get(k);
279
+ if (existing && !existing.equals(pub)) {
280
+ throw new OmemoError("untrusted identity: existing pin differs");
281
+ }
282
+ if (!existing) {
283
+ this.state.identities.set(k, pub);
284
+ this.state.persist();
285
+ }
286
+ return IdentityChange.NewOrUnchanged;
287
+ }
288
+ async isTrustedIdentity(name, pub, _direction) {
289
+ const existing = this.state.identities.get(key(name));
290
+ return existing === void 0 || existing.equals(pub);
291
+ }
292
+ async getIdentity(name) {
293
+ return this.state.identities.get(key(name)) ?? null;
294
+ }
295
+ };
296
+ var FilePreKeyStore = class extends PreKeyStore {
297
+ constructor(state) {
298
+ super();
299
+ this.state = state;
300
+ }
301
+ state;
302
+ /**
303
+ * #50 typed classification signal (never parsed from an error string): the
304
+ * one-time prekey ID a library call asked for and did not find. Use one
305
+ * instance per decrypt call (concurrent peers never share it).
306
+ */
307
+ missingLookup;
308
+ async savePreKey(id, record) {
309
+ this.state.preKeys.set(id, record);
310
+ this.state.persist();
311
+ }
312
+ async getPreKey(id) {
313
+ const rec = this.state.preKeys.get(id);
314
+ if (!rec) {
315
+ this.missingLookup = id;
316
+ throw new Error("one-time prekey not found");
317
+ }
318
+ return rec;
319
+ }
320
+ async removePreKey(id) {
321
+ this.state.consumePreKey(id);
322
+ this.state.persist();
323
+ }
324
+ };
325
+ var FileSignedPreKeyStore = class extends SignedPreKeyStore {
326
+ constructor(state) {
327
+ super();
328
+ this.state = state;
329
+ }
330
+ state;
331
+ async saveSignedPreKey(_id, record) {
332
+ void record;
333
+ this.state.persist();
334
+ }
335
+ async getSignedPreKey(id) {
336
+ if (id !== this.state.signedPreKey.id()) {
337
+ throw new Error(`signed prekey ${id} not found`);
338
+ }
339
+ return this.state.signedPreKey;
340
+ }
341
+ };
342
+ var FileKyberPreKeyStore = class extends KyberPreKeyStore {
343
+ constructor(state) {
344
+ super();
345
+ this.state = state;
346
+ }
347
+ state;
348
+ used = /* @__PURE__ */ new Set();
349
+ async saveKyberPreKey(_id, _record) {
350
+ this.state.persist();
351
+ }
352
+ async getKyberPreKey(id) {
353
+ if (id !== this.state.kyberPreKey.id()) {
354
+ throw new Error(`kyber prekey ${id} not found`);
355
+ }
356
+ return this.state.kyberPreKey;
357
+ }
358
+ async markKyberPreKeyUsed(kyberPreKeyId) {
359
+ this.used.add(kyberPreKeyId);
360
+ this.state.persist();
361
+ }
362
+ };
363
+ function makeStores(state) {
364
+ return {
365
+ session: new FileSessionStore(state),
366
+ identity: new FileIdentityStore(state),
367
+ preKey: new FilePreKeyStore(state),
368
+ signedPreKey: new FileSignedPreKeyStore(state),
369
+ kyberPreKey: new FileKyberPreKeyStore(state)
370
+ };
371
+ }
372
+
373
+ // packages/omemo/src/libsignal-backend.ts
374
+ function address(peer) {
375
+ return ProtocolAddress.new(peer.bareJid, peer.deviceId);
376
+ }
377
+ var LibsignalNodeBackend = class {
378
+ state;
379
+ stores;
380
+ localAddress;
381
+ constructor(state) {
382
+ this.state = state;
383
+ this.stores = makeStores(state);
384
+ this.localAddress = ProtocolAddress.new(state.jid, state.deviceId);
385
+ }
386
+ get deviceId() {
387
+ return this.state.deviceId;
388
+ }
389
+ /** The live libsignal stores (#50 recovery builds staged copies over them). */
390
+ get signalStores() {
391
+ return this.stores;
392
+ }
393
+ bundleElement() {
394
+ return bundleEl(this.state);
395
+ }
396
+ topUpPreKeys() {
397
+ this.state.topUpPreKeys();
398
+ }
399
+ supportsBundle(bundleElement) {
400
+ return bundleElement.getChild("kyberPreKeys", NS_WACANDO_OMEMO) !== void 0;
401
+ }
402
+ async hasSession(peer) {
403
+ return await this.stores.session.getSession(address(peer)) !== null;
404
+ }
405
+ async ensureSession(peer, bundleElement) {
406
+ const addr = address(peer);
407
+ if (await this.stores.session.getSession(addr) !== null) return;
408
+ const { bundle } = parseBundle(bundleElement, peer.deviceId, this.state.jid);
409
+ try {
410
+ await processPreKeyBundle(
411
+ bundle,
412
+ addr,
413
+ this.localAddress,
414
+ this.stores.session,
415
+ this.stores.identity
416
+ );
417
+ } catch (err) {
418
+ throw new OmemoError(
419
+ `session establishment failed for ${peer.bareJid}.${peer.deviceId}: ${err.message}`
420
+ );
421
+ }
422
+ }
423
+ async encrypt(peer, plaintext) {
424
+ try {
425
+ const encrypted = await signalEncrypt(
426
+ plaintext,
427
+ address(peer),
428
+ this.localAddress,
429
+ this.stores.session,
430
+ this.stores.identity
431
+ );
432
+ return {
433
+ ciphertextB64: b64encode(encrypted.serialize()),
434
+ isPreKey: encrypted.type() === CiphertextMessageType.PreKey
435
+ };
436
+ } catch (err) {
437
+ throw new OmemoError(
438
+ `encrypt failed for ${peer.bareJid}.${peer.deviceId}: ${err.message}`
439
+ );
440
+ }
441
+ }
442
+ async decrypt(peer, ciphertextB64, isPreKey) {
443
+ const raw = b64decode(ciphertextB64);
444
+ const addr = address(peer);
445
+ if (isPreKey && raw[0] === 51 && !await this.hasSession(peer)) {
446
+ throw new UnsupportedSignalFormatError("classic v3 first-contact format");
447
+ }
448
+ const preKeys = new FilePreKeyStore(this.state);
449
+ const sessions = new ReceiveSessionStore(this.state);
450
+ try {
451
+ return isPreKey ? await signalDecryptPreKey(
452
+ PreKeySignalMessage.deserialize(raw),
453
+ addr,
454
+ this.localAddress,
455
+ sessions,
456
+ this.stores.identity,
457
+ preKeys,
458
+ this.stores.signedPreKey,
459
+ this.stores.kyberPreKey
460
+ ) : await signalDecrypt(
461
+ SignalMessage.deserialize(raw),
462
+ addr,
463
+ this.localAddress,
464
+ sessions,
465
+ this.stores.identity
466
+ );
467
+ } catch (err) {
468
+ const failure = this.classifyFailure(peer, raw, isPreKey, preKeys.missingLookup);
469
+ throw new OmemoReceiveError(
470
+ `decrypt failed for ${peer.bareJid}.${peer.deviceId}: ${err.message}`,
471
+ failure
472
+ );
473
+ }
474
+ }
475
+ /**
476
+ * #50 §3 classification, from typed store signals only (never error text).
477
+ * `consumed_prekey_candidate` requires: a structurally valid PreKey message,
478
+ * a library lookup of exactly its one-time prekey that found nothing, a
479
+ * committed consumption tombstone for that ID, our current signed prekey,
480
+ * and no conflicting exact-device pin. The message itself stays
481
+ * unauthenticated — the candidate only allows the signed negotiation.
482
+ */
483
+ classifyFailure(peer, raw, isPreKey, missingLookup) {
484
+ if (!isPreKey) return { kind: "authentication_failed" };
485
+ let preKeyId;
486
+ let signedPreKeyId;
487
+ let claimed;
488
+ try {
489
+ const msg = PreKeySignalMessage.deserialize(raw);
490
+ preKeyId = msg.preKeyId();
491
+ signedPreKeyId = msg.signedPreKeyId();
492
+ claimed = preKeyHeaderIdentity(raw);
493
+ } catch {
494
+ return { kind: "authentication_failed" };
495
+ }
496
+ if (preKeyId === null || !claimed) return { kind: "authentication_failed" };
497
+ const pin = this.state.identities.get(`${peer.bareJid}.${peer.deviceId}`);
498
+ if (pin && !bytesEqual(pin.serialize(), claimed)) {
499
+ return { kind: "untrusted_identity", preKeyId };
500
+ }
501
+ if (missingLookup !== preKeyId) {
502
+ return { kind: "authentication_failed", preKeyId };
503
+ }
504
+ if (!this.state.isConsumedPreKey(preKeyId) || signedPreKeyId !== this.state.signedPreKey.id()) {
505
+ return { kind: "missing_prekey_unknown", preKeyId };
506
+ }
507
+ return { kind: "consumed_prekey_candidate", preKeyId, claimedIdentity: claimed };
508
+ }
509
+ };
510
+ function bytesEqual(a, b) {
511
+ return a.length === b.length && a.every((v, i) => v === b[i]);
512
+ }
513
+
514
+ // packages/omemo/src/provider.ts
515
+ import { jid as parseJid } from "@xmpp/jid";
516
+ var OmemoProvider = class {
517
+ bareJid;
518
+ backend;
519
+ /** Serialize ratchet ops per peer session (encrypt/decrypt mutate state). */
520
+ locks = /* @__PURE__ */ new Map();
521
+ /** #50 consumed-prekey recovery, when attached (recovery.ts). */
522
+ recovery;
523
+ constructor(jid, backend) {
524
+ this.bareJid = parseJid(jid).bare().toString();
525
+ this.backend = backend;
526
+ }
527
+ get deviceId() {
528
+ return this.backend.deviceId;
529
+ }
530
+ /** This device's public bundle element (what publishBundle sends to PEP). */
531
+ bundleElement() {
532
+ return this.backend.bundleElement();
533
+ }
534
+ /**
535
+ * Publish our devicelist (merged + pruned) and this device's bundle via PEP.
536
+ * Merge keeps other devices of this principal listed (multi-device);
537
+ * prune drops ids whose bundle node is gone (self-heals stale entries —
538
+ * a stale id would otherwise block every send fail-closed forever).
539
+ */
540
+ async publishBundle(entity, timeoutMs = 1e4) {
541
+ const existing = await this.deviceIds(entity, this.bareJid, timeoutMs).catch(
542
+ () => []
543
+ );
544
+ const live = [];
545
+ for (const id of existing) {
546
+ if (id === this.deviceId) continue;
547
+ const items = await pepFetchItems(entity, this.bareJid, bundleNode(id), timeoutMs);
548
+ if (items.length > 0) live.push(id);
549
+ }
550
+ const ids = [.../* @__PURE__ */ new Set([...live, this.deviceId])].sort((a, b) => a - b);
551
+ await this.backend.topUpPreKeys();
552
+ await pepPublish(
553
+ entity,
554
+ this.bareJid,
555
+ NS_DEVICELIST,
556
+ PEP_ITEM_ID,
557
+ deviceListEl(ids),
558
+ timeoutMs
559
+ );
560
+ const published = this.bundleElement();
561
+ await pepPublish(
562
+ entity,
563
+ this.bareJid,
564
+ bundleNode(this.deviceId),
565
+ PEP_ITEM_ID,
566
+ published,
567
+ timeoutMs
568
+ );
569
+ return published;
570
+ }
571
+ /** Device ids advertised by `bareJid` (empty when no devicelist node). */
572
+ async deviceIds(entity, bareJid, timeoutMs) {
573
+ const payloads = await pepFetchItems(entity, bareJid, NS_DEVICELIST, timeoutMs);
574
+ for (const el of payloads) {
575
+ const ids = parseDeviceList(el);
576
+ if (ids.length > 0) return ids;
577
+ }
578
+ return [];
579
+ }
580
+ /**
581
+ * Resolve OMEMO device targets for a member set via PEP — fail closed:
582
+ * missing devicelist or missing bundle throws OmemoError before any
583
+ * plaintext is touched. Bundle elements are kept raw: each session
584
+ * backend parses the flavor it accepts (kyber → PQXDH, else classic).
585
+ */
586
+ async resolveDeviceTargets(entity, memberBareJids, timeoutMs = 1e4) {
587
+ const recipients = [.../* @__PURE__ */ new Set([...memberBareJids, this.bareJid])];
588
+ const targets = [];
589
+ for (const member of recipients) {
590
+ const ids = await this.deviceIds(entity, member, timeoutMs);
591
+ if (ids.length === 0) {
592
+ throw new OmemoError(`missing OMEMO devicelist for ${member}`);
593
+ }
594
+ for (const deviceId of ids) {
595
+ if (member === this.bareJid && deviceId === this.deviceId) continue;
596
+ const items = await pepFetchItems(entity, member, bundleNode(deviceId), timeoutMs);
597
+ const bundleElement = items[0];
598
+ if (!bundleElement) {
599
+ throw new OmemoError(`missing OMEMO bundle for ${member} device ${deviceId}`);
600
+ }
601
+ targets.push({ bareJid: member, deviceId, bundleElement });
602
+ }
603
+ }
604
+ return targets;
605
+ }
606
+ /**
607
+ * Encrypt `plaintext` for already-resolved device targets and return the
608
+ * ready-to-send groupchat stanza Element.
609
+ */
610
+ async encryptForTargets(targets, roomJid, plaintext) {
611
+ const effective = targets.filter(
612
+ (t) => !(t.bareJid === this.bareJid && t.deviceId === this.deviceId)
613
+ );
614
+ if (effective.length === 0) {
615
+ throw new OmemoError("empty recipient device set");
616
+ }
617
+ for (const t of effective) {
618
+ const peer = { bareJid: t.bareJid, deviceId: t.deviceId };
619
+ await this.withLock(
620
+ `${t.bareJid}.${t.deviceId}`,
621
+ () => this.backend.ensureSession(peer, t.bundleElement, this.bareJid)
622
+ );
623
+ }
624
+ const messageKey = randomBytes(MESSAGE_KEY_BYTES);
625
+ const iv = randomBytes(GCM_IV_BYTES);
626
+ const envelope = buildSceEnvelope(plaintext);
627
+ const key2 = await globalThis.crypto.subtle.importKey(
628
+ "raw",
629
+ messageKey,
630
+ { name: "AES-GCM" },
631
+ false,
632
+ ["encrypt"]
633
+ );
634
+ const payload = new Uint8Array(
635
+ await globalThis.crypto.subtle.encrypt(
636
+ { name: "AES-GCM", iv },
637
+ key2,
638
+ new TextEncoder().encode(envelope)
639
+ )
640
+ );
641
+ const keys = [];
642
+ for (const t of effective) {
643
+ const peer = { bareJid: t.bareJid, deviceId: t.deviceId };
644
+ const wrapped = await this.withLock(
645
+ `${t.bareJid}.${t.deviceId}`,
646
+ () => this.backend.encrypt(peer, messageKey)
647
+ );
648
+ keys.push({
649
+ rid: t.deviceId,
650
+ jid: t.bareJid,
651
+ ciphertextB64: wrapped.ciphertextB64,
652
+ isPreKey: wrapped.isPreKey
653
+ });
654
+ }
655
+ const el = buildEncryptedMessageEl({
656
+ roomJid,
657
+ senderBareJid: this.bareJid,
658
+ senderDeviceId: this.deviceId,
659
+ keys,
660
+ ivB64: b64encode(iv),
661
+ payloadB64: b64encode(payload)
662
+ });
663
+ const stanzaId = el.getAttr("id");
664
+ if (this.recovery && typeof stanzaId === "string") {
665
+ for (const k of keys) {
666
+ if (k.isPreKey)
667
+ this.recovery.onPreKeySent(
668
+ { bareJid: k.jid, deviceId: k.rid },
669
+ stanzaId,
670
+ k.ciphertextB64,
671
+ roomJid
672
+ );
673
+ }
674
+ }
675
+ return el;
676
+ }
677
+ /**
678
+ * Encrypt `plaintext` for every device of every member bare JID
679
+ * (our other devices included so multi-device senders read sent copies;
680
+ * the SENDING device itself is excluded — X3DH self-send is undefined).
681
+ * Returns the ready-to-send groupchat stanza Element.
682
+ * Throws OmemoError on ANY missing device material — nothing is sent.
683
+ */
684
+ async encryptForRoom(entity, roomJid, memberBareJids, plaintext, timeoutMs = 1e4) {
685
+ const targets = await this.resolveDeviceTargets(entity, memberBareJids, timeoutMs);
686
+ return this.encryptForTargets(targets, roomJid, plaintext);
687
+ }
688
+ /**
689
+ * Decrypt one stanza (Element or raw XML — raw message or MAM-forwarded).
690
+ * Returns the SCE-protected body text. Requires transport provenance on
691
+ * the Element, or a trusted caller-supplied origin. Serialized XML alone
692
+ * cannot authenticate a sender. Never derive origin from sender claims.
693
+ * Returns null only when the stanza is not OMEMO-encrypted at all;
694
+ * every real decryption failure throws OmemoError (fail closed).
695
+ */
696
+ async decryptStanza(stanza, origin) {
697
+ const el = typeof stanza === "string" ? parseXmlDocument(stanza) : stanza;
698
+ const parsed = parseEncrypted(el);
699
+ if (!parsed) return null;
700
+ const source = origin ?? authenticatedMessageOrigin(el);
701
+ if (!source || source.senderBareJid !== parsed.senderBareJid) {
702
+ throw new OmemoError("missing or mismatched authenticated sender origin");
703
+ }
704
+ const from = el.getAttr("from");
705
+ if (from !== void 0 && !from.startsWith(`${source.roomJid}/`)) {
706
+ throw new OmemoError("authenticated room origin mismatch");
707
+ }
708
+ const candidates = parsed.keys.filter(
709
+ (k) => k.rid === this.deviceId && (k.jid === void 0 || k.jid === this.bareJid)
710
+ );
711
+ if (candidates.length === 0) {
712
+ throw new OmemoError(`no key element for device ${this.deviceId}`);
713
+ }
714
+ const peer = {
715
+ bareJid: parsed.senderBareJid,
716
+ deviceId: parsed.senderDeviceId
717
+ };
718
+ const lockKey = `${parsed.senderBareJid}.${parsed.senderDeviceId}`;
719
+ let lastErr;
720
+ let messageKey;
721
+ let commitCandidate;
722
+ let failed;
723
+ for (const ours of candidates) {
724
+ try {
725
+ messageKey = await this.withLock(lockKey, async () => {
726
+ try {
727
+ return await this.backend.decrypt(peer, ours.ciphertextB64, ours.isPreKey);
728
+ } catch (err) {
729
+ const viaCandidate = await this.recovery?.decryptCandidate(
730
+ peer,
731
+ ours.ciphertextB64,
732
+ ours.isPreKey
733
+ );
734
+ if (viaCandidate) {
735
+ commitCandidate = viaCandidate.commit;
736
+ return viaCandidate.key;
737
+ }
738
+ throw err;
739
+ }
740
+ });
741
+ break;
742
+ } catch (err) {
743
+ lastErr = err;
744
+ if (err instanceof OmemoReceiveError && !failed)
745
+ failed = { ciphertextB64: ours.ciphertextB64, failure: err.failure };
746
+ }
747
+ }
748
+ if (!messageKey && failed && this.recovery && authenticatedMessageOrigin(el) === source) {
749
+ const stanzaId = el.getAttr("id");
750
+ this.recovery.onReceiveFailure({
751
+ roomJid: source.roomJid,
752
+ peer,
753
+ stanzaId: typeof stanzaId === "string" ? stanzaId : void 0,
754
+ ciphertextB64: failed.ciphertextB64,
755
+ failure: failed.failure
756
+ });
757
+ }
758
+ if (!messageKey) {
759
+ throw lastErr instanceof OmemoError ? lastErr : new OmemoError(
760
+ `key decryption failed for device ${this.deviceId}: ${lastErr?.message}`
761
+ );
762
+ }
763
+ const ciphertext = b64decode(parsed.payloadB64);
764
+ if (ciphertext.length < GCM_TAG_BYTES + 1) {
765
+ throw new OmemoError("payload too short for GCM tag");
766
+ }
767
+ const key2 = await globalThis.crypto.subtle.importKey(
768
+ "raw",
769
+ messageKey,
770
+ { name: "AES-GCM" },
771
+ false,
772
+ ["decrypt"]
773
+ );
774
+ let envelope;
775
+ try {
776
+ envelope = new Uint8Array(
777
+ await globalThis.crypto.subtle.decrypt(
778
+ { name: "AES-GCM", iv: b64decode(parsed.ivB64) },
779
+ key2,
780
+ ciphertext
781
+ )
782
+ );
783
+ } catch {
784
+ throw new OmemoError("payload authentication failed");
785
+ }
786
+ const body = parseSceEnvelope(new TextDecoder().decode(envelope));
787
+ commitCandidate?.();
788
+ this.recovery?.onDecrypted(peer);
789
+ return body;
790
+ }
791
+ /** Run `fn` exclusively with every other ratchet operation on this peer device. */
792
+ exclusive(peer, fn) {
793
+ return this.withLock(`${peer.bareJid}.${peer.deviceId}`, fn);
794
+ }
795
+ /** Serialize async ops per key so concurrent calls never interleave on one ratchet. */
796
+ async withLock(key2, fn) {
797
+ const prev = this.locks.get(key2) ?? Promise.resolve();
798
+ const next = prev.then(fn, fn);
799
+ this.locks.set(
800
+ key2,
801
+ next.catch(() => {
802
+ })
803
+ );
804
+ return next;
805
+ }
806
+ };
807
+
808
+ // packages/omemo/src/state.ts
809
+ import { randomInt } from "node:crypto";
810
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
811
+ import { homedir, tmpdir } from "node:os";
812
+ import { join } from "node:path";
813
+ import {
814
+ IdentityKeyPair,
815
+ KEMKeyPair,
816
+ KyberPreKeyRecord,
817
+ PreKeyRecord,
818
+ PrivateKey,
819
+ PublicKey as PublicKey2,
820
+ SessionRecord,
821
+ SignedPreKeyRecord
822
+ } from "@signalapp/libsignal-client";
823
+ var STATE_VERSION = 1;
824
+ var PREKEY_COUNT = 100;
825
+ var SIGNED_PREKEY_ID = 1;
826
+ var KYBER_PREKEY_ID = 1;
827
+ var DEVICE_ID_MAX = 127;
828
+ function toRanges(ids) {
829
+ const sorted = [...ids].sort((a, b) => a - b);
830
+ const out = [];
831
+ for (const id of sorted) {
832
+ const last = out[out.length - 1];
833
+ if (last && id === last[1] + 1) last[1] = id;
834
+ else out.push([id, id]);
835
+ }
836
+ return out;
837
+ }
838
+ function fromRanges(ranges) {
839
+ const out = /* @__PURE__ */ new Set();
840
+ if (!Array.isArray(ranges)) throw new Error("corrupt prekey consumption ledger");
841
+ for (const r of ranges) {
842
+ if (!Array.isArray(r) || r.length !== 2 || !Number.isSafeInteger(r[0]) || !Number.isSafeInteger(r[1]) || r[0] < 1 || r[1] < r[0] || r[1] > 16777215)
843
+ throw new Error("corrupt prekey consumption ledger");
844
+ for (let id = r[0]; id <= r[1]; id++) out.add(id);
845
+ }
846
+ return out;
847
+ }
848
+ function b642(bytes) {
849
+ return Buffer.from(bytes).toString("base64");
850
+ }
851
+ function unb642(s) {
852
+ return new Uint8Array(Buffer.from(s, "base64"));
853
+ }
854
+ function defaultStateBaseDir() {
855
+ return join(homedir(), ".wacando");
856
+ }
857
+ function jidStateDirName(jid) {
858
+ return jid.replace(/[^a-zA-Z0-9._-]/g, "_");
859
+ }
860
+ var DeviceState = class _DeviceState {
861
+ jid;
862
+ deviceId;
863
+ identityKeyPair;
864
+ signedPreKey;
865
+ kyberPreKey;
866
+ preKeys = /* @__PURE__ */ new Map();
867
+ sessions = /* @__PURE__ */ new Map();
868
+ identities = /* @__PURE__ */ new Map();
869
+ /** #50: consumed one-time prekey IDs (tombstones; never key material). */
870
+ consumedPreKeys = /* @__PURE__ */ new Set();
871
+ /** #50: IDs below this were allocated before the ledger existed. */
872
+ ledgerFloor = 1;
873
+ /**
874
+ * #50: per-peer generation of AUTHENTICATED receive state. It moves on every
875
+ * successful decrypt and on every recovery promotion, so a candidate built
876
+ * on an older view never overwrites a session that has since become healthy.
877
+ */
878
+ sessionGenerations = /* @__PURE__ */ new Map();
879
+ /** #50: bundle publication generation. */
880
+ publicationGeneration = 0;
881
+ /** #50: opaque recovery ledger, persisted atomically with everything else. */
882
+ recoveryLedger;
883
+ nextPreKeyId = PREKEY_COUNT + 1;
884
+ stateFile;
885
+ codec;
886
+ ephemeral = false;
887
+ constructor(jid, stateFile, init) {
888
+ this.jid = jid;
889
+ this.stateFile = stateFile;
890
+ this.deviceId = init.deviceId;
891
+ this.identityKeyPair = init.identityKeyPair;
892
+ this.signedPreKey = init.signedPreKey;
893
+ this.kyberPreKey = init.kyberPreKey;
894
+ }
895
+ /** Load existing device state or generate a fresh device. */
896
+ static load(jid, options = {}) {
897
+ if (options.ephemeral) {
898
+ const state2 = _DeviceState.generate(jid, "");
899
+ state2.ephemeral = true;
900
+ return state2;
901
+ }
902
+ const base = options.baseDir ?? process.env.WACANDO_STATE_DIR ?? defaultStateBaseDir();
903
+ const dir = join(base, jidStateDirName(jid));
904
+ const codec = options.codec;
905
+ const plainFile = join(dir, "state.json");
906
+ const file = codec ? join(dir, "state.enc") : plainFile;
907
+ mkdirSync(dir, { recursive: true, mode: 448 });
908
+ chmodSync(dir, 448);
909
+ if (codec && existsSync(plainFile)) {
910
+ throw new Error("plaintext device state present where encrypted state is required");
911
+ }
912
+ if (existsSync(file)) {
913
+ const raw = codec ? codec.decode(new Uint8Array(readFileSync(file))) : readFileSync(file, "utf8");
914
+ const state2 = _DeviceState.deserialize(jid, file, raw);
915
+ state2.codec = codec;
916
+ return state2;
917
+ }
918
+ const state = _DeviceState.generate(jid, file);
919
+ state.codec = codec;
920
+ state.persist();
921
+ return state;
922
+ }
923
+ static generate(jid, file) {
924
+ const identityKeyPair = IdentityKeyPair.generate();
925
+ const deviceId = randomInt(1, DEVICE_ID_MAX + 1);
926
+ const signedPreKeyPair = PrivateKey.generate();
927
+ const signedPreKey = SignedPreKeyRecord.new(
928
+ SIGNED_PREKEY_ID,
929
+ Date.now(),
930
+ signedPreKeyPair.getPublicKey(),
931
+ signedPreKeyPair,
932
+ identityKeyPair.privateKey.sign(signedPreKeyPair.getPublicKey().serialize())
933
+ );
934
+ const kyberPair = KEMKeyPair.generate();
935
+ const kyberPreKey = KyberPreKeyRecord.new(
936
+ KYBER_PREKEY_ID,
937
+ Date.now(),
938
+ kyberPair,
939
+ identityKeyPair.privateKey.sign(kyberPair.getPublicKey().serialize())
940
+ );
941
+ const state = new _DeviceState(jid, file, {
942
+ deviceId,
943
+ identityKeyPair,
944
+ signedPreKey,
945
+ kyberPreKey
946
+ });
947
+ for (let id = 1; id <= PREKEY_COUNT; id++) {
948
+ const pk = PrivateKey.generate();
949
+ state.preKeys.set(id, PreKeyRecord.new(id, pk.getPublicKey(), pk));
950
+ }
951
+ return state;
952
+ }
953
+ static deserialize(jid, file, raw) {
954
+ const s = JSON.parse(raw);
955
+ if (s.version !== STATE_VERSION) {
956
+ throw new Error(`unsupported device-state version ${s.version} at ${file}`);
957
+ }
958
+ const state = new _DeviceState(jid, file, {
959
+ deviceId: s.deviceId,
960
+ identityKeyPair: IdentityKeyPair.deserialize(unb642(s.identityKeyPair)),
961
+ signedPreKey: SignedPreKeyRecord.deserialize(unb642(s.signedPreKey)),
962
+ kyberPreKey: KyberPreKeyRecord.deserialize(unb642(s.kyberPreKey))
963
+ });
964
+ for (const [id, rec] of Object.entries(s.preKeys)) {
965
+ state.preKeys.set(Number(id), PreKeyRecord.deserialize(unb642(rec)));
966
+ }
967
+ state.nextPreKeyId = Math.max(
968
+ PREKEY_COUNT + 1,
969
+ ...[...state.preKeys.keys()].map((id) => id + 1),
970
+ s.nextPreKeyId ?? 1
971
+ );
972
+ for (const [addr, rec] of Object.entries(s.sessions)) {
973
+ state.sessions.set(addr, SessionRecord.deserialize(unb642(rec)));
974
+ }
975
+ for (const [addr, key2] of Object.entries(s.identities)) {
976
+ state.identities.set(addr, PublicKey2.deserialize(unb642(key2)));
977
+ }
978
+ if (s.consumedPreKeys === void 0) {
979
+ state.ledgerFloor = state.nextPreKeyId;
980
+ } else {
981
+ for (const id of fromRanges(s.consumedPreKeys)) state.consumedPreKeys.add(id);
982
+ const floor = s.ledgerFloor ?? 1;
983
+ if (!Number.isSafeInteger(floor) || floor < 1) throw new Error("corrupt prekey ledger floor");
984
+ state.ledgerFloor = floor;
985
+ }
986
+ for (const [addr, gen] of Object.entries(s.sessionGenerations ?? {})) {
987
+ if (!Number.isSafeInteger(gen) || gen < 0) throw new Error("corrupt session generation");
988
+ state.sessionGenerations.set(addr, gen);
989
+ }
990
+ const pub = s.publicationGeneration ?? 0;
991
+ if (!Number.isSafeInteger(pub) || pub < 0) throw new Error("corrupt publication generation");
992
+ state.publicationGeneration = pub;
993
+ state.recoveryLedger = typeof s.recovery === "string" ? s.recovery : void 0;
994
+ return state;
995
+ }
996
+ /** Has this one-time prekey ID been allocated by this device (ever)? */
997
+ wasAllocated(id) {
998
+ return Number.isSafeInteger(id) && id >= 1 && id < this.nextPreKeyId;
999
+ }
1000
+ /**
1001
+ * #50 classification: was `id` consumed by an authenticated session on
1002
+ * this device, with a committed tombstone? IDs below the ledger floor have
1003
+ * unknown history and never qualify.
1004
+ */
1005
+ isConsumedPreKey(id) {
1006
+ return id >= this.ledgerFloor && this.consumedPreKeys.has(id) && !this.preKeys.has(id);
1007
+ }
1008
+ /** Delete a one-time prekey and commit its tombstone (call persist()). */
1009
+ consumePreKey(id) {
1010
+ this.preKeys.delete(id);
1011
+ if (this.wasAllocated(id)) this.consumedPreKeys.add(id);
1012
+ }
1013
+ /** Bump the authenticated-receive generation of a peer session (promotion fence). */
1014
+ bumpSessionGeneration(address2) {
1015
+ const next = (this.sessionGenerations.get(address2) ?? 0) + 1;
1016
+ this.sessionGenerations.set(address2, next);
1017
+ return next;
1018
+ }
1019
+ sessionGeneration(address2) {
1020
+ return this.sessionGenerations.get(address2) ?? 0;
1021
+ }
1022
+ /** Regenerate consumed one-time prekeys back to PREKEY_COUNT. */
1023
+ topUpPreKeys() {
1024
+ while (this.preKeys.size < PREKEY_COUNT) {
1025
+ const id = this.nextPreKeyId++;
1026
+ if (!Number.isSafeInteger(id) || id > 16777215) {
1027
+ throw new Error("native prekey ID space exhausted");
1028
+ }
1029
+ const pk = PrivateKey.generate();
1030
+ this.preKeys.set(id, PreKeyRecord.new(id, pk.getPublicKey(), pk));
1031
+ }
1032
+ this.persist();
1033
+ }
1034
+ /** Flush all in-memory state to state.json (0600). */
1035
+ persist() {
1036
+ if (this.ephemeral) return;
1037
+ const s = {
1038
+ version: STATE_VERSION,
1039
+ deviceId: this.deviceId,
1040
+ identityKeyPair: b642(this.identityKeyPair.serialize()),
1041
+ signedPreKey: b642(this.signedPreKey.serialize()),
1042
+ kyberPreKey: b642(this.kyberPreKey.serialize()),
1043
+ preKeys: Object.fromEntries(
1044
+ [...this.preKeys].map(([id, r]) => [String(id), b642(r.serialize())])
1045
+ ),
1046
+ nextPreKeyId: this.nextPreKeyId,
1047
+ sessions: Object.fromEntries([...this.sessions].map(([a, r]) => [a, b642(r.serialize())])),
1048
+ identities: Object.fromEntries([...this.identities].map(([a, k]) => [a, b642(k.serialize())])),
1049
+ consumedPreKeys: toRanges(this.consumedPreKeys),
1050
+ ledgerFloor: this.ledgerFloor,
1051
+ sessionGenerations: Object.fromEntries(this.sessionGenerations),
1052
+ publicationGeneration: this.publicationGeneration,
1053
+ ...this.recoveryLedger !== void 0 ? { recovery: this.recoveryLedger } : {}
1054
+ };
1055
+ const tmp = `${this.stateFile}.${process.pid}.tmp`;
1056
+ const text = `${JSON.stringify(s)}
1057
+ `;
1058
+ writeFileSync(tmp, this.codec ? this.codec.encode(text) : text, { mode: 384 });
1059
+ renameSync(tmp, this.stateFile);
1060
+ chmodSync(this.stateFile, 384);
1061
+ }
1062
+ };
1063
+
1064
+ // packages/omemo/src/decryptor.ts
1065
+ var OmemoTranscriptDecryptor = class {
1066
+ constructor(provider) {
1067
+ this.provider = provider;
1068
+ }
1069
+ provider;
1070
+ async decrypt(stanzaXml) {
1071
+ try {
1072
+ return await this.provider.decryptStanza(stanzaXml);
1073
+ } catch {
1074
+ return null;
1075
+ }
1076
+ }
1077
+ };
1078
+
1079
+ // packages/omemo/src/recovery.ts
1080
+ import {
1081
+ CiphertextMessageType as CiphertextMessageType2,
1082
+ PreKeySignalMessage as PreKeySignalMessage2,
1083
+ ProtocolAddress as ProtocolAddress2,
1084
+ PublicKey as PublicKey4,
1085
+ processPreKeyBundle as processPreKeyBundle2,
1086
+ SessionRecord as SessionRecord3,
1087
+ SignalMessage as SignalMessage2,
1088
+ signalDecrypt as signalDecrypt2,
1089
+ signalDecryptPreKey as signalDecryptPreKey2,
1090
+ signalEncrypt as signalEncrypt2
1091
+ } from "@signalapp/libsignal-client";
1092
+ import xml3 from "@xmpp/xml";
1093
+
1094
+ // packages/omemo/src/recovery-crypto.ts
1095
+ import { createHash } from "node:crypto";
1096
+ import {
1097
+ IdentityChange as IdentityChange2,
1098
+ IdentityKeyStore as IdentityKeyStore2,
1099
+ KyberPreKeyStore as KyberPreKeyStore2,
1100
+ PreKeyStore as PreKeyStore2,
1101
+ PublicKey as PublicKey3,
1102
+ SessionRecord as SessionRecord2,
1103
+ SessionStore as SessionStore2,
1104
+ SignedPreKeyStore as SignedPreKeyStore2
1105
+ } from "@signalapp/libsignal-client";
1106
+ function sha256Hex(bytes) {
1107
+ return createHash("sha256").update(bytes).digest("hex");
1108
+ }
1109
+ function fingerprintOf(identity) {
1110
+ return sha256Hex(identity instanceof PublicKey3 ? identity.serialize() : identity);
1111
+ }
1112
+ function canon(text) {
1113
+ if (text === void 0 || text === "") throw new OmemoError("bundle field missing");
1114
+ return b64encode(b64decode(text));
1115
+ }
1116
+ function bundleDigest(bundle) {
1117
+ if (bundle.getName() !== "bundle") throw new OmemoError("not an OMEMO bundle");
1118
+ const spk = bundle.getChild("signedPreKeyPublic");
1119
+ const kyber = bundle.getChild("kyberPreKeys", NS_WACANDO_OMEMO);
1120
+ const kpk = kyber?.getChild("kyberPreKeyPublic");
1121
+ const pairs = (bundle.getChild("prekeys")?.getChildren("preKeyPublic") ?? []).map((k) => {
1122
+ const id = Number(k.getAttr("preKeyId"));
1123
+ if (!Number.isSafeInteger(id) || id < 1) throw new OmemoError("bundle prekey id invalid");
1124
+ return [id, canon(k.text())];
1125
+ });
1126
+ pairs.sort((a, b) => a[0] - b[0]);
1127
+ const fields = [
1128
+ "wacando-omemo-bundle-digest-v1",
1129
+ canon(bundle.getChildText("identityKey") ?? void 0),
1130
+ String(Number(spk?.getAttr("signedPreKeyId"))),
1131
+ canon(spk?.text()),
1132
+ canon(bundle.getChildText("signedPreKeySignature") ?? void 0),
1133
+ ...pairs.flatMap(([id, pub]) => [String(id), pub]),
1134
+ "kyber",
1135
+ kyber ? String(Number(kpk?.getAttr("kyberPreKeyId"))) : "",
1136
+ kyber ? canon(kpk?.text()) : "",
1137
+ kyber ? canon(kyber.getChild("kyberPreKeySignature")?.text()) : ""
1138
+ ];
1139
+ return sha256Hex(utf8Encode(JSON.stringify(fields)));
1140
+ }
1141
+ function bundleIdentity(bundle) {
1142
+ const text = bundle.getChildText("identityKey");
1143
+ if (!text) throw new OmemoError("bundle missing identity");
1144
+ return b64decode(text);
1145
+ }
1146
+ function bundlePreKeyIds(bundle) {
1147
+ return (bundle.getChild("prekeys")?.getChildren("preKeyPublic") ?? []).map((k) => Number(k.getAttr("preKeyId"))).filter((id) => Number.isSafeInteger(id) && id > 0);
1148
+ }
1149
+ function controlPlaintext(phase, nonce, prevDigest) {
1150
+ return utf8Encode(JSON.stringify([RECOVERY_DOMAIN, "control", phase, nonce, prevDigest]));
1151
+ }
1152
+ function bytesEqual2(a, b) {
1153
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1154
+ }
1155
+ function addressKey(addr) {
1156
+ return `${addr.name()}.${addr.deviceId()}`;
1157
+ }
1158
+ var StagedStores = class {
1159
+ constructor(state, target, base, expectedIdentity) {
1160
+ this.state = state;
1161
+ this.target = target;
1162
+ this.expectedIdentity = expectedIdentity;
1163
+ this.record = base === null ? null : SessionRecord2.deserialize(
1164
+ typeof base === "string" ? b64decode(base) : base.serialize()
1165
+ );
1166
+ const self = this;
1167
+ const guard = (addr) => {
1168
+ if (addressKey(addr) !== self.target) throw new OmemoError("staged store: foreign address");
1169
+ };
1170
+ this.session = new class extends SessionStore2 {
1171
+ async saveSession(addr, record) {
1172
+ guard(addr);
1173
+ self.record = record;
1174
+ }
1175
+ async getSession(addr) {
1176
+ guard(addr);
1177
+ return self.record;
1178
+ }
1179
+ async getExistingSessions(addrs) {
1180
+ for (const a of addrs) guard(a);
1181
+ return self.record ? [self.record] : [];
1182
+ }
1183
+ }();
1184
+ this.identity = new class extends IdentityKeyStore2 {
1185
+ async getIdentityKey() {
1186
+ return self.state.identityKeyPair.privateKey;
1187
+ }
1188
+ async getLocalRegistrationId() {
1189
+ return self.state.deviceId;
1190
+ }
1191
+ async saveIdentity(addr, pub) {
1192
+ guard(addr);
1193
+ const pin = self.state.identities.get(self.target);
1194
+ if (pin) {
1195
+ if (!pin.equals(pub)) throw new OmemoError("untrusted identity: existing pin differs");
1196
+ return IdentityChange2.NewOrUnchanged;
1197
+ }
1198
+ if (!self.expectedIdentity?.equals(pub)) {
1199
+ throw new OmemoError("untrusted identity: not the expected first-contact identity");
1200
+ }
1201
+ self.stagedPin = pub;
1202
+ return IdentityChange2.NewOrUnchanged;
1203
+ }
1204
+ async isTrustedIdentity(addr, pub, _d) {
1205
+ if (addressKey(addr) !== self.target) return false;
1206
+ const pin = self.state.identities.get(self.target);
1207
+ return pin ? pin.equals(pub) : self.expectedIdentity?.equals(pub) === true;
1208
+ }
1209
+ async getIdentity(addr) {
1210
+ guard(addr);
1211
+ return self.state.identities.get(self.target) ?? self.stagedPin ?? null;
1212
+ }
1213
+ }();
1214
+ this.preKey = new class extends PreKeyStore2 {
1215
+ async savePreKey() {
1216
+ throw new OmemoError("staged store: prekey generation refused");
1217
+ }
1218
+ async getPreKey(id) {
1219
+ const rec = self.removedPreKeys.has(id) ? void 0 : self.state.preKeys.get(id);
1220
+ if (!rec) throw new OmemoError("one-time prekey not found");
1221
+ return rec;
1222
+ }
1223
+ async removePreKey(id) {
1224
+ self.removedPreKeys.add(id);
1225
+ }
1226
+ }();
1227
+ this.signedPreKey = new class extends SignedPreKeyStore2 {
1228
+ async saveSignedPreKey() {
1229
+ throw new OmemoError("staged store: signed prekey write refused");
1230
+ }
1231
+ async getSignedPreKey(id) {
1232
+ if (id !== self.state.signedPreKey.id()) throw new OmemoError("signed prekey not found");
1233
+ return self.state.signedPreKey;
1234
+ }
1235
+ }();
1236
+ this.kyberPreKey = new class extends KyberPreKeyStore2 {
1237
+ async saveKyberPreKey() {
1238
+ throw new OmemoError("staged store: kyber prekey write refused");
1239
+ }
1240
+ async getKyberPreKey(id) {
1241
+ if (id !== self.state.kyberPreKey.id()) throw new OmemoError("kyber prekey not found");
1242
+ return self.state.kyberPreKey;
1243
+ }
1244
+ async markKyberPreKeyUsed() {
1245
+ }
1246
+ }();
1247
+ }
1248
+ state;
1249
+ target;
1250
+ expectedIdentity;
1251
+ record;
1252
+ stagedPin;
1253
+ removedPreKeys = /* @__PURE__ */ new Set();
1254
+ session;
1255
+ identity;
1256
+ preKey;
1257
+ signedPreKey;
1258
+ kyberPreKey;
1259
+ /** The staged record, serialized (endpoint-private, same protection as sessions). */
1260
+ serialized() {
1261
+ if (!this.record) throw new OmemoError("staged store holds no session");
1262
+ return b64encode(this.record.serialize());
1263
+ }
1264
+ };
1265
+
1266
+ // packages/omemo/src/recovery-first-contact.ts
1267
+ var DIRECTORY_FIRST_CONTACT = /* @__PURE__ */ Symbol(
1268
+ "wacando.recovery.directoryFirstContact"
1269
+ );
1270
+
1271
+ // packages/omemo/src/recovery-machine.ts
1272
+ var RECOVERY_LIMITS = {
1273
+ /** Authenticated automatic attempts per local/peer device pair, rolling hour. */
1274
+ attemptsPerPairPerHour: 3,
1275
+ /** Delay before the 1st, 2nd and 3rd attempt of a window (seconds). */
1276
+ attemptDelaysS: [0, 5, 30],
1277
+ jitterMaxMs: 1e3,
1278
+ attemptTtlS: 120,
1279
+ skewS: 30,
1280
+ /** Exact cached packet retransmissions inside the attempt expiry. */
1281
+ retransmitDelaysMs: [2e3, 8e3],
1282
+ offersPerPeerPerHour: 4,
1283
+ offersPerLocalPerMinute: 16,
1284
+ maxUnconfirmedOffers: 16,
1285
+ maxAcceptedAttempts: 16,
1286
+ maxPendingSendsPerPeer: 8,
1287
+ maxPendingSends: 64,
1288
+ /** Failed-packet digests already resolved (recovered or refused as stale). */
1289
+ maxResolvedKeys: 256,
1290
+ /** Cancellation fences kept (oldest dropped; lifecycle + STOP still deny). */
1291
+ maxCancelled: 1024,
1292
+ /** Periodic expiry check, independent of inbound controls. */
1293
+ tickIntervalMs: 15e3
1294
+ };
1295
+ var HOUR = 3600;
1296
+ var MINUTE = 60;
1297
+ function emptyLedger() {
1298
+ return {
1299
+ v: 1,
1300
+ attempts: [],
1301
+ spends: [],
1302
+ offers: [],
1303
+ pendingSends: [],
1304
+ backoff: {},
1305
+ cancelled: [],
1306
+ highWater: 0,
1307
+ resolvedKeys: []
1308
+ };
1309
+ }
1310
+ function parseLedger(raw) {
1311
+ if (raw === void 0) return emptyLedger();
1312
+ const v = JSON.parse(raw);
1313
+ if (v.v !== 1 || !Array.isArray(v.attempts) || !Array.isArray(v.spends) || !Array.isArray(v.offers) || !Array.isArray(v.pendingSends) || typeof v.backoff !== "object" || v.backoff === null || !Array.isArray(v.cancelled) || typeof v.highWater !== "number")
1314
+ throw new Error("corrupt recovery ledger");
1315
+ if (v.resolvedKeys !== void 0 && !Array.isArray(v.resolvedKeys))
1316
+ throw new Error("corrupt recovery ledger");
1317
+ return { ...v, resolvedKeys: v.resolvedKeys ?? [] };
1318
+ }
1319
+ function markResolved(ledger, keyDigest) {
1320
+ if (ledger.resolvedKeys.includes(keyDigest)) return;
1321
+ ledger.resolvedKeys.push(keyDigest);
1322
+ if (ledger.resolvedKeys.length > RECOVERY_LIMITS.maxResolvedKeys) ledger.resolvedKeys.shift();
1323
+ }
1324
+ function isResolved(ledger, keyDigest) {
1325
+ return ledger.resolvedKeys.includes(keyDigest);
1326
+ }
1327
+ function serializeLedger(ledger) {
1328
+ return JSON.stringify(ledger);
1329
+ }
1330
+ function pairKey(peerJid, peerDevice) {
1331
+ return `${peerJid}#${peerDevice}`;
1332
+ }
1333
+ var TERMINAL = /* @__PURE__ */ new Set(["recovered", "failed", "cancelled"]);
1334
+ function isUnconfirmed(a) {
1335
+ return a.state === "offered";
1336
+ }
1337
+ function isAccepted(a) {
1338
+ return !TERMINAL.has(a.state) && !isUnconfirmed(a);
1339
+ }
1340
+ function activeAttempt(ledger, pair) {
1341
+ return ledger.attempts.find(
1342
+ (a) => pairKey(a.peerJid, a.peerDevice) === pair && !TERMINAL.has(a.state)
1343
+ );
1344
+ }
1345
+ function findAttempt(ledger, nonce) {
1346
+ return ledger.attempts.find((a) => a.nonce === nonce);
1347
+ }
1348
+ function observeClock(ledger, nowS) {
1349
+ if (nowS + RECOVERY_LIMITS.skewS < ledger.highWater) return false;
1350
+ if (nowS > ledger.highWater) ledger.highWater = nowS;
1351
+ return true;
1352
+ }
1353
+ function isCancelled(ledger, conversationId) {
1354
+ return ledger.cancelled.includes(conversationId);
1355
+ }
1356
+ function cancelConversation(ledger, conversationId) {
1357
+ if (!ledger.cancelled.includes(conversationId)) {
1358
+ ledger.cancelled.push(conversationId);
1359
+ if (ledger.cancelled.length > RECOVERY_LIMITS.maxCancelled) ledger.cancelled.shift();
1360
+ }
1361
+ const hit = [];
1362
+ for (const a of ledger.attempts) {
1363
+ if (a.conversationId !== conversationId || a.state === "cancelled") continue;
1364
+ a.state = "cancelled";
1365
+ delete a.cached;
1366
+ a.reason = "not_authorized";
1367
+ hit.push(a);
1368
+ }
1369
+ return hit;
1370
+ }
1371
+ function prune(ledger, nowS) {
1372
+ ledger.spends = ledger.spends.filter((s) => nowS - s.at < HOUR);
1373
+ ledger.offers = ledger.offers.filter((s) => nowS - s.at < HOUR);
1374
+ ledger.pendingSends = ledger.pendingSends.filter((p) => nowS - p.at < HOUR);
1375
+ ledger.attempts = ledger.attempts.filter(
1376
+ (a) => !TERMINAL.has(a.state) || nowS <= a.expiresAt + RECOVERY_LIMITS.skewS
1377
+ );
1378
+ }
1379
+ function canOffer(ledger, pair, conversationId, nowS) {
1380
+ if (!observeClock(ledger, nowS)) return { ok: false, reason: "clock" };
1381
+ prune(ledger, nowS);
1382
+ if (isCancelled(ledger, conversationId)) return { ok: false, reason: "cancelled" };
1383
+ if (activeAttempt(ledger, pair)) return { ok: false, reason: "busy" };
1384
+ if ((ledger.backoff[pair] ?? 0) > nowS) return { ok: false, reason: "backoff" };
1385
+ const perPeer = ledger.offers.filter((o) => o.pair === pair).length;
1386
+ const perMinute = ledger.offers.filter((o) => nowS - o.at < MINUTE).length;
1387
+ const unconfirmed = ledger.attempts.filter(isUnconfirmed).length;
1388
+ if (perPeer >= RECOVERY_LIMITS.offersPerPeerPerHour || perMinute >= RECOVERY_LIMITS.offersPerLocalPerMinute || unconfirmed >= RECOVERY_LIMITS.maxUnconfirmedOffers)
1389
+ return { ok: false, reason: "quota" };
1390
+ const spent = ledger.spends.filter((s) => s.pair === pair).length;
1391
+ if (spent >= RECOVERY_LIMITS.attemptsPerPairPerHour) return { ok: false, reason: "quota" };
1392
+ return { ok: true };
1393
+ }
1394
+ function recordOffer(ledger, attempt, nowS) {
1395
+ ledger.offers.push({ pair: pairKey(attempt.peerJid, attempt.peerDevice), at: nowS });
1396
+ ledger.attempts.push(attempt);
1397
+ }
1398
+ function canSpend(ledger, pair, conversationId, nowS, except) {
1399
+ if (!observeClock(ledger, nowS)) return { ok: false, reason: "clock" };
1400
+ prune(ledger, nowS);
1401
+ if (isCancelled(ledger, conversationId)) return { ok: false, reason: "cancelled" };
1402
+ const live = ledger.attempts.filter(
1403
+ (a) => pairKey(a.peerJid, a.peerDevice) === pair && !TERMINAL.has(a.state) && a.nonce !== except
1404
+ );
1405
+ if (live.some(
1406
+ (a) => a.state === "awaiting_commit_resolution" || a.state === "recovered_unacknowledged"
1407
+ ))
1408
+ return { ok: false, reason: "fenced" };
1409
+ if (live.length > 0) return { ok: false, reason: "fenced" };
1410
+ const spent = ledger.spends.filter((s) => s.pair === pair).length;
1411
+ const accepted = ledger.attempts.filter((a) => isAccepted(a) && a.nonce !== except).length;
1412
+ if (spent >= RECOVERY_LIMITS.attemptsPerPairPerHour) return { ok: false, reason: "limit" };
1413
+ if (accepted >= RECOVERY_LIMITS.maxAcceptedAttempts) return { ok: false, reason: "limit" };
1414
+ return { ok: true };
1415
+ }
1416
+ function spend(ledger, pair, nowS) {
1417
+ ledger.spends.push({ pair, at: nowS });
1418
+ }
1419
+ function scheduleBackoff(ledger, pair, nowS, jitterMs) {
1420
+ const spent = ledger.spends.filter((s) => s.pair === pair && nowS - s.at < HOUR).length;
1421
+ const delays = RECOVERY_LIMITS.attemptDelaysS;
1422
+ const delay = delays[Math.min(spent, delays.length - 1)] ?? 0;
1423
+ const jitter = Math.max(0, Math.min(RECOVERY_LIMITS.jitterMaxMs, jitterMs)) / 1e3;
1424
+ ledger.backoff[pair] = nowS + delay + jitter;
1425
+ }
1426
+ function classifyInbound(attempt, phase, recordDigest, prevDigest) {
1427
+ if (attempt.state === "cancelled") return "reject";
1428
+ if (attempt.inboundDigest !== "" && recordDigest === attempt.inboundDigest) {
1429
+ return attempt.cached ? "duplicate" : "reject";
1430
+ }
1431
+ const expected = attempt.role === "A" ? { handshaking: "CONFIRM", awaiting_commit_resolution: "COMMITTED" } : { offered: "INIT", confirming: "COMMIT" };
1432
+ if (expected[attempt.state] !== phase) return "reject";
1433
+ if (prevDigest !== attempt.sentDigest) return "reject";
1434
+ return "process";
1435
+ }
1436
+ function withinValidity(createdAt, expiresAt, nowS) {
1437
+ return expiresAt - createdAt <= RECOVERY_LIMITS.attemptTtlS && createdAt <= nowS + RECOVERY_LIMITS.skewS && nowS <= expiresAt + RECOVERY_LIMITS.skewS;
1438
+ }
1439
+ function expireAttempts(ledger, nowS, jitterMs) {
1440
+ const out = [];
1441
+ for (const a of ledger.attempts) {
1442
+ if (TERMINAL.has(a.state) || nowS <= a.expiresAt + RECOVERY_LIMITS.skewS) continue;
1443
+ const pair = pairKey(a.peerJid, a.peerDevice);
1444
+ if (a.state === "offered") {
1445
+ a.state = "failed";
1446
+ a.reason = "expired";
1447
+ delete a.cached;
1448
+ scheduleBackoff(ledger, pair, nowS, jitterMs);
1449
+ out.push({ attempt: a, status: "backoff" });
1450
+ } else if (a.state === "handshaking" || a.state === "confirming") {
1451
+ a.state = "failed";
1452
+ a.reason = "expired";
1453
+ delete a.cached;
1454
+ delete a.candidate;
1455
+ scheduleBackoff(ledger, pair, nowS, jitterMs);
1456
+ out.push({ attempt: a, status: "backoff" });
1457
+ } else if (a.state === "awaiting_commit_resolution" && a.reason !== "expired") {
1458
+ a.reason = "expired";
1459
+ delete a.cached;
1460
+ out.push({ attempt: a, status: "needs_attention" });
1461
+ } else if (a.state === "recovered_unacknowledged" && a.reason !== "expired") {
1462
+ a.reason = "expired";
1463
+ delete a.cached;
1464
+ }
1465
+ }
1466
+ return out;
1467
+ }
1468
+ function compareInitiators(x, y) {
1469
+ const a = utf8Encode(x.jid);
1470
+ const b = utf8Encode(y.jid);
1471
+ const n = Math.min(a.length, b.length);
1472
+ for (let i = 0; i < n; i++) {
1473
+ const d = a[i] - b[i];
1474
+ if (d !== 0) return d < 0 ? -1 : 1;
1475
+ }
1476
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
1477
+ if (x.device === y.device) return 0;
1478
+ return x.device < y.device ? -1 : 1;
1479
+ }
1480
+ function recordPendingSend(ledger, send) {
1481
+ if (ledger.pendingSends.some((p) => p.pair === send.pair && p.keyDigest === send.keyDigest))
1482
+ return;
1483
+ ledger.pendingSends.push(send);
1484
+ const mine = ledger.pendingSends.filter((p) => p.pair === send.pair);
1485
+ if (mine.length > RECOVERY_LIMITS.maxPendingSendsPerPeer) {
1486
+ const drop = mine[0];
1487
+ ledger.pendingSends = ledger.pendingSends.filter((p) => p !== drop);
1488
+ }
1489
+ if (ledger.pendingSends.length > RECOVERY_LIMITS.maxPendingSends) ledger.pendingSends.shift();
1490
+ }
1491
+ function clearPendingSends(ledger, pair) {
1492
+ ledger.pendingSends = ledger.pendingSends.filter((p) => p.pair !== pair);
1493
+ }
1494
+ function findPendingSend(ledger, pair, roomJid, stanzaId, keyDigest, preKeyId) {
1495
+ return ledger.pendingSends.find(
1496
+ (p) => p.pair === pair && p.roomJid === roomJid && p.stanzaId === stanzaId && p.keyDigest === keyDigest && p.preKeyId === preKeyId
1497
+ );
1498
+ }
1499
+ function statusOf(a) {
1500
+ switch (a.state) {
1501
+ case "recovered":
1502
+ return "recovered";
1503
+ case "recovered_unacknowledged":
1504
+ return "recovered";
1505
+ case "cancelled":
1506
+ return "cancelled";
1507
+ case "failed":
1508
+ return a.reason === "expired" || a.reason === "stale" || a.reason === "transport_unavailable" ? "backoff" : "needs_attention";
1509
+ case "awaiting_commit_resolution":
1510
+ return a.reason === "expired" ? "needs_attention" : "recovering";
1511
+ default:
1512
+ return "recovering";
1513
+ }
1514
+ }
1515
+
1516
+ // packages/omemo/src/recovery.ts
1517
+ var NS_STANZA = "urn:ietf:params:xml:ns:xmpp-stanzas";
1518
+ var Refused = class extends Error {
1519
+ constructor(condition, reason) {
1520
+ super(reason);
1521
+ this.condition = condition;
1522
+ this.reason = reason;
1523
+ }
1524
+ condition;
1525
+ reason;
1526
+ };
1527
+ function errorEl(condition) {
1528
+ const type = condition === "resource-constraint" ? "wait" : condition === "not-acceptable" ? "modify" : "cancel";
1529
+ return xml3("error", { type }, xml3(condition, NS_STANZA));
1530
+ }
1531
+ function conversationOfRoom(roomJid) {
1532
+ const local = roomJid.split("@")[0] ?? "";
1533
+ return /^c_[a-z0-9][a-z0-9_-]{0,63}$/.test(local) ? local.slice(2) : void 0;
1534
+ }
1535
+ var TRANSIENT = /* @__PURE__ */ new Set([
1536
+ "recipient-unavailable",
1537
+ "remote-server-timeout",
1538
+ "resource-constraint",
1539
+ "internal-server-error"
1540
+ ]);
1541
+ var REFUSAL_REASON = {
1542
+ forbidden: "not_authorized",
1543
+ "not-authorized": "authentication_failed",
1544
+ "not-acceptable": "stale",
1545
+ "service-unavailable": "transport_unavailable",
1546
+ "item-not-found": "transport_unavailable"
1547
+ };
1548
+ function sameTuple(a, b) {
1549
+ return a.conversationId === b.conversationId && a.roomJid === b.roomJid && a.aJid === b.aJid && a.aDevice === b.aDevice && a.aFingerprint === b.aFingerprint && a.bJid === b.bJid && a.bDevice === b.bDevice && a.bFingerprint === b.bFingerprint && a.nonce === b.nonce && a.createdAt === b.createdAt && a.expiresAt === b.expiresAt && a.consumedPreKeyId === b.consumedPreKeyId && a.stanzaId === b.stanzaId && a.keyDigest === b.keyDigest && a.generation === b.generation && a.bundleDigest === b.bundleDigest;
1550
+ }
1551
+ var SessionRecovery = class _SessionRecovery {
1552
+ constructor(o) {
1553
+ this.o = o;
1554
+ this.now = o.now ?? (() => Date.now());
1555
+ this.jitter = o.jitter ?? (() => Math.random());
1556
+ this.timeoutMs = o.timeoutMs ?? 1e4;
1557
+ this.setTimer = o.setTimer ?? ((fn, ms) => {
1558
+ const t = setTimeout(fn, ms);
1559
+ t.unref?.();
1560
+ return () => clearTimeout(t);
1561
+ });
1562
+ try {
1563
+ this.ledger = parseLedger(o.backend.state.recoveryLedger);
1564
+ } catch {
1565
+ this.corrupt = true;
1566
+ this.ledger = parseLedger(void 0);
1567
+ }
1568
+ }
1569
+ o;
1570
+ ledger;
1571
+ corrupt = false;
1572
+ detached = false;
1573
+ chain = Promise.resolve();
1574
+ /** Pairs with network work in flight (offer preparation / offer acceptance). */
1575
+ inflight = /* @__PURE__ */ new Set();
1576
+ timers = /* @__PURE__ */ new Set();
1577
+ persistQueued = false;
1578
+ /** Background work in flight (offer preparation, acceptance, sends). */
1579
+ pending = /* @__PURE__ */ new Set();
1580
+ now;
1581
+ setTimer;
1582
+ jitter;
1583
+ timeoutMs;
1584
+ /** Attach to a provider + XMPP entity. One instance per device state at a time. */
1585
+ static attach(options) {
1586
+ const recovery = new _SessionRecovery(options);
1587
+ options.entity.iqCallee.set(NS_RECOVERY, "recovery", (ctx) => recovery.onIq(ctx));
1588
+ options.provider.recovery = recovery;
1589
+ recovery.tick();
1590
+ recovery.armTick();
1591
+ return recovery;
1592
+ }
1593
+ armTick() {
1594
+ if (this.detached) return;
1595
+ const cancel = this.setTimer(() => {
1596
+ this.timers.delete(cancel);
1597
+ this.tick();
1598
+ this.armTick();
1599
+ }, RECOVERY_LIMITS.tickIntervalMs);
1600
+ this.timers.add(cancel);
1601
+ }
1602
+ /**
1603
+ * Resolves once no recovery work is in flight (event-driven barrier for
1604
+ * callers and tests; retransmission timers are not awaited).
1605
+ */
1606
+ async idle() {
1607
+ while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
1608
+ }
1609
+ track(work) {
1610
+ const tracked = work.catch(() => {
1611
+ }).finally(() => this.pending.delete(tracked));
1612
+ this.pending.add(tracked);
1613
+ }
1614
+ /** Stop handling controls (connection replaced). Persisted state is kept. */
1615
+ detach() {
1616
+ this.detached = true;
1617
+ for (const cancel of this.timers) cancel();
1618
+ this.timers.clear();
1619
+ if (this.o.provider.recovery === this) this.o.provider.recovery = void 0;
1620
+ }
1621
+ /** Current attempts (identifiers and states only) — diagnostics/tests. */
1622
+ attempts() {
1623
+ return this.ledger.attempts.map((a) => ({
1624
+ nonce: a.nonce,
1625
+ role: a.role,
1626
+ conversationId: a.conversationId,
1627
+ peerJid: a.peerJid,
1628
+ peerDevice: a.peerDevice,
1629
+ state: a.state,
1630
+ reason: a.reason
1631
+ }));
1632
+ }
1633
+ /** Authoritative attempts spent for a peer device in the rolling hour. */
1634
+ spentFor(peer) {
1635
+ const pair = pairKey(peer.bareJid, peer.deviceId);
1636
+ return this.ledger.spends.filter((s) => s.pair === pair).length;
1637
+ }
1638
+ /**
1639
+ * STOP / revocation / closed: persist the terminal fence NOW (synchronous),
1640
+ * before any further callback, and cancel every attempt of the conversation.
1641
+ */
1642
+ cancelConversation(conversationId) {
1643
+ const hit = cancelConversation(this.ledger, conversationId);
1644
+ this.persist();
1645
+ for (const a of hit) this.emit(a, "cancelled", "not_authorized");
1646
+ }
1647
+ /** Apply expiry (and its statuses) at the current clock. */
1648
+ tick() {
1649
+ if (this.corrupt || this.detached) return;
1650
+ const nowS = this.nowS();
1651
+ if (!observeClock(this.ledger, nowS)) return;
1652
+ const out = expireAttempts(this.ledger, nowS, this.jitterMs());
1653
+ if (out.length > 0) this.persist();
1654
+ for (const { attempt, status } of out) this.emit(attempt, status, attempt.reason);
1655
+ }
1656
+ // ---------------------------------------------------------------- hook
1657
+ onPreKeySent(peer, stanzaId, ciphertextB64, roomJid) {
1658
+ if (this.corrupt || this.detached) return;
1659
+ let preKeyId;
1660
+ let raw;
1661
+ try {
1662
+ raw = b64decode(ciphertextB64);
1663
+ preKeyId = PreKeySignalMessage2.deserialize(raw).preKeyId();
1664
+ } catch {
1665
+ return;
1666
+ }
1667
+ if (preKeyId === null) return;
1668
+ recordPendingSend(this.ledger, {
1669
+ pair: pairKey(peer.bareJid, peer.deviceId),
1670
+ roomJid,
1671
+ stanzaId,
1672
+ keyDigest: sha256Hex(raw),
1673
+ preKeyId,
1674
+ at: this.nowS()
1675
+ });
1676
+ if (!this.persistQueued) {
1677
+ this.persistQueued = true;
1678
+ queueMicrotask(() => {
1679
+ this.persistQueued = false;
1680
+ this.persist();
1681
+ });
1682
+ }
1683
+ }
1684
+ onDecrypted(peer) {
1685
+ if (this.corrupt || this.detached) return;
1686
+ const pair = pairKey(peer.bareJid, peer.deviceId);
1687
+ let changed = this.ledger.pendingSends.some((p) => p.pair === pair);
1688
+ clearPendingSends(this.ledger, pair);
1689
+ for (const a of this.ledger.attempts) {
1690
+ if (pairKey(a.peerJid, a.peerDevice) !== pair || TERMINAL.has(a.state)) continue;
1691
+ if (a.role === "B" && a.state === "recovered_unacknowledged") {
1692
+ a.state = "recovered";
1693
+ changed = true;
1694
+ this.emit(a, "recovered");
1695
+ } else if (a.state === "offered" || a.state === "handshaking" || a.state === "confirming") {
1696
+ a.state = "failed";
1697
+ a.reason = "stale";
1698
+ delete a.cached;
1699
+ delete a.candidate;
1700
+ changed = true;
1701
+ this.emit(a, "recovered", "stale");
1702
+ }
1703
+ }
1704
+ if (changed) this.persist();
1705
+ }
1706
+ onReceiveFailure(info) {
1707
+ this.track(
1708
+ this.startOffer(info).catch(() => {
1709
+ this.log("recovery.offer.error", { peerDevice: info.peer.deviceId });
1710
+ })
1711
+ );
1712
+ }
1713
+ async decryptCandidate(peer, ciphertextB64, isPreKey) {
1714
+ if (this.corrupt || this.detached || isPreKey) return void 0;
1715
+ const pair = pairKey(peer.bareJid, peer.deviceId);
1716
+ const a = this.ledger.attempts.find(
1717
+ (x) => pairKey(x.peerJid, x.peerDevice) === pair && x.role === "A" && x.state === "awaiting_commit_resolution" && x.candidate !== void 0
1718
+ );
1719
+ if (!a?.candidate) return void 0;
1720
+ if (isCancelled(this.ledger, a.conversationId) || this.o.lifecycle(a.conversationId) !== "active")
1721
+ return void 0;
1722
+ const key2 = `${peer.bareJid}.${peer.deviceId}`;
1723
+ const staged = new StagedStores(this.state, key2, a.candidate);
1724
+ let plaintext;
1725
+ try {
1726
+ plaintext = await signalDecrypt2(
1727
+ SignalMessage2.deserialize(b64decode(ciphertextB64)),
1728
+ ProtocolAddress2.new(peer.bareJid, peer.deviceId),
1729
+ this.localAddress(),
1730
+ staged.session,
1731
+ staged.identity
1732
+ );
1733
+ } catch {
1734
+ return void 0;
1735
+ }
1736
+ if (this.state.sessionGeneration(key2) !== a.candidateBaseGeneration || !staged.record) {
1737
+ a.reason = "state";
1738
+ this.persist();
1739
+ this.emit(a, "needs_attention", "state");
1740
+ return void 0;
1741
+ }
1742
+ const record = staged.record;
1743
+ const commit = () => {
1744
+ if (a.state !== "awaiting_commit_resolution" || this.state.sessionGeneration(key2) !== a.candidateBaseGeneration || !this.allowed(a.conversationId))
1745
+ return;
1746
+ this.promote(key2, record);
1747
+ this.finishInitiator(a, pair);
1748
+ };
1749
+ return { key: plaintext, commit };
1750
+ }
1751
+ // ------------------------------------------------------------- B: OFFER
1752
+ async startOffer(info) {
1753
+ if (this.corrupt || this.detached) return;
1754
+ const conversationId = conversationOfRoom(info.roomJid);
1755
+ if (!conversationId) return;
1756
+ const { peer, failure } = info;
1757
+ const pair = pairKey(peer.bareJid, peer.deviceId);
1758
+ if (failure.kind === "untrusted_identity" || failure.kind === "missing_prekey_unknown") {
1759
+ this.emitRaw(conversationId, peer, "receiver", "needs_attention", failure.kind);
1760
+ return;
1761
+ }
1762
+ if (failure.kind !== "consumed_prekey_candidate" || failure.preKeyId === void 0) return;
1763
+ const claimed = failure.claimedIdentity;
1764
+ if (!claimed || !info.stanzaId || !/^[A-Za-z0-9_.:-]{1,64}$/.test(info.stanzaId)) return;
1765
+ if (this.inflight.has(pair) || !this.allowed(conversationId)) return;
1766
+ this.tick();
1767
+ if (isResolved(this.ledger, sha256Hex(b64decode(info.ciphertextB64)))) return;
1768
+ const gate = canOffer(this.ledger, pair, conversationId, this.nowS());
1769
+ if (!gate.ok) {
1770
+ if (gate.reason === "quota" || gate.reason === "clock")
1771
+ this.emitRaw(
1772
+ conversationId,
1773
+ peer,
1774
+ "receiver",
1775
+ "needs_attention",
1776
+ gate.reason === "clock" ? "clock" : "limit"
1777
+ );
1778
+ else if (gate.reason === "backoff") this.emitRaw(conversationId, peer, "receiver", "backoff");
1779
+ return;
1780
+ }
1781
+ this.inflight.add(pair);
1782
+ try {
1783
+ const key2 = `${peer.bareJid}.${peer.deviceId}`;
1784
+ const pin = this.state.identities.get(key2);
1785
+ let peerIdentity;
1786
+ if (pin) {
1787
+ if (!bytesEqual2(pin.serialize(), claimed)) {
1788
+ this.emitRaw(conversationId, peer, "receiver", "needs_attention", "untrusted_identity");
1789
+ return;
1790
+ }
1791
+ } else if (this.o[DIRECTORY_FIRST_CONTACT] !== true) {
1792
+ this.emitRaw(conversationId, peer, "receiver", "needs_attention", "first_contact");
1793
+ return;
1794
+ } else {
1795
+ const items = await pepFetchItems(
1796
+ this.o.entity,
1797
+ peer.bareJid,
1798
+ bundleNode(peer.deviceId),
1799
+ this.timeoutMs
1800
+ );
1801
+ const advertised = items[0];
1802
+ if (!advertised || advertised.getChild("kyberPreKeys", NS_WACANDO_OMEMO) === void 0 || !bytesEqual2(bundleIdentity(advertised), claimed)) {
1803
+ this.emitRaw(conversationId, peer, "receiver", "needs_attention", "untrusted_identity");
1804
+ return;
1805
+ }
1806
+ peerIdentity = b64encode(claimed);
1807
+ }
1808
+ const published = await this.o.provider.publishBundle(this.o.entity, this.timeoutMs);
1809
+ if (this.detached || !this.allowed(conversationId)) return;
1810
+ const again = canOffer(this.ledger, pair, conversationId, this.nowS());
1811
+ if (!again.ok) return;
1812
+ this.state.publicationGeneration += 1;
1813
+ const nowS = this.nowS();
1814
+ const record = {
1815
+ phase: "OFFER",
1816
+ conversationId,
1817
+ roomJid: info.roomJid,
1818
+ aJid: peer.bareJid,
1819
+ aDevice: peer.deviceId,
1820
+ aFingerprint: fingerprintOf(claimed),
1821
+ bJid: this.o.provider.bareJid,
1822
+ bDevice: this.state.deviceId,
1823
+ bFingerprint: this.ownFingerprint(),
1824
+ nonce: Buffer.from(randomBytes(32)).toString("hex"),
1825
+ createdAt: nowS,
1826
+ expiresAt: nowS + RECOVERY_LIMITS.attemptTtlS,
1827
+ consumedPreKeyId: failure.preKeyId,
1828
+ stanzaId: info.stanzaId,
1829
+ keyDigest: sha256Hex(b64decode(info.ciphertextB64)),
1830
+ generation: this.state.publicationGeneration,
1831
+ bundleDigest: bundleDigest(published),
1832
+ prevDigest: "",
1833
+ ctlDigest: "",
1834
+ selectedPreKeyId: null
1835
+ };
1836
+ const { packet, digest } = this.sign(record);
1837
+ const attempt = {
1838
+ nonce: record.nonce,
1839
+ role: "B",
1840
+ conversationId,
1841
+ roomJid: info.roomJid,
1842
+ peerJid: peer.bareJid,
1843
+ peerDevice: peer.deviceId,
1844
+ state: "offered",
1845
+ createdAt: record.createdAt,
1846
+ expiresAt: record.expiresAt,
1847
+ consumedPreKeyId: record.consumedPreKeyId,
1848
+ stanzaId: record.stanzaId,
1849
+ keyDigest: record.keyDigest,
1850
+ sentDigest: digest,
1851
+ inboundDigest: "",
1852
+ cached: packet,
1853
+ offerRecordB64: packet.recordB64,
1854
+ bundleDigest: record.bundleDigest,
1855
+ generation: record.generation,
1856
+ retransmits: 0,
1857
+ ...peerIdentity ? { peerIdentity } : {}
1858
+ };
1859
+ recordOffer(this.ledger, attempt, nowS);
1860
+ this.persist();
1861
+ this.log("recovery.offer", { conversation: conversationId, peerDevice: peer.deviceId });
1862
+ this.emit(attempt, "recovering");
1863
+ this.track(this.transmit(attempt));
1864
+ } finally {
1865
+ this.inflight.delete(pair);
1866
+ }
1867
+ }
1868
+ // --------------------------------------------------------------- inbound
1869
+ async onIq(ctx) {
1870
+ if (this.detached) return errorEl("service-unavailable");
1871
+ if (ctx.stanza.attrs.from !== this.o.policyJid) {
1872
+ this.log("recovery.refused", { reason: "source" });
1873
+ return errorEl("not-authorized");
1874
+ }
1875
+ const element = ctx.stanza.getChild("recovery", NS_RECOVERY);
1876
+ if (!element) return errorEl("not-acceptable");
1877
+ const parsed = parseRecoveryEl(element, true);
1878
+ if (!parsed.ok) {
1879
+ this.log("recovery.refused", { reason: parsed.reason });
1880
+ return errorEl("not-acceptable");
1881
+ }
1882
+ const after = [];
1883
+ try {
1884
+ await this.serial(() => this.handle(parsed.control, after));
1885
+ } catch (err) {
1886
+ const refusal = err instanceof Refused ? err : new Refused("not-acceptable", "error");
1887
+ this.log("recovery.refused", {
1888
+ phase: parsed.control.record.phase,
1889
+ reason: refusal.reason
1890
+ });
1891
+ return errorEl(refusal.condition);
1892
+ }
1893
+ for (const fn of after) queueMicrotask(fn);
1894
+ return xml3("recovered", { xmlns: NS_RECOVERY });
1895
+ }
1896
+ async handle(c, after) {
1897
+ if (this.corrupt) throw new Refused("service-unavailable", "state");
1898
+ const r = c.record;
1899
+ const origin = c.originJid?.split("/")[0];
1900
+ if (origin !== signerJid(r)) throw new Refused("not-authorized", "origin");
1901
+ const self = this.o.provider.bareJid;
1902
+ const mine = signerRole(r.phase) === "B" ? r.aJid === self && r.aDevice === this.state.deviceId : r.bJid === self && r.bDevice === this.state.deviceId;
1903
+ if (!mine) throw new Refused("not-acceptable", "target");
1904
+ if (!this.allowed(r.conversationId)) throw new Refused("forbidden", "lifecycle");
1905
+ const nowS = this.nowS();
1906
+ if (!observeClock(this.ledger, nowS)) throw new Refused("resource-constraint", "clock");
1907
+ this.tick();
1908
+ if (!withinValidity(r.createdAt, r.expiresAt, nowS))
1909
+ throw new Refused("not-acceptable", "expired");
1910
+ if (c.ctl && sha256Hex(c.ctl) !== r.ctlDigest)
1911
+ throw new Refused("not-acceptable", "ctl-digest");
1912
+ const digest = sha256Hex(c.recordBytes);
1913
+ switch (r.phase) {
1914
+ case "OFFER":
1915
+ return this.onOffer(c, digest, after);
1916
+ case "INIT":
1917
+ return this.onInit(c, digest, after);
1918
+ case "CONFIRM":
1919
+ return this.onConfirm(c, digest, after);
1920
+ case "COMMIT":
1921
+ return this.onCommit(c, digest, after);
1922
+ case "COMMITTED":
1923
+ return this.onCommitted(c, digest);
1924
+ }
1925
+ }
1926
+ /** Signature by the exact pinned (or B's TOFU-expected) identity of the signer. */
1927
+ verify(c, identity, fingerprint) {
1928
+ if (!identity || fingerprintOf(identity) !== fingerprint)
1929
+ throw new Refused("not-authorized", "signer");
1930
+ let ok = false;
1931
+ try {
1932
+ ok = identity.verify(c.recordBytes, c.signature);
1933
+ } catch {
1934
+ ok = false;
1935
+ }
1936
+ if (!ok) throw new Refused("not-authorized", "signature");
1937
+ const ownFp = this.ownFingerprint();
1938
+ const r = c.record;
1939
+ if ((signerRole(r.phase) === "B" ? r.aFingerprint : r.bFingerprint) !== ownFp)
1940
+ throw new Refused("not-acceptable", "own-identity");
1941
+ }
1942
+ offerOf(a) {
1943
+ const parsed = a.offerRecordB64 ? parseRecord(b64decode(a.offerRecordB64)) : void 0;
1944
+ if (!parsed?.ok) throw new Refused("not-acceptable", "state");
1945
+ return parsed.record;
1946
+ }
1947
+ /** Inbound phase against our attempt: duplicates only re-send the cached answer. */
1948
+ expect(c, digest, role) {
1949
+ const a = findAttempt(this.ledger, c.record.nonce);
1950
+ if (!a || a.role !== role) throw new Refused("not-acceptable", "unknown-attempt");
1951
+ const cls = classifyInbound(a, c.record.phase, digest, c.record.prevDigest);
1952
+ if (cls === "duplicate") return null;
1953
+ if (cls === "reject") {
1954
+ if (a.state === "recovered" && a.inboundDigest === digest) return null;
1955
+ throw new Refused("not-acceptable", "unexpected-phase");
1956
+ }
1957
+ if (!sameTuple(this.offerOf(a), c.record)) throw new Refused("not-acceptable", "tuple");
1958
+ if (c.record.phase !== "OFFER" && c.record.selectedPreKeyId !== (a.selectedPreKeyId ?? c.record.selectedPreKeyId))
1959
+ throw new Refused("not-acceptable", "selected");
1960
+ return a;
1961
+ }
1962
+ duplicate(c, after) {
1963
+ const a = findAttempt(this.ledger, c.record.nonce);
1964
+ if (a?.cached && a.retransmits < RECOVERY_LIMITS.retransmitDelaysMs.length) {
1965
+ a.retransmits += 1;
1966
+ this.persist();
1967
+ after.push(() => this.track(this.transmit(a, false)));
1968
+ }
1969
+ }
1970
+ // A side ---------------------------------------------------------------
1971
+ async onOffer(c, digest, after) {
1972
+ const r = c.record;
1973
+ const peer = { bareJid: r.bJid, deviceId: r.bDevice };
1974
+ const key2 = `${r.bJid}.${r.bDevice}`;
1975
+ const pair = pairKey(r.bJid, r.bDevice);
1976
+ this.verify(c, this.state.identities.get(key2), r.bFingerprint);
1977
+ const existing = findAttempt(this.ledger, r.nonce);
1978
+ if (existing) {
1979
+ if (existing.role === "A" && existing.inboundDigest === digest)
1980
+ return this.duplicate(c, after);
1981
+ throw new Refused("not-acceptable", "nonce-reuse");
1982
+ }
1983
+ if (this.inflight.has(pair)) throw new Refused("resource-constraint", "busy");
1984
+ if (!findPendingSend(this.ledger, pair, r.roomJid, r.stanzaId, r.keyDigest, r.consumedPreKeyId))
1985
+ throw new Refused("not-acceptable", "stale");
1986
+ const own = activeAttempt(this.ledger, pair);
1987
+ if (own) {
1988
+ const weInitiate = { jid: this.o.provider.bareJid, device: this.state.deviceId };
1989
+ const theyInitiate = { jid: r.bJid, device: r.bDevice };
1990
+ if (own.role === "B" && own.state === "offered" && compareInitiators(weInitiate, theyInitiate) < 0) {
1991
+ own.state = "failed";
1992
+ own.reason = "stale";
1993
+ delete own.cached;
1994
+ this.persist();
1995
+ } else throw new Refused("not-acceptable", "fenced");
1996
+ }
1997
+ const gate = canSpend(this.ledger, pair, r.conversationId, this.nowS());
1998
+ if (!gate.ok)
1999
+ throw new Refused(
2000
+ gate.reason === "limit" ? "resource-constraint" : "not-acceptable",
2001
+ gate.reason
2002
+ );
2003
+ this.inflight.add(pair);
2004
+ after.push(() => {
2005
+ this.track(this.acceptOffer(c, digest, peer).finally(() => this.inflight.delete(pair)));
2006
+ });
2007
+ }
2008
+ async acceptOffer(c, digest, peer) {
2009
+ const r = c.record;
2010
+ const key2 = `${peer.bareJid}.${peer.deviceId}`;
2011
+ const pair = pairKey(peer.bareJid, peer.deviceId);
2012
+ let bundle;
2013
+ try {
2014
+ bundle = (await pepFetchItems(this.o.entity, r.bJid, bundleNode(r.bDevice), this.timeoutMs))[0];
2015
+ } catch {
2016
+ bundle = void 0;
2017
+ }
2018
+ const pin = this.state.identities.get(key2);
2019
+ if (!bundle || !pin || bundle.getChild("kyberPreKeys", NS_WACANDO_OMEMO) === void 0 || bundleDigest(bundle) !== r.bundleDigest || !bytesEqual2(bundleIdentity(bundle), pin.serialize())) {
2020
+ this.log("recovery.offer.rejected", { reason: "bundle", peerDevice: peer.deviceId });
2021
+ return;
2022
+ }
2023
+ const ids = bundlePreKeyIds(bundle).filter((id) => id !== r.consumedPreKeyId);
2024
+ if (ids.length === 0) return;
2025
+ const selected = ids[Number.parseInt(r.nonce.slice(0, 8), 16) % ids.length];
2026
+ await this.serial(async () => {
2027
+ if (this.detached || !this.allowed(r.conversationId) || findAttempt(this.ledger, r.nonce))
2028
+ return;
2029
+ if (!findPendingSend(this.ledger, pair, r.roomJid, r.stanzaId, r.keyDigest, r.consumedPreKeyId))
2030
+ return;
2031
+ this.tick();
2032
+ const gate = canSpend(this.ledger, pair, r.conversationId, this.nowS());
2033
+ if (!gate.ok) return;
2034
+ spend(this.ledger, pair, this.nowS());
2035
+ const a = {
2036
+ nonce: r.nonce,
2037
+ role: "A",
2038
+ conversationId: r.conversationId,
2039
+ roomJid: r.roomJid,
2040
+ peerJid: r.bJid,
2041
+ peerDevice: r.bDevice,
2042
+ state: "handshaking",
2043
+ createdAt: r.createdAt,
2044
+ expiresAt: r.expiresAt,
2045
+ consumedPreKeyId: r.consumedPreKeyId,
2046
+ stanzaId: r.stanzaId,
2047
+ keyDigest: r.keyDigest,
2048
+ sentDigest: "",
2049
+ inboundDigest: digest,
2050
+ offerRecordB64: c.packet.recordB64,
2051
+ selectedPreKeyId: selected,
2052
+ retransmits: 0
2053
+ };
2054
+ this.ledger.attempts.push(a);
2055
+ this.persist();
2056
+ this.emit(a, "recovering");
2057
+ try {
2058
+ await this.o.provider.exclusive(peer, async () => {
2059
+ const staged = new StagedStores(this.state, key2, this.state.sessions.get(key2) ?? null);
2060
+ const addr = ProtocolAddress2.new(peer.bareJid, peer.deviceId);
2061
+ const parsed = parseBundle(bundle, peer.deviceId, "", selected);
2062
+ await processPreKeyBundle2(
2063
+ parsed.bundle,
2064
+ addr,
2065
+ this.localAddress(),
2066
+ staged.session,
2067
+ staged.identity
2068
+ );
2069
+ const probe = await signalEncrypt2(
2070
+ controlPlaintext("INIT", r.nonce, digest),
2071
+ addr,
2072
+ this.localAddress(),
2073
+ staged.session,
2074
+ staged.identity
2075
+ );
2076
+ if (probe.type() !== CiphertextMessageType2.PreKey)
2077
+ throw new Error("probe is not a PreKey message");
2078
+ const ctl = probe.serialize();
2079
+ a.candidate = staged.serialized();
2080
+ a.candidateBaseGeneration = this.state.sessionGeneration(key2);
2081
+ const signed = this.sign(
2082
+ {
2083
+ ...r,
2084
+ phase: "INIT",
2085
+ prevDigest: digest,
2086
+ ctlDigest: sha256Hex(ctl),
2087
+ selectedPreKeyId: selected
2088
+ },
2089
+ ctl
2090
+ );
2091
+ a.sentDigest = signed.digest;
2092
+ a.cached = signed.packet;
2093
+ });
2094
+ } catch {
2095
+ this.fail(a, "authentication_failed");
2096
+ return;
2097
+ }
2098
+ if (isCancelled(this.ledger, a.conversationId)) return;
2099
+ this.persist();
2100
+ this.log("recovery.init", { conversation: a.conversationId, peerDevice: a.peerDevice });
2101
+ this.track(this.transmit(a));
2102
+ });
2103
+ }
2104
+ async onConfirm(c, digest, after) {
2105
+ const r = c.record;
2106
+ const a = this.expect(c, digest, "A");
2107
+ if (!a) return this.duplicate(c, after);
2108
+ const key2 = `${r.bJid}.${r.bDevice}`;
2109
+ this.verify(c, this.state.identities.get(key2), r.bFingerprint);
2110
+ const peer = { bareJid: r.bJid, deviceId: r.bDevice };
2111
+ await this.o.provider.exclusive(peer, async () => {
2112
+ const staged = new StagedStores(this.state, key2, a.candidate ?? null);
2113
+ const addr = ProtocolAddress2.new(peer.bareJid, peer.deviceId);
2114
+ let plain;
2115
+ try {
2116
+ plain = await signalDecrypt2(
2117
+ SignalMessage2.deserialize(c.ctl),
2118
+ addr,
2119
+ this.localAddress(),
2120
+ staged.session,
2121
+ staged.identity
2122
+ );
2123
+ } catch {
2124
+ throw new Refused("not-acceptable", "ctl-auth");
2125
+ }
2126
+ if (!bytesEqual2(plain, controlPlaintext("CONFIRM", r.nonce, r.prevDigest)))
2127
+ throw new Refused("not-acceptable", "ctl-context");
2128
+ const commit = await signalEncrypt2(
2129
+ controlPlaintext("COMMIT", r.nonce, digest),
2130
+ addr,
2131
+ this.localAddress(),
2132
+ staged.session,
2133
+ staged.identity
2134
+ );
2135
+ const ctl = commit.serialize();
2136
+ if (!this.allowed(a.conversationId)) throw new Refused("forbidden", "lifecycle");
2137
+ const signed = this.sign(
2138
+ { ...r, phase: "COMMIT", prevDigest: digest, ctlDigest: sha256Hex(ctl) },
2139
+ ctl
2140
+ );
2141
+ a.candidate = staged.serialized();
2142
+ a.state = "awaiting_commit_resolution";
2143
+ a.inboundDigest = digest;
2144
+ a.sentDigest = signed.digest;
2145
+ a.cached = signed.packet;
2146
+ a.retransmits = 0;
2147
+ this.persist();
2148
+ });
2149
+ after.push(() => this.track(this.transmit(a)));
2150
+ }
2151
+ async onCommitted(c, digest) {
2152
+ const r = c.record;
2153
+ const a = this.expect(c, digest, "A");
2154
+ if (!a) return;
2155
+ const key2 = `${r.bJid}.${r.bDevice}`;
2156
+ this.verify(c, this.state.identities.get(key2), r.bFingerprint);
2157
+ const peer = { bareJid: r.bJid, deviceId: r.bDevice };
2158
+ await this.o.provider.exclusive(peer, async () => {
2159
+ if (a.state !== "awaiting_commit_resolution") return;
2160
+ const staged = new StagedStores(this.state, key2, a.candidate ?? null);
2161
+ let plain;
2162
+ try {
2163
+ plain = await signalDecrypt2(
2164
+ SignalMessage2.deserialize(c.ctl),
2165
+ ProtocolAddress2.new(peer.bareJid, peer.deviceId),
2166
+ this.localAddress(),
2167
+ staged.session,
2168
+ staged.identity
2169
+ );
2170
+ } catch {
2171
+ throw new Refused("not-acceptable", "ctl-auth");
2172
+ }
2173
+ if (!bytesEqual2(plain, controlPlaintext("COMMITTED", r.nonce, r.prevDigest)))
2174
+ throw new Refused("not-acceptable", "ctl-context");
2175
+ if (this.state.sessionGeneration(key2) !== a.candidateBaseGeneration || !staged.record) {
2176
+ a.reason = "state";
2177
+ this.persist();
2178
+ this.emit(a, "needs_attention", "state");
2179
+ throw new Refused("not-acceptable", "generation");
2180
+ }
2181
+ if (!this.allowed(a.conversationId)) throw new Refused("forbidden", "lifecycle");
2182
+ a.inboundDigest = digest;
2183
+ this.promote(key2, staged.record);
2184
+ this.finishInitiator(a, pairKey(r.bJid, r.bDevice));
2185
+ });
2186
+ }
2187
+ // B side ---------------------------------------------------------------
2188
+ async onInit(c, digest, after) {
2189
+ const r = c.record;
2190
+ const a = this.expect(c, digest, "B");
2191
+ if (!a) return this.duplicate(c, after);
2192
+ const key2 = `${r.aJid}.${r.aDevice}`;
2193
+ const pin = this.state.identities.get(key2);
2194
+ const expected = pin ?? (a.peerIdentity ? PublicKey4.deserialize(b64decode(a.peerIdentity)) : void 0);
2195
+ this.verify(c, expected, r.aFingerprint);
2196
+ const selected = r.selectedPreKeyId;
2197
+ const pair = pairKey(r.aJid, r.aDevice);
2198
+ const gate = canSpend(this.ledger, pair, r.conversationId, this.nowS(), a.nonce);
2199
+ if (!gate.ok) throw new Refused("resource-constraint", gate.reason);
2200
+ spend(this.ledger, pair, this.nowS());
2201
+ a.inboundDigest = digest;
2202
+ a.selectedPreKeyId = selected;
2203
+ a.state = "confirming";
2204
+ delete a.cached;
2205
+ this.persist();
2206
+ const reserved = this.ledger.attempts.some(
2207
+ (x) => x !== a && x.role === "B" && !TERMINAL.has(x.state) && x.selectedPreKeyId === selected
2208
+ );
2209
+ if (!this.state.preKeys.has(selected) || reserved) {
2210
+ this.fail(a, "stale");
2211
+ throw new Refused("not-acceptable", "selected-gone");
2212
+ }
2213
+ const peer = { bareJid: r.aJid, deviceId: r.aDevice };
2214
+ try {
2215
+ await this.o.provider.exclusive(peer, async () => {
2216
+ const ctl = c.ctl;
2217
+ const msg = PreKeySignalMessage2.deserialize(ctl);
2218
+ const header = preKeyHeaderIdentity(ctl);
2219
+ if (msg.preKeyId() !== selected) throw new Refused("not-acceptable", "probe-key");
2220
+ if (!header || !expected || !bytesEqual2(header, expected.serialize()))
2221
+ throw new Refused("not-acceptable", "probe-header");
2222
+ const staged = new StagedStores(
2223
+ this.state,
2224
+ key2,
2225
+ this.state.sessions.get(key2) ?? null,
2226
+ pin ? void 0 : expected
2227
+ );
2228
+ const addr = ProtocolAddress2.new(peer.bareJid, peer.deviceId);
2229
+ const plain = await signalDecryptPreKey2(
2230
+ msg,
2231
+ addr,
2232
+ this.localAddress(),
2233
+ staged.session,
2234
+ staged.identity,
2235
+ staged.preKey,
2236
+ staged.signedPreKey,
2237
+ staged.kyberPreKey
2238
+ );
2239
+ if (!bytesEqual2(plain, controlPlaintext("INIT", r.nonce, r.prevDigest)))
2240
+ throw new Refused("not-acceptable", "probe-context");
2241
+ if (staged.removedPreKeys.size !== 1 || !staged.removedPreKeys.has(selected))
2242
+ throw new Refused("not-acceptable", "probe-consumed");
2243
+ const confirm = await signalEncrypt2(
2244
+ controlPlaintext("CONFIRM", r.nonce, digest),
2245
+ addr,
2246
+ this.localAddress(),
2247
+ staged.session,
2248
+ staged.identity
2249
+ );
2250
+ const out = confirm.serialize();
2251
+ if (isCancelled(this.ledger, a.conversationId)) throw new Error("cancelled");
2252
+ const signed = this.sign(
2253
+ { ...r, phase: "CONFIRM", prevDigest: digest, ctlDigest: sha256Hex(out) },
2254
+ out
2255
+ );
2256
+ this.state.consumePreKey(selected);
2257
+ if (staged.stagedPin) this.state.identities.set(key2, staged.stagedPin);
2258
+ a.candidate = staged.serialized();
2259
+ a.candidateBaseGeneration = this.state.sessionGeneration(key2);
2260
+ a.sentDigest = signed.digest;
2261
+ a.cached = signed.packet;
2262
+ a.retransmits = 0;
2263
+ this.persist();
2264
+ });
2265
+ } catch (err) {
2266
+ this.fail(a, "authentication_failed");
2267
+ throw err instanceof Refused ? err : new Refused("not-acceptable", "probe");
2268
+ }
2269
+ this.log("recovery.confirm", { conversation: a.conversationId, peerDevice: a.peerDevice });
2270
+ after.push(() => this.track(this.transmit(a)));
2271
+ }
2272
+ async onCommit(c, digest, after) {
2273
+ const r = c.record;
2274
+ const a = this.expect(c, digest, "B");
2275
+ if (!a) return this.duplicate(c, after);
2276
+ const key2 = `${r.aJid}.${r.aDevice}`;
2277
+ this.verify(c, this.state.identities.get(key2), r.aFingerprint);
2278
+ const peer = { bareJid: r.aJid, deviceId: r.aDevice };
2279
+ await this.o.provider.exclusive(peer, async () => {
2280
+ const staged = new StagedStores(this.state, key2, a.candidate ?? null);
2281
+ const addr = ProtocolAddress2.new(peer.bareJid, peer.deviceId);
2282
+ let plain;
2283
+ try {
2284
+ plain = await signalDecrypt2(
2285
+ SignalMessage2.deserialize(c.ctl),
2286
+ addr,
2287
+ this.localAddress(),
2288
+ staged.session,
2289
+ staged.identity
2290
+ );
2291
+ } catch {
2292
+ throw new Refused("not-acceptable", "ctl-auth");
2293
+ }
2294
+ if (!bytesEqual2(plain, controlPlaintext("COMMIT", r.nonce, r.prevDigest)))
2295
+ throw new Refused("not-acceptable", "ctl-context");
2296
+ if (this.state.sessionGeneration(key2) !== a.candidateBaseGeneration || !staged.record) {
2297
+ this.fail(a, "state");
2298
+ throw new Refused("not-acceptable", "generation");
2299
+ }
2300
+ if (isCancelled(this.ledger, a.conversationId) || !this.allowed(a.conversationId))
2301
+ throw new Refused("forbidden", "lifecycle");
2302
+ this.promote(key2, staged.record);
2303
+ const stores = this.o.backend.signalStores;
2304
+ const committed = await signalEncrypt2(
2305
+ controlPlaintext("COMMITTED", r.nonce, digest),
2306
+ addr,
2307
+ this.localAddress(),
2308
+ stores.session,
2309
+ stores.identity
2310
+ );
2311
+ const out = committed.serialize();
2312
+ const signed = this.sign(
2313
+ { ...r, phase: "COMMITTED", prevDigest: digest, ctlDigest: sha256Hex(out) },
2314
+ out
2315
+ );
2316
+ delete a.candidate;
2317
+ markResolved(this.ledger, a.keyDigest);
2318
+ a.state = "recovered_unacknowledged";
2319
+ a.inboundDigest = digest;
2320
+ a.sentDigest = signed.digest;
2321
+ a.cached = signed.packet;
2322
+ a.retransmits = 0;
2323
+ this.persist();
2324
+ this.emit(a, "recovered");
2325
+ });
2326
+ after.push(() => this.track(this.transmit(a)));
2327
+ }
2328
+ // --------------------------------------------------------------- helpers
2329
+ get state() {
2330
+ return this.o.backend.state;
2331
+ }
2332
+ localAddress() {
2333
+ return ProtocolAddress2.new(this.o.provider.bareJid, this.state.deviceId);
2334
+ }
2335
+ ownFingerprint() {
2336
+ return fingerprintOf(this.state.identityKeyPair.publicKey);
2337
+ }
2338
+ nowS() {
2339
+ return Math.floor(this.now() / 1e3);
2340
+ }
2341
+ jitterMs() {
2342
+ return Math.floor(Math.min(0.999, Math.max(0, this.jitter())) * RECOVERY_LIMITS.jitterMaxMs);
2343
+ }
2344
+ allowed(conversationId) {
2345
+ return !isCancelled(this.ledger, conversationId) && this.o.lifecycle(conversationId) === "active";
2346
+ }
2347
+ sign(record, ctl) {
2348
+ const bytes = encodeRecord(record);
2349
+ const sig = this.state.identityKeyPair.privateKey.sign(bytes);
2350
+ return {
2351
+ packet: {
2352
+ recordB64: b64encode(bytes),
2353
+ sigB64: b64encode(sig),
2354
+ ...ctl ? { ctlB64: b64encode(ctl) } : {}
2355
+ },
2356
+ digest: sha256Hex(bytes)
2357
+ };
2358
+ }
2359
+ /** Promote a candidate into the live store (generation moves; old states stay archived). */
2360
+ promote(key2, record) {
2361
+ this.state.sessions.set(
2362
+ key2,
2363
+ SessionRecord3.deserialize(record.serialize())
2364
+ );
2365
+ this.state.bumpSessionGeneration(key2);
2366
+ }
2367
+ finishInitiator(a, pair) {
2368
+ const uncertain = [
2369
+ .../* @__PURE__ */ new Set([
2370
+ a.stanzaId,
2371
+ ...this.ledger.pendingSends.filter((p) => p.pair === pair).map((p) => p.stanzaId)
2372
+ ])
2373
+ ];
2374
+ a.state = "recovered";
2375
+ delete a.candidate;
2376
+ delete a.cached;
2377
+ clearPendingSends(this.ledger, pair);
2378
+ this.persist();
2379
+ this.log("recovery.recovered", { conversation: a.conversationId, peerDevice: a.peerDevice });
2380
+ this.emit(a, "recovered", void 0, uncertain);
2381
+ }
2382
+ fail(a, reason) {
2383
+ if (a.role === "B" && reason === "stale") markResolved(this.ledger, a.keyDigest);
2384
+ a.state = "failed";
2385
+ a.reason = reason;
2386
+ delete a.cached;
2387
+ delete a.candidate;
2388
+ scheduleBackoff(this.ledger, pairKey(a.peerJid, a.peerDevice), this.nowS(), this.jitterMs());
2389
+ this.persist();
2390
+ this.emit(a, statusOf(a), reason);
2391
+ }
2392
+ persist() {
2393
+ if (this.detached) return;
2394
+ this.state.recoveryLedger = serializeLedger(this.ledger);
2395
+ this.state.persist();
2396
+ }
2397
+ serial(fn) {
2398
+ const next = this.chain.then(fn, fn);
2399
+ this.chain = next.catch(() => {
2400
+ });
2401
+ return next;
2402
+ }
2403
+ /** Send our cached packet through the component; bounded retransmission on transport errors only. */
2404
+ async transmit(a, schedule = true) {
2405
+ const packet = a.cached;
2406
+ if (!packet || this.detached || isCancelled(this.ledger, a.conversationId)) return;
2407
+ try {
2408
+ await this.o.entity.iqCaller.set(buildRecoveryEl(packet), this.o.policyJid, this.timeoutMs);
2409
+ } catch (err) {
2410
+ const condition = err?.condition;
2411
+ const transient = condition === void 0 || TRANSIENT.has(condition);
2412
+ if (a.cached !== packet || this.detached) return;
2413
+ if (!transient) {
2414
+ if (a.state === "offered" || a.state === "handshaking" || a.state === "confirming") {
2415
+ this.fail(a, REFUSAL_REASON[condition] ?? "authentication_failed");
2416
+ }
2417
+ return;
2418
+ }
2419
+ if (!schedule || a.retransmits >= RECOVERY_LIMITS.retransmitDelaysMs.length) return;
2420
+ if (this.nowS() > a.expiresAt) return;
2421
+ const delay = RECOVERY_LIMITS.retransmitDelaysMs[a.retransmits];
2422
+ a.retransmits += 1;
2423
+ this.persist();
2424
+ const cancel = this.setTimer(() => {
2425
+ this.timers.delete(cancel);
2426
+ this.track(this.transmit(a));
2427
+ }, delay);
2428
+ this.timers.add(cancel);
2429
+ }
2430
+ }
2431
+ emit(a, status, reason, deliveryUncertain) {
2432
+ this.o.onStatus?.({
2433
+ conversationId: a.conversationId,
2434
+ peerJid: a.peerJid,
2435
+ peerDeviceId: a.peerDevice,
2436
+ role: a.role === "A" ? "initiator" : "receiver",
2437
+ status,
2438
+ ...reason ? { reason } : {},
2439
+ ...deliveryUncertain && deliveryUncertain.length > 0 ? { deliveryUncertain } : {}
2440
+ });
2441
+ }
2442
+ emitRaw(conversationId, peer, role, status, reason) {
2443
+ this.o.onStatus?.({
2444
+ conversationId,
2445
+ peerJid: peer.bareJid,
2446
+ peerDeviceId: peer.deviceId,
2447
+ role,
2448
+ status,
2449
+ ...reason ? { reason } : {}
2450
+ });
2451
+ }
2452
+ log(event, fields) {
2453
+ this.o.log?.(event, fields);
2454
+ }
2455
+ };
2456
+
2457
+ // packages/omemo/src/libsignal.ts
2458
+ function loadNativeOmemoProvider(jid, options = {}) {
2459
+ const bare = parseJid2(jid).bare().toString();
2460
+ return new OmemoProvider(bare, new LibsignalNodeBackend(DeviceState.load(bare, options)));
2461
+ }
2462
+ export {
2463
+ BODY_MARKER,
2464
+ DeviceState,
2465
+ LibsignalNodeBackend,
2466
+ NS_DEVICELIST,
2467
+ NS_EME,
2468
+ NS_HINTS,
2469
+ NS_OMEMO,
2470
+ NS_RECOVERY,
2471
+ NS_SCE,
2472
+ NS_WACANDO_OMEMO,
2473
+ OmemoError,
2474
+ OmemoProvider,
2475
+ OmemoReceiveError,
2476
+ OmemoTranscriptDecryptor,
2477
+ PEP_ITEM_ID,
2478
+ RECOVERY_LIMITS,
2479
+ SessionRecovery,
2480
+ UnsupportedSignalFormatError,
2481
+ bundleNode,
2482
+ defaultStateBaseDir,
2483
+ deviceListEl,
2484
+ jidStateDirName,
2485
+ loadNativeOmemoProvider,
2486
+ parseDeviceList,
2487
+ parseEncrypted
2488
+ };