@wacando/agent 2.3.0 → 2.4.1

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.
@@ -29,8 +29,10 @@ import { protectedDirectory, SafeError, storageError } from '../onboarding/stora
29
29
  // directory (0700/0600). The runtime never opens, extracts or executes it.
30
30
  // Nothing here logs; errors carry no name, digest, URL token or key.
31
31
  //
32
- // Local retention (lbframe/wacando-agent#21): sweep() runs at every connection
33
- // and before every listing; it deletes the local ciphertext, keys and
32
+ // Local retention (lbframe/wacando-agent#21, #24): sweep() runs before the
33
+ // first file operation of each connection (at every connection for the
34
+ // daemon, never from a read-only tool), before every listing and before a
35
+ // full queue or quarantine refuses; it deletes the local ciphertext, keys and
34
36
  // quarantined descriptors of transfers that can never continue (STOP or any
35
37
  // other closed conversation, rejected, expired or unknown on the server, or
36
38
  // past their 30-day expiry by the local clock). A RECEIVED file, written to
@@ -40,11 +42,15 @@ import { protectedDirectory, SafeError, storageError } from '../onboarding/stora
40
42
  export const FILE_POLL_MS = 2000;
41
43
  export const MAX_PENDING_OUTGOING = 10;
42
44
  export const MAX_QUARANTINED_INCOMING = 100;
45
+ // One conversation (one peer) can fill only part of the quarantine (lbframe/wacando-agent#24).
46
+ export const MAX_QUARANTINED_PER_CONVERSATION = 20;
43
47
  export const FILE_RETENTION_MS = 30 * 24 * 3600 * 1000;
44
- // Leftovers of an interrupted write (staged ciphertext, .part) older than this are removed by the sweep.
48
+ // Leftovers of an interrupted write (staged ciphertext, .part, a ciphertext
49
+ // without its record) older than this are removed by the sweep.
45
50
  const STALE_LEFTOVER_MS = 3600 * 1000;
46
51
  const STAGED = /^staged-[0-9a-f-]{36}\.bin$/;
47
52
  const PART = /^\.[0-9a-f-]{36}\.part$/;
53
+ const COMMITTED = /^(fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.bin$/;
48
54
  const FILE_ID = /^fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
49
55
  const UPLOAD_PATH = /^\/v1\/uploads\/(fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/[A-Za-z0-9_-]{43}$/;
50
56
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
@@ -66,6 +72,7 @@ export const fileErrors = {
66
72
  download: 'The encrypted file could not be downloaded from the configured Wacando upload origin. Retry later.',
67
73
  plainText: 'A message body may not be a file share; use wacando_send_file.',
68
74
  tooManyIncoming: `Too many received files are waiting in quarantine on this runtime (at most ${MAX_QUARANTINED_INCOMING}). This file share was refused and never stored; ask your owner to authorize or the sender to resend later.`,
75
+ tooManyInConversation: `Too many received files of this conversation are waiting in quarantine (at most ${MAX_QUARANTINED_PER_CONVERSATION} per conversation). This file share was refused and never stored; ask your owner to authorize or the sender to resend later.`,
69
76
  orphan: 'The file could not be kept on this device after the request: nothing will be deposited. Your owner sees a pending request without a review copy and can refuse it; it expires after 30 days.',
70
77
  noOwnerDevice: 'Your owner has no active Wacando app device to review this file: ask your owner to open the Wacando desktop app (signed in) to approve it, then call wacando_file_status. The file stays pending; nothing was deposited.',
71
78
  reviewRetry: 'The review copy for your owner could not be deposited yet; call wacando_file_status to retry. The file stays pending.',
@@ -161,23 +168,67 @@ export function ownOwnerId(view, agentId) {
161
168
  }
162
169
 
163
170
  /**
164
- * Pure: whether a message body is a Wacando file descriptor, whatever its
165
- * whitespace or key order: 'share' (file share, `wacandoFile`), 'review'
166
- * (owner review descriptor, `wacandoFileReview`, never meant for a room) or
167
- * null. Such a body is never shown nor sent as text (it carries a key).
171
+ * Pure: whether a message body is, or carries, a Wacando file descriptor:
172
+ * 'share' (file share, `wacandoFile`), 'review' (owner review descriptor,
173
+ * `wacandoFileReview`, never meant for a room) or null. Such a body is never
174
+ * shown nor sent as text: it carries a key (lbframe/wacando-agent#21, #24).
175
+ * Detected whatever the whitespace, key order or JSON escaping, behind
176
+ * invisible format characters (ZWSP, BOM, word joiner...) or compatibility
177
+ * forms (fullwidth braces):
178
+ * - a JSON object with such a key at its top level (strict: whatever its
179
+ * values), or a body that opens like one but is not valid JSON;
180
+ * - a descriptor anywhere else (nested in JSON, in an array, in prose, a code
181
+ * block or a JSON string), only when its key material is there too: an
182
+ * `aesgcm://...#<88 hex>` URL for a share, a `"secret"` of 88 hex for a
183
+ * review. Text that merely names the format (e.g. a nested
184
+ * `{"wacandoFile":false}` setting) carries no key and stays text.
185
+ * Linear in the body size (a hostile body cannot stall the event loop).
168
186
  */
169
187
  export function fileBodyKind(text) {
170
188
  if (typeof text !== 'string') return null;
171
- const trimmed = text.trim();
172
- if (!trimmed.startsWith('{')) return null;
173
- let value;
174
- try { value = JSON.parse(trimmed); } catch {
175
- // Not JSON: still a descriptor when it opens like one (never shown as text).
176
- return /^\{\s*"wacandoFileReview"\s*:/.test(trimmed) ? 'review' : /^\{\s*"wacandoFile"\s*:/.test(trimmed) ? 'share' : null;
177
- }
178
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
179
- if (Object.hasOwn(value, 'wacandoFileReview')) return 'review';
180
- return Object.hasOwn(value, 'wacandoFile') ? 'share' : null;
189
+ // Detection only: the body itself is never rewritten.
190
+ const plain = text.normalize('NFKC').replace(/\p{Cf}/gu, '').trim();
191
+ if (plain.startsWith('{') || plain.startsWith('[')) {
192
+ let value;
193
+ let parsed = true;
194
+ try { value = JSON.parse(plain); } catch { parsed = false; }
195
+ if (parsed) {
196
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
197
+ if (Object.hasOwn(value, 'wacandoFileReview')) return 'review';
198
+ if (Object.hasOwn(value, 'wacandoFile')) return 'share';
199
+ }
200
+ } else if (/^[[\s]*\{\s*"wacandoFileReview"\s*:/.test(plain)) return 'review';
201
+ else if (/^[[\s]*\{\s*"wacandoFile"\s*:/.test(plain)) return 'share';
202
+ }
203
+ let unescaped = plain;
204
+ for (let i = 0; i < 3; i++) {
205
+ const next = unescaped.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => String.fromCharCode(Number.parseInt(code, 16))).replace(/\\(["/\\])/g, '$1');
206
+ if (next === unescaped) break;
207
+ unescaped = next;
208
+ }
209
+ if (/"wacandoFileReview"\s*:/.test(unescaped) && /"secret"\s*:\s*"[0-9a-fA-F]{88}"/.test(unescaped)) return 'review';
210
+ if (/"wacandoFile"\s*:/.test(unescaped) && hasAesgcmKey(unescaped)) return 'share';
211
+ return null;
212
+ }
213
+
214
+ const URL_END = /[\s"#]/;
215
+ const KEY_HEX = /^[0-9a-fA-F]{88}/;
216
+ /**
217
+ * Pure: whether the text holds an `aesgcm://<no space, quote or #>#<88 hex>`
218
+ * URL, in one linear pass (the equivalent regular expression is quadratic on
219
+ * `aesgcm://` repeated without a `#`, #24 review LOW-2): every later
220
+ * `aesgcm://` inside the same URL body ends at the same character.
221
+ */
222
+ function hasAesgcmKey(text) {
223
+ let from = 0;
224
+ for (;;) {
225
+ const start = text.indexOf('aesgcm://', from);
226
+ if (start < 0) return false;
227
+ let end = start + 'aesgcm://'.length;
228
+ while (end < text.length && !URL_END.test(text[end])) end++;
229
+ if (text[end] === '#' && KEY_HEX.test(text.slice(end + 1, end + 89))) return true;
230
+ from = end;
231
+ }
181
232
  }
182
233
 
183
234
  const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
@@ -269,15 +320,24 @@ export class FileVault {
269
320
  try { await rename(join(dir, staged), join(dir, `${FileVault.id(fileId)}.bin`)); } catch { throw storageError(); }
270
321
  }
271
322
  async dropStaged(staged) { if (STAGED.test(staged ?? '')) await unlink(join(await this.dir('outgoing'), staged)).catch(() => {}); }
272
- /** Remove interrupted-write leftovers (staged ciphertext, .part files) older than `before` (ms). Returns the count. */
323
+ /**
324
+ * Remove interrupted-write leftovers older than `before` (ms): staged
325
+ * ciphertext, .part files, and a committed ciphertext whose record was never
326
+ * written (a crash between the rename and the record, lbframe/wacando-agent#24;
327
+ * without its record it has no key and can never be used). Returns the count.
328
+ */
273
329
  async dropLeftovers(before) {
274
330
  let count = 0;
275
331
  for (const kind of ['outgoing', 'incoming']) {
276
332
  const dir = await this.dir(kind);
277
333
  for (const name of await readdir(dir)) {
278
- if (!STAGED.test(name) && !PART.test(name)) continue;
334
+ const orphan = kind === 'outgoing' ? COMMITTED.exec(name)?.[1] : null;
335
+ if (!STAGED.test(name) && !PART.test(name) && !orphan) continue;
279
336
  const info = await stat(join(dir, name)).catch(() => null);
280
- if (info?.isFile() && info.mtimeMs < before && await unlink(join(dir, name)).then(() => true, () => false)) count++;
337
+ if (!info?.isFile() || info.mtimeMs >= before) continue;
338
+ // Its record may have been written since the directory was read.
339
+ if (orphan && await lstat(join(dir, `${orphan}.json`)).then(() => true, error => error.code !== 'ENOENT')) continue;
340
+ if (await unlink(join(dir, name)).then(() => true, () => false)) count++;
281
341
  }
282
342
  }
283
343
  return count;
@@ -326,6 +386,7 @@ export class FileTransfers {
326
386
  this.pollMs = pollMs;
327
387
  this.fetch = fetch;
328
388
  this.busy = new Map(); // fileId -> in-flight advance (one at a time per file)
389
+ this.claims = new WeakSet(); // busy entries that are a sweep removal
329
390
  }
330
391
 
331
392
  get pin() { return this.pinSetting ?? (this.pinSetting = uploadPin(this.c.agent.origin)); }
@@ -489,15 +550,24 @@ export class FileTransfers {
489
550
  async status(fileId, { timeoutMs = 0 } = {}) {
490
551
  FileVault.id(fileId);
491
552
  if (await this.vault.read('outgoing', fileId)) return this.once(fileId, () => this.advance(fileId, timeoutMs));
492
- const incoming = await this.vault.read('incoming', fileId);
493
- if (!incoming) throw new SafeError(fileErrors.unknown);
494
- return this.incomingState(incoming, { timeoutMs, fetch: false });
553
+ // Incoming: one step at a time per file, like receive (a record written by
554
+ // a receive in flight is never dropped by this step, #24 review LOW-1).
555
+ return this.once(fileId, async () => {
556
+ const incoming = await this.vault.read('incoming', fileId);
557
+ if (!incoming) throw new SafeError(fileErrors.unknown);
558
+ return this.incomingState(incoming, { timeoutMs, fetch: false });
559
+ });
495
560
  }
496
561
 
562
+ /**
563
+ * One transfer step at a time per file in this process: a caller joins the
564
+ * step in flight; a removal by the sweep (a claim) is waited for first.
565
+ */
497
566
  once(fileId, run) {
498
567
  const running = this.busy.get(fileId);
499
- if (running) return running;
500
- const task = run().finally(() => this.busy.delete(fileId));
568
+ if (running && !this.claims.has(running)) return running;
569
+ const task = (running ? running.then(() => run(), () => run()) : run())
570
+ .finally(() => { if (this.busy.get(fileId) === task) this.busy.delete(fileId); });
501
571
  this.busy.set(fileId, task);
502
572
  return task;
503
573
  }
@@ -617,19 +687,36 @@ export class FileTransfers {
617
687
  }
618
688
  return { ...publicIncoming(known), state: known.path ? 'received' : 'quarantined' };
619
689
  }
620
- if ((await this.vault.list('incoming')).filter(r => !r.path).length >= MAX_QUARANTINED_INCOMING) {
621
- return { state: 'refused', reason: 'too_many_quarantined', note: fileErrors.tooManyIncoming };
690
+ // Bounded quarantine, and one conversation (one peer) fills at most part of
691
+ // it. Ended transfers do not count: clean them up before refusing (#24).
692
+ let full = await this.quarantineFull(conversationId);
693
+ if (full) {
694
+ await this.sweep().catch(() => null);
695
+ full = await this.quarantineFull(conversationId);
622
696
  }
697
+ if (full) return { state: 'refused', reason: 'too_many_quarantined', note: full };
623
698
  const record = { fileId, conversationId, messageId, share, ciphertextSha256: share.ciphertextSha256, receivedAt: new Date(this.now()).toISOString() };
624
699
  await this.vault.write('incoming', fileId, record);
625
700
  return { ...publicIncoming(record), state: 'quarantined', note: `In quarantine until your owner authorizes it; then call wacando_receive_file. ${UNTRUSTED}` };
626
701
  }
627
702
 
703
+ /** The refusal note when the quarantine (or this conversation's share of it) is full, else null. */
704
+ async quarantineFull(conversationId) {
705
+ const waiting = (await this.vault.list('incoming')).filter(r => !r.path);
706
+ if (waiting.length >= MAX_QUARANTINED_INCOMING) return fileErrors.tooManyIncoming;
707
+ if (waiting.filter(r => r.conversationId === conversationId).length >= MAX_QUARANTINED_PER_CONVERSATION) return fileErrors.tooManyInConversation;
708
+ return null;
709
+ }
710
+
628
711
  /** Fetch a released incoming file into the quarantine directory (bounded wait for the release). */
629
712
  async receive(fileId, { timeoutMs = 0 } = {}) {
630
- const record = await this.vault.read('incoming', FileVault.id(fileId));
631
- if (!record) throw new SafeError(fileErrors.unknown);
632
- return this.once(fileId, () => this.incomingState(record, { timeoutMs, fetch: true }));
713
+ FileVault.id(fileId);
714
+ // Read inside the step: a removal by the sweep is never overtaken.
715
+ return this.once(fileId, async () => {
716
+ const record = await this.vault.read('incoming', fileId);
717
+ if (!record) throw new SafeError(fileErrors.unknown);
718
+ return this.incomingState(record, { timeoutMs, fetch: true });
719
+ });
633
720
  }
634
721
 
635
722
  async incomingState(record, { timeoutMs, fetch }) {
@@ -640,17 +727,17 @@ export class FileTransfers {
640
727
  const view = await this.c.conversation(record.conversationId);
641
728
  if (view.state !== 'active') {
642
729
  // STOP is terminal: this agent can never fetch it; drop the key.
643
- await this.vault.remove('incoming', record.fileId);
730
+ await this.dropIncoming(record);
644
731
  return { file: summary, state: 'refused', reason: 'conversation_closed', note: fileErrors.notActive(view.state) };
645
732
  }
646
733
  const dto = (await this.serverFiles(record.conversationId)).find(f => f.id === record.fileId);
647
734
  if (!dto || dto.recipientAgentId !== this.c.agent.agentId || dto.ciphertextSha256 !== record.share.ciphertextSha256 ||
648
735
  dto.sizeBytes !== record.share.ciphertextSize || dto.mediaType !== record.share.mediaType) {
649
- await this.vault.remove('incoming', record.fileId);
736
+ await this.dropIncoming(record);
650
737
  return { file: summary, state: 'refused', reason: 'not_approved_file', note: 'The server does not list this file as approved for this agent. It was refused and never fetched.' };
651
738
  }
652
739
  if (this.expired(dto)) {
653
- await this.vault.remove('incoming', record.fileId);
740
+ await this.dropIncoming(record);
654
741
  return { file: summary, state: 'expired', note: fileApiMessages.FILE_EXPIRED };
655
742
  }
656
743
  if (dto.status === 'released' && fetch) return this.fetchReleased(record, dto, summary);
@@ -663,6 +750,12 @@ export class FileTransfers {
663
750
  }
664
751
  }
665
752
 
753
+ /** Drop a quarantined descriptor (its key), never the record of a file received meanwhile. */
754
+ async dropIncoming(record) {
755
+ if ((await this.vault.read('incoming', record.fileId))?.path) return;
756
+ await this.vault.remove('incoming', record.fileId);
757
+ }
758
+
666
759
  async fetchReleased(record, dto, summary) {
667
760
  let file;
668
761
  try {
@@ -690,16 +783,22 @@ export class FileTransfers {
690
783
 
691
784
  /**
692
785
  * Delete the local secrets of every transfer that can never continue
693
- * (lbframe/wacando-agent#21), at connection and before each listing:
786
+ * (lbframe/wacando-agent#21, #24), before the first file operation of a
787
+ * connection (at every connection for the daemon), before each listing and
788
+ * before a full queue or quarantine refuses:
694
789
  * - pending/deposited sends (ciphertext + key) and quarantined descriptors
695
790
  * (key): conversation no longer active (STOP is terminal) or unknown to
696
791
  * the server, file unknown, different, rejected or expired on the server,
697
792
  * or past its 30-day expiry by the local clock (even offline);
698
793
  * - sent records (public metadata only, no key): past their expiry;
699
- * - interrupted-write leftovers older than an hour.
794
+ * - interrupted-write leftovers older than an hour, including a ciphertext
795
+ * whose record was never written.
700
796
  * A received file (written to the quarantine directory) and its record are
701
797
  * never touched. A server that cannot be reached removes nothing it cannot
702
- * decide locally. Transfers in progress in this process are skipped.
798
+ * decide locally. Transfers in progress in this process are skipped; a
799
+ * record that changed since it was listed (e.g. a file received meanwhile)
800
+ * is kept. Another process of the same identity may sweep at the same time:
801
+ * every step tolerates a file the other one already removed.
703
802
  * Returns content-free counts.
704
803
  */
705
804
  sweep() {
@@ -721,14 +820,31 @@ export class FileTransfers {
721
820
  if (this.busy.has(record.fileId)) continue;
722
821
  if (!(await this.ended(direction, record, now, server))) continue;
723
822
  if (this.busy.has(record.fileId)) continue;
724
- await this.vault.remove(direction, record.fileId);
725
- counts[direction]++;
823
+ if (await this.claim(direction, record)) counts[direction]++;
726
824
  }
727
825
  }
728
826
  await this.vault.dropLeftovers(now - STALE_LEFTOVER_MS).catch(() => 0);
729
827
  return counts;
730
828
  }
731
829
 
830
+ /**
831
+ * Remove an ended record unless it changed since it was listed. Set as the
832
+ * file's busy step in the same tick as the caller's last busy check, so no
833
+ * step of this process (receive, status) interleaves with the removal.
834
+ */
835
+ claim(direction, record) {
836
+ const removal = (async () => {
837
+ const fresh = await this.vault.read(direction, record.fileId);
838
+ if (!fresh || JSON.stringify(fresh) !== JSON.stringify(record)) return false;
839
+ await this.vault.remove(direction, record.fileId);
840
+ return true;
841
+ })();
842
+ const held = removal.finally(() => { if (this.busy.get(record.fileId) === held) this.busy.delete(record.fileId); });
843
+ this.claims.add(held);
844
+ this.busy.set(record.fileId, held);
845
+ return held;
846
+ }
847
+
732
848
  /** Conversation state and files as the server lists them for this agent; 'gone' when unknown; null when unreachable. */
733
849
  async serverState(conversationId) {
734
850
  let state;
@@ -30,9 +30,12 @@ export function publicIdentity(agent, state) {
30
30
  */
31
31
  export class AgentRuntime {
32
32
  /**
33
- * `files`: `{ uploadOrigin, quarantineDir }` (defaults: WACANDO_UPLOAD_ORIGIN
34
- * or the API origin; WACANDO_QUARANTINE_DIR or a directory inside the
35
- * protected runtime state) plus test hooks (`now`, `sleep`, `pollMs`, `fetch`).
33
+ * `files`: `{ uploadOrigin, quarantineDir, sweepOnConnect }` (defaults:
34
+ * WACANDO_UPLOAD_ORIGIN or the API origin; WACANDO_QUARANTINE_DIR or a
35
+ * directory inside the protected runtime state; `sweepOnConnect: true`
36
+ * cleans up ended transfers at every connection, as the daemon does,
37
+ * otherwise before the first file operation of a connection) plus test
38
+ * hooks (`now`, `sleep`, `pollMs`, `fetch`).
36
39
  */
37
40
  constructor({ host = 'cli', origin, store = new CredentialStore(), report, request, connect, onStanza, files = {} } = {}) {
38
41
  if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(host)) throw new SafeError('Invalid runtime host name.');
@@ -147,7 +150,7 @@ export class AgentRuntime {
147
150
  if (client && client.session !== session) this.rebind(agent.agentId, session);
148
151
  if (!client) {
149
152
  const files = await this.fileTransfersFor(agent);
150
- client = new ConversationClient({ runtime: this, agent, session, files });
153
+ client = new ConversationClient({ runtime: this, agent, session, files, sweepOnConnect: this.fileOptions.sweepOnConnect === true });
151
154
  this.clients.set(agent.agentId, client);
152
155
  for (const stanza of this.buffered.get(agent.agentId) ?? []) client.handle(stanza);
153
156
  this.buffered.delete(agent.agentId);
@@ -220,7 +223,7 @@ export class AgentRuntime {
220
223
 
221
224
  /** File transfer state of one identity: private directories, pinned upload origin. */
222
225
  async fileTransfersFor(agent) {
223
- const { uploadOrigin, quarantineDir = process.env.WACANDO_QUARANTINE_DIR, ...hooks } = this.fileOptions;
226
+ const { uploadOrigin, quarantineDir = process.env.WACANDO_QUARANTINE_DIR, sweepOnConnect: _sweep, ...hooks } = this.fileOptions;
224
227
  const pin = uploadPin(agent.origin, uploadOrigin ?? process.env.WACANDO_UPLOAD_ORIGIN);
225
228
  const base = join(await this.store.directory(agent.origin, this.host), `files-${digest(agent.agentId)}`);
226
229
  const vault = new FileVault(base, quarantineDir ? join(quarantineDir, digest(`${agent.origin}\n${agent.agentId}`).slice(0, 32)) : undefined);
@@ -1,10 +1,13 @@
1
- import { client } from '@xmpp/client';
2
- import { announceAvailable, refuseSubscriptionRequests, xml } from '#wacando/xmpp';
1
+ import { announceAvailable, refuseSubscriptionRequests, ScramAuthError, scramClient, startScramClient, xml } from '#wacando/xmpp';
2
+ import { SCRAM_REFUSED, XMPP_SERVER_UNVERIFIED } from '../onboarding/service.mjs';
3
3
  import { SafeError } from '../onboarding/storage.mjs';
4
4
  import { loadDevice } from './device.mjs';
5
5
 
6
6
  const unavailable = () => new SafeError('Secure messaging is unavailable. Retry the saved identity.');
7
7
 
8
+ /** SafeError for a ScramAuthError: the fixed message plus its local reason enum (no secret). */
9
+ const serverUnverified = error => Object.assign(new SafeError(XMPP_SERVER_UNVERIFIED), { code: SCRAM_REFUSED, reason: error.reason });
10
+
8
11
  /**
9
12
  * One XMPP c2s stream (WebSocket) + this identity's native OMEMO device.
10
13
  *
@@ -13,12 +16,23 @@ const unavailable = () => new SafeError('Secure messaging is unavailable. Retry
13
16
  * delivery addressed to the bare JID after that point reaches this resource
14
17
  * and none can be missed between "online" and the caller subscribing.
15
18
  * Raw XMPP errors, stanzas and credentials are never logged.
19
+ *
20
+ * The client is the embedded `scramClient` (lbframe/wacando#186): SCRAM only,
21
+ * SASL1 only, and authentication succeeds only once the server's
22
+ * ServerSignature (`v=`) was verified. A refusal closes the connection,
23
+ * stops @xmpp/client's reconnect and rejects with `code: 'SCRAM_REFUSED'`.
24
+ * Any failure (start, presence echo or deadline) stops the reconnect loop and
25
+ * the client before rejecting: the same guarantee as the embedded
26
+ * `startOrStop`, extended to the presence echo, so that helper is not used.
16
27
  */
17
28
  export async function connectSession({ jid, password, service, stateBaseDir, onStanza, onReplaced, timeoutMs = 15000 }) {
18
29
  const provider = await loadDevice(jid, stateBaseDir);
19
30
  const at = jid.indexOf('@');
20
31
  const domain = jid.slice(at + 1);
21
- const xmpp = client({ service, domain, username: jid.slice(0, at), password });
32
+ // scramClient applies the embedded guardStart (lbframe/wacando#200): an
33
+ // 'error' while the stream opens rejects start() (an ordinary, retried
34
+ // failure here), never leaves an unhandled rejection behind.
35
+ const xmpp = scramClient({ service, domain, username: jid.slice(0, at), password });
22
36
  // One server-enforced session per agent (lbframe/wacando #15): a newer login
23
37
  // ends this one with <conflict/>. Remember it so nothing reconnects in a loop.
24
38
  let replaced = false;
@@ -31,15 +45,16 @@ export async function connectSession({ jid, password, service, stateBaseDir, onS
31
45
  let timer;
32
46
  const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); });
33
47
  try {
34
- await Promise.race([xmpp.start(), deadline]);
48
+ await Promise.race([startScramClient(xmpp), deadline]);
35
49
  xmpp.reconnect?.stop();
36
50
  // The control plane owns the roster (#13): never approve a subscription.
37
51
  refuseSubscriptionRequests(xmpp);
38
52
  if (onStanza) xmpp.on('stanza', stanza => onStanza(stanza, xmpp));
39
53
  await Promise.race([announceAvailable(xmpp, timeoutMs), deadline]);
40
- } catch {
54
+ } catch (error) {
55
+ xmpp.reconnect?.stop();
41
56
  await xmpp.stop().catch(() => {});
42
- throw unavailable();
57
+ throw error instanceof ScramAuthError ? serverUnverified(error) : unavailable();
43
58
  } finally { clearTimeout(timer); }
44
59
  return {
45
60
  client: xmpp, provider, bareJid: provider.bareJid, domain,
@@ -1,4 +1,4 @@
1
- // Embedded from lbframe/wacando@618922ecaf2213da18949a8b27d343c8767b64c5 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
1
+ // Embedded from lbframe/wacando@87b51a96aff0a13ca5ad6650c9c841bee72e1612 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
2
2
 
3
3
  // packages/contracts/src/errors.ts
4
4
  var DomainError = class extends Error {