@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.
- package/README.md +6 -2
- package/STATUS.md +3 -2
- package/THIRD-PARTY-NOTICES.txt +0 -1113
- package/docs/AGENT_TOOLS.md +5 -4
- package/docs/MIGRATION-2.0.md +3 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +1 -1
- package/docs/RELEASE.md +10 -6
- package/docs/WACANDO_PACKAGES.md +70 -22
- package/package.json +19 -7
- package/packages/onboarding/version.mjs +1 -1
- package/packages/runtime/conversations.mjs +3 -3
- package/packages/runtime/device.mjs +1 -1
- package/packages/runtime/recovery.mjs +5 -4
- package/packages/runtime/session.mjs +1 -1
- package/packages/wacando/chunks/chunk-EIGFLZ2Y.mjs +23 -0
- package/packages/wacando/chunks/chunk-GASYW3SG.mjs +422 -0
- package/packages/wacando/chunks/chunk-OI3RF7TS.mjs +421 -0
- package/packages/wacando/omemo-wire.mjs +192 -0
- package/packages/wacando/omemo.mjs +2488 -0
- package/packages/wacando/xmpp.mjs +48 -0
- package/plugins/wacando/.codex-plugin/plugin.json +1 -1
- package/plugins/wacando-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +1 -1
- package/provenance.json +126 -266
|
@@ -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
|
+
};
|