zapo-js 1.3.0 → 1.5.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.
Files changed (113) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +25 -0
  3. package/dist/auth/WaAuthClient.js +63 -7
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
  7. package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
  8. package/dist/auth/pairing/companion-host.d.ts +105 -0
  9. package/dist/auth/pairing/companion-host.js +148 -0
  10. package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
  11. package/dist/auth/pairing/pairing-code-crypto.js +3 -2
  12. package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
  13. package/dist/auth/pairing/shortcake-crypto.js +106 -0
  14. package/dist/auth/types.d.ts +20 -9
  15. package/dist/client/WaClient.d.ts +15 -5
  16. package/dist/client/WaClient.js +46 -12
  17. package/dist/client/WaClientFactory.d.ts +3 -0
  18. package/dist/client/WaClientFactory.js +27 -7
  19. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  20. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  21. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  22. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  23. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  24. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  25. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  26. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  27. package/dist/client/messaging/companion-host.d.ts +43 -0
  28. package/dist/client/messaging/companion-host.js +61 -0
  29. package/dist/client/messaging/key-protocol.js +25 -1
  30. package/dist/client/persistence/companion-host.d.ts +36 -0
  31. package/dist/client/persistence/companion-host.js +57 -0
  32. package/dist/client/plugins/install.d.ts +2 -1
  33. package/dist/client/plugins/install.js +44 -11
  34. package/dist/client/types.d.ts +90 -7
  35. package/dist/esm/auth/WaAuthClient.js +64 -8
  36. package/dist/esm/auth/credentials-flow.js +36 -3
  37. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  38. package/dist/esm/auth/pairing/companion-host.js +140 -0
  39. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  40. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  41. package/dist/esm/client/WaClient.js +46 -12
  42. package/dist/esm/client/WaClientFactory.js +28 -8
  43. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  44. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  45. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  46. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  47. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  48. package/dist/esm/client/messaging/companion-host.js +56 -0
  49. package/dist/esm/client/messaging/key-protocol.js +25 -1
  50. package/dist/esm/client/persistence/companion-host.js +54 -0
  51. package/dist/esm/client/plugins/install.js +44 -11
  52. package/dist/esm/index.js +5 -2
  53. package/dist/esm/message/WaMessageClient.js +3 -2
  54. package/dist/esm/message/context-info.js +4 -0
  55. package/dist/esm/protocol/constants.js +4 -3
  56. package/dist/esm/protocol/device-identity.js +18 -0
  57. package/dist/esm/protocol/group.js +6 -0
  58. package/dist/esm/protocol/message.js +12 -0
  59. package/dist/esm/protocol/nodes.js +17 -1
  60. package/dist/esm/protocol/notification.js +3 -1
  61. package/dist/esm/retry/replay.js +7 -15
  62. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  63. package/dist/esm/signal/attestation/constants.js +2 -0
  64. package/dist/esm/signal/index.js +1 -0
  65. package/dist/esm/transport/index.js +1 -1
  66. package/dist/esm/transport/node/builders/group.js +4 -3
  67. package/dist/esm/transport/node/builders/mobile.js +113 -0
  68. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  69. package/dist/esm/transport/noise/WaClientPayload.js +17 -8
  70. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  71. package/dist/esm/util/index.js +1 -0
  72. package/dist/index.d.ts +11 -4
  73. package/dist/index.js +13 -4
  74. package/dist/message/WaMessageClient.js +2 -1
  75. package/dist/message/context-info.d.ts +2 -0
  76. package/dist/message/context-info.js +5 -0
  77. package/dist/protocol/constants.d.ts +6 -4
  78. package/dist/protocol/constants.js +8 -2
  79. package/dist/protocol/device-identity.d.ts +25 -0
  80. package/dist/protocol/device-identity.js +21 -0
  81. package/dist/protocol/group.d.ts +6 -0
  82. package/dist/protocol/group.js +7 -1
  83. package/dist/protocol/message.d.ts +14 -0
  84. package/dist/protocol/message.js +13 -1
  85. package/dist/protocol/nodes.d.ts +16 -0
  86. package/dist/protocol/nodes.js +17 -1
  87. package/dist/protocol/notification.d.ts +2 -0
  88. package/dist/protocol/notification.js +3 -1
  89. package/dist/retry/replay.d.ts +1 -0
  90. package/dist/retry/replay.js +6 -14
  91. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  92. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  93. package/dist/signal/attestation/constants.d.ts +2 -0
  94. package/dist/signal/attestation/constants.js +3 -1
  95. package/dist/signal/index.d.ts +1 -0
  96. package/dist/signal/index.js +13 -1
  97. package/dist/transport/index.d.ts +2 -2
  98. package/dist/transport/index.js +4 -3
  99. package/dist/transport/node/builders/group.js +4 -3
  100. package/dist/transport/node/builders/mobile.d.ts +68 -0
  101. package/dist/transport/node/builders/mobile.js +120 -0
  102. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  103. package/dist/transport/node/builders/shortcake.js +57 -0
  104. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  105. package/dist/transport/noise/WaClientPayload.js +17 -7
  106. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  107. package/dist/transport/wa-version-fetcher.js +156 -0
  108. package/dist/util/index.d.ts +1 -0
  109. package/dist/util/index.js +3 -1
  110. package/package.json +1 -1
  111. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  112. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  113. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -0,0 +1,645 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WaMobileCoordinator = void 0;
4
+ const companion_host_1 = require("../../auth/pairing/companion-host");
5
+ const companion_host_2 = require("../messaging/companion-host");
6
+ const _crypto_1 = require("../../crypto");
7
+ const jid_1 = require("../../protocol/jid");
8
+ const nodes_1 = require("../../protocol/nodes");
9
+ const _transport_1 = require("../../transport");
10
+ const mobile_1 = require("../../transport/node/builders/mobile");
11
+ const async_1 = require("../../util/async");
12
+ const primitives_1 = require("../../util/primitives");
13
+ const PAIR_DEVICE_TIMEOUT_MS = 32000;
14
+ const KEY_INDEX_TIMEOUT_MS = 32000;
15
+ const REMOVE_DEVICE_TIMEOUT_MS = 32000;
16
+ const PRIMARY_HELLO_TIMEOUT_MS = 120000;
17
+ const LINK_CODE_FINISH_TIMEOUT_MS = 120000;
18
+ const PEM_TTL_SECONDS = 5 * 24 * 60 * 60;
19
+ const COMPANION_PROVISION_ATTEMPTS = 8;
20
+ const COMPANION_PROVISION_RETRY_MS = 1500;
21
+ /**
22
+ * Hosts companion devices from a mobile-primary session: signs a companion's
23
+ * device identity, uploads `pair-device`, tracks the linked set, and can revoke
24
+ * a device. Exposed at `client.mobile`; requires a registered primary session.
25
+ * Supports the QR ({@link linkCompanion}) and pairing-code
26
+ * ({@link linkCompanionByCode}) flows.
27
+ */
28
+ class WaMobileCoordinator {
29
+ constructor(deps) {
30
+ this.epoch = null;
31
+ this.pendingCompanionHello = null;
32
+ this.pendingFinish = null;
33
+ this.accountKeyIndexes = new Set([0]);
34
+ this.pushNameSeeded = false;
35
+ this.authClient = deps.authClient;
36
+ this.messageDispatch = deps.messageDispatch;
37
+ this.chatCoordinator = deps.chatCoordinator;
38
+ this.deviceSync = deps.deviceSync;
39
+ this.appStateStore = deps.appStateStore;
40
+ this.queryWithContext = deps.queryWithContext;
41
+ this.emitEvent = deps.emitEvent;
42
+ this.isMobilePrimary = deps.isMobilePrimary;
43
+ this.logger = deps.logger.child({ scope: 'mobile' });
44
+ this.persistence = deps.persistence;
45
+ this.includePem = deps.includePem ?? false;
46
+ deps.registerIncomingHandler({
47
+ tag: nodes_1.WA_NODE_TAGS.NOTIFICATION,
48
+ prepend: true,
49
+ handler: (node) => this.handleNotification(node)
50
+ });
51
+ }
52
+ /**
53
+ * Links the companion from its pairing QR: signs the device identity, uploads
54
+ * `pair-device`, and returns the server-assigned device jid.
55
+ *
56
+ * @throws when there is no primary session or the server rejects the upload
57
+ * (e.g. the linked-device cap is reached).
58
+ */
59
+ async linkCompanion(qr) {
60
+ try {
61
+ const parsed = (0, companion_host_1.parseCompanionQr)(qr);
62
+ return await this.completeLink({
63
+ ref: parsed.ref,
64
+ companionIdentityPublicKey: parsed.identityPublicKey,
65
+ companionNoisePublicKey: parsed.noisePublicKey,
66
+ advSecretKey: parsed.advSecretKey,
67
+ platform: parsed.platform
68
+ });
69
+ }
70
+ catch (error) {
71
+ const normalized = (0, primitives_1.toError)(error);
72
+ this.logger.warn('companion link failed', { message: normalized.message });
73
+ this.emitEvent('companion_host_error', normalized);
74
+ throw normalized;
75
+ }
76
+ }
77
+ /**
78
+ * Links a companion via its 8-character pairing code (the "link with phone
79
+ * number" flow). The companion must have requested a code for this account
80
+ * first (its `companion_hello` is recorded); this drives `primary_hello`,
81
+ * awaits `companion_finish`, and completes the same `pair-device` upload as
82
+ * the QR path.
83
+ *
84
+ * @throws when there is no primary session, no companion is pending, the code
85
+ * is wrong, or the handshake times out.
86
+ */
87
+ async linkCompanionByCode(pairingCode) {
88
+ try {
89
+ const accountIdentityKeyPair = this.requirePrimaryIdentityKeyPair();
90
+ const hello = this.pendingCompanionHello;
91
+ if (!hello) {
92
+ throw new Error('no pending companion; a companion must request a pairing code for this account first');
93
+ }
94
+ const prepared = await (0, companion_host_1.preparePrimaryHello)({
95
+ pairingCode: pairingCode.replace(/[^0-9A-Za-z]/g, '').toUpperCase(),
96
+ wrappedCompanionEphemeralPub: hello.wrappedCompanionEphemeralPub
97
+ });
98
+ let finishTimeout;
99
+ const finishPromise = new Promise((resolve, reject) => {
100
+ this.pendingFinish = { ref: hello.ref, resolve };
101
+ finishTimeout = setTimeout(() => {
102
+ if (this.pendingFinish?.ref === hello.ref) {
103
+ this.pendingFinish = null;
104
+ reject(new Error('link-code companion_finish timed out'));
105
+ }
106
+ }, LINK_CODE_FINISH_TIMEOUT_MS);
107
+ });
108
+ finishPromise.catch(() => undefined);
109
+ let finish;
110
+ try {
111
+ const helloResult = await this.queryWithContext('companion-host.primary-hello', (0, mobile_1.buildPrimaryHelloIq)({
112
+ ref: hello.ref,
113
+ wrappedPrimaryEphemeralPub: prepared.wrappedPrimaryEphemeralPub,
114
+ primaryIdentityPub: (0, _crypto_1.toRawPubKey)(accountIdentityKeyPair.pubKey)
115
+ }), PRIMARY_HELLO_TIMEOUT_MS);
116
+ (0, _transport_1.assertIqResult)(helloResult, 'companion-host.primary-hello');
117
+ finish = await finishPromise;
118
+ }
119
+ finally {
120
+ clearTimeout(finishTimeout);
121
+ if (this.pendingFinish?.ref === hello.ref) {
122
+ this.pendingFinish = null;
123
+ }
124
+ }
125
+ const advSecretKey = await (0, companion_host_1.completePrimaryHandshake)({
126
+ sharedEphemeral: prepared.sharedEphemeral,
127
+ wrappedKeyBundle: finish.wrappedKeyBundle,
128
+ companionIdentityPub: finish.companionIdentityPub,
129
+ primaryIdentityKeyPair: accountIdentityKeyPair
130
+ });
131
+ this.pendingCompanionHello = null;
132
+ return await this.completeLink({
133
+ ref: hello.ref,
134
+ companionIdentityPublicKey: (0, _crypto_1.toRawPubKey)(finish.companionIdentityPub),
135
+ companionNoisePublicKey: hello.companionServerAuthKeyPub,
136
+ advSecretKey,
137
+ platform: 'link-code'
138
+ });
139
+ }
140
+ catch (error) {
141
+ const normalized = (0, primitives_1.toError)(error);
142
+ this.logger.warn('companion link-by-code failed', { message: normalized.message });
143
+ this.emitEvent('companion_host_error', normalized);
144
+ throw normalized;
145
+ }
146
+ }
147
+ /** Re-signs and republishes the key-index list for the current device set. */
148
+ async publishKeyIndexList() {
149
+ const accountIdentityKeyPair = this.requirePrimaryIdentityKeyPair();
150
+ const epoch = await this.ensureEpoch();
151
+ const timestampSeconds = Math.floor(Date.now() / 1000);
152
+ const validIndexes = this.validKeyIndexes(epoch);
153
+ const currentIndex = validIndexes[validIndexes.length - 1];
154
+ const keyIndexListBytes = await (0, companion_host_1.buildSignedKeyIndexList)({
155
+ accountIdentityKeyPair,
156
+ rawId: epoch.rawId,
157
+ currentIndex,
158
+ timestampSeconds,
159
+ validIndexes
160
+ });
161
+ const iq = (0, mobile_1.buildKeyIndexListPublishIq)({ keyIndexListBytes, timestampSeconds });
162
+ const result = await this.queryWithContext('companion-host.key-index-list', iq, KEY_INDEX_TIMEOUT_MS);
163
+ (0, _transport_1.assertIqResult)(result, 'companion-host.key-index-list');
164
+ this.logger.debug('key-index list published', { currentIndex, validIndexes });
165
+ }
166
+ /**
167
+ * Unlinks a companion this primary linked: sends the `remove-companion-device`
168
+ * IQ, drops it from the tracked set, and republishes the key-index list for
169
+ * the reduced device set.
170
+ *
171
+ * @throws when there is no registered primary session, the companion is not
172
+ * tracked, or the server rejects the removal.
173
+ */
174
+ async revokeCompanion(companionDeviceJid, reason = 'user_initiated') {
175
+ this.requirePrimaryIdentityKeyPair();
176
+ const epoch = await this.ensureEpoch();
177
+ if (!epoch.companions.some((companion) => companion.deviceJid === companionDeviceJid)) {
178
+ throw new Error(`companion ${companionDeviceJid} is not tracked by this primary`);
179
+ }
180
+ const result = await this.queryWithContext('companion-host.remove-device', (0, mobile_1.buildRemoveCompanionDeviceIq)({ deviceJid: companionDeviceJid, reason }), REMOVE_DEVICE_TIMEOUT_MS);
181
+ (0, _transport_1.assertIqResult)(result, 'companion-host.remove-device');
182
+ this.epoch = {
183
+ rawId: epoch.rawId,
184
+ currentKeyIndex: epoch.currentKeyIndex,
185
+ companions: epoch.companions.filter((companion) => companion.deviceJid !== companionDeviceJid)
186
+ };
187
+ this.forgetAccountKeyIndex((0, jid_1.parseSignalAddressFromJid)(companionDeviceJid).device);
188
+ await this.persist(this.epoch);
189
+ await this.publishKeyIndexList().catch((error) => {
190
+ this.logger.warn('key-index list republish failed after companion revoke', {
191
+ deviceJid: companionDeviceJid,
192
+ message: (0, primitives_1.toError)(error).message
193
+ });
194
+ });
195
+ this.logger.info('companion revoked', { deviceJid: companionDeviceJid, reason });
196
+ this.emitEvent('companion_host_revoked', { deviceJid: companionDeviceJid });
197
+ }
198
+ /**
199
+ * Unlinks EVERY companion from the account in a single `remove-companion-device`
200
+ * `all="true"` stanza (the phone's "log out all companion devices"), clears the
201
+ * tracked set, and republishes the key-index list for the primary alone.
202
+ * `excludeHostedCompanion` spares companions this account itself hosts.
203
+ *
204
+ * @throws when there is no registered primary session or the server rejects
205
+ * the removal.
206
+ */
207
+ async revokeAllCompanions(reason = 'user_initiated', options = {}) {
208
+ this.requirePrimaryIdentityKeyPair();
209
+ const epoch = await this.ensureEpoch();
210
+ const result = await this.queryWithContext('companion-host.remove-all-devices', (0, mobile_1.buildRemoveAllCompanionDevicesIq)({
211
+ reason,
212
+ excludeHostedCompanion: options.excludeHostedCompanion
213
+ }), REMOVE_DEVICE_TIMEOUT_MS);
214
+ (0, _transport_1.assertIqResult)(result, 'companion-host.remove-all-devices');
215
+ if (options.excludeHostedCompanion) {
216
+ this.logger.info('revoked non-hosted companions; hosted set kept', {
217
+ kept: epoch.companions.length,
218
+ reason
219
+ });
220
+ return;
221
+ }
222
+ const removed = epoch.companions.map((companion) => companion.deviceJid);
223
+ this.epoch = { rawId: epoch.rawId, currentKeyIndex: epoch.currentKeyIndex, companions: [] };
224
+ this.accountKeyIndexes = new Set([0]);
225
+ await this.persist(this.epoch);
226
+ await this.publishKeyIndexList().catch((error) => {
227
+ this.logger.warn('key-index list republish failed after revoke-all', {
228
+ message: (0, primitives_1.toError)(error).message
229
+ });
230
+ });
231
+ this.logger.info('all companions revoked', { count: removed.length, reason });
232
+ for (const deviceJid of removed) {
233
+ this.emitEvent('companion_host_revoked', { deviceJid });
234
+ }
235
+ }
236
+ /** Returns the companions this primary has linked in the current epoch. */
237
+ async listCompanions() {
238
+ const epoch = await this.ensureEpoch();
239
+ return epoch.companions;
240
+ }
241
+ /**
242
+ * Reconciles the tracked companion set against the account's live device list
243
+ * on the server (`usync`). Drops companions the server no longer lists - one
244
+ * the user unlinked, or one that self-removed while the primary was offline -
245
+ * then persists and emits `companion_host_revoked` for each. Runs on connect
246
+ * and on `account_sync`; safe to call manually. A no-op (no server query, no
247
+ * epoch created) when no companions are tracked. Returns the removed jids.
248
+ */
249
+ async reconcileCompanions() {
250
+ if (!this.isMobilePrimary()) {
251
+ return [];
252
+ }
253
+ const meJid = this.authClient.getCurrentCredentials()?.meJid;
254
+ if (!meJid) {
255
+ return [];
256
+ }
257
+ const epoch = this.epoch ?? (this.persistence ? await this.persistence.load() : null);
258
+ if (!epoch || epoch.companions.length === 0) {
259
+ return [];
260
+ }
261
+ this.epoch = epoch;
262
+ const [synced] = await this.deviceSync.syncDeviceList([meJid]);
263
+ const serverJids = synced?.deviceJids;
264
+ if (!serverJids || serverJids.length === 0) {
265
+ return [];
266
+ }
267
+ return this.pruneCompanionsToDevices(epoch, new Set(serverJids.map((jid) => (0, jid_1.parseSignalAddressFromJid)(jid).device)));
268
+ }
269
+ /**
270
+ * Prunes tracked companions to those whose device slot is still in
271
+ * `serverDeviceIndexes`, persisting + emitting `companion_host_revoked` for
272
+ * each drop. Shared by the connect-time (`usync`) and `account_sync` paths.
273
+ */
274
+ async pruneCompanionsToDevices(epoch, serverDeviceIndexes) {
275
+ const kept = epoch.companions.filter((companion) => serverDeviceIndexes.has((0, jid_1.parseSignalAddressFromJid)(companion.deviceJid).device));
276
+ if (kept.length === epoch.companions.length) {
277
+ return [];
278
+ }
279
+ const removed = epoch.companions
280
+ .filter((companion) => !serverDeviceIndexes.has((0, jid_1.parseSignalAddressFromJid)(companion.deviceJid).device))
281
+ .map((companion) => companion.deviceJid);
282
+ this.epoch = {
283
+ rawId: epoch.rawId,
284
+ currentKeyIndex: epoch.currentKeyIndex,
285
+ companions: kept
286
+ };
287
+ await this.persist(this.epoch);
288
+ this.logger.info('reconciled companions with server device list', {
289
+ kept: kept.length,
290
+ removed: removed.length
291
+ });
292
+ for (const deviceJid of removed) {
293
+ this.emitEvent('companion_host_revoked', { deviceJid });
294
+ }
295
+ return removed;
296
+ }
297
+ /**
298
+ * Reconciles from an `account_sync` notification's `<devices>` payload - the
299
+ * fresh, authoritative device set - so a companion the user just unlinked is
300
+ * pruned immediately (no `usync` round-trip, which would read a stale cache).
301
+ */
302
+ async reconcileFromAccountSync(node) {
303
+ if (!this.isMobilePrimary()) {
304
+ return;
305
+ }
306
+ const devices = (0, _transport_1.findNodeChild)(node, nodes_1.WA_NODE_TAGS.DEVICES);
307
+ if (!devices) {
308
+ return;
309
+ }
310
+ const epoch = this.epoch ?? (this.persistence ? await this.persistence.load() : null);
311
+ if (!epoch || epoch.companions.length === 0) {
312
+ return;
313
+ }
314
+ this.epoch = epoch;
315
+ const serverDeviceIndexes = new Set([0]);
316
+ for (const child of (0, _transport_1.getNodeChildren)(devices)) {
317
+ if (child.tag !== nodes_1.WA_NODE_TAGS.DEVICE) {
318
+ continue;
319
+ }
320
+ const jid = child.attrs.jid;
321
+ if (jid) {
322
+ serverDeviceIndexes.add((0, jid_1.parseSignalAddressFromJid)(jid).device);
323
+ }
324
+ }
325
+ await this.pruneCompanionsToDevices(epoch, serverDeviceIndexes);
326
+ }
327
+ /**
328
+ * Shares the primary's active app-state sync key with a linked companion via
329
+ * an `APP_STATE_SYNC_KEY_SHARE` peer message so it can decrypt app-state.
330
+ * Establishes the outbound Signal session on demand, so call it once the
331
+ * companion is online (prekeys uploaded), not at link time.
332
+ *
333
+ * @throws when there is no primary session or no active sync key.
334
+ */
335
+ async shareAppStateSyncKeys(companionDeviceJid) {
336
+ this.requirePrimaryIdentityKeyPair();
337
+ const activeKey = await this.appStateStore.getActiveSyncKey();
338
+ if (!activeKey) {
339
+ throw new Error('no active app-state sync key to share; the primary session is not initialized');
340
+ }
341
+ await this.messageDispatch.sendAppStateSyncKeyShare(companionDeviceJid, [activeKey]);
342
+ this.logger.info('shared app-state sync key with companion', {
343
+ deviceJid: companionDeviceJid
344
+ });
345
+ }
346
+ /**
347
+ * Pushes the `INITIAL_BOOTSTRAP` history sync to a linked companion as a
348
+ * `HISTORY_SYNC_NOTIFICATION` peer message. Flips the companion's
349
+ * `initialChatHistory` bootstrap flag; without it the companion self-removes
350
+ * with `HistorySyncTimeout`. Requires the companion online with prekeys.
351
+ *
352
+ * @throws when there is no registered primary session.
353
+ */
354
+ async sendHistorySyncBootstrap(companionDeviceJid, options = {}) {
355
+ this.requirePrimaryIdentityKeyPair();
356
+ const { message, payloadBytes } = await (0, companion_host_2.buildHistorySyncBootstrapMessage)({
357
+ phoneNumberToLidMappings: options.phoneNumberToLidMappings
358
+ });
359
+ await this.messageDispatch.publishProtocolMessageToDevice(companionDeviceJid, message);
360
+ this.logger.info('companion-host history sync bootstrap sent', {
361
+ deviceJid: companionDeviceJid,
362
+ mappings: options.phoneNumberToLidMappings?.length ?? 0,
363
+ payloadBytes
364
+ });
365
+ }
366
+ /**
367
+ * `ClientPairingProps` for the `<client-props>` pair-device element. A
368
+ * LID-native primary declares the chat DB LID-migrated + pure-LID session so
369
+ * the companion runs `setIsLidMigrated` at pair time and does not self-remove
370
+ * on its LID-addressed blocklist.
371
+ */
372
+ buildClientPairingProps() {
373
+ const meLid = this.authClient.getCurrentCredentials()?.meLid;
374
+ const lidNative = typeof meLid === 'string' && meLid.length > 0;
375
+ return {
376
+ isChatDbLidMigrated: lidNative,
377
+ isSyncdPureLidSession: lidNative,
378
+ isSyncdSnapshotRecoveryEnabled: false
379
+ };
380
+ }
381
+ /**
382
+ * Seeds the primary's own `setting_pushName` into the `critical_block`
383
+ * app-state collection, once per session. The companion's critical bootstrap
384
+ * completes only when it finds an applied `SettingPushName` action; without
385
+ * one it self-removes with `syncd_timeout`. Falls back to the account phone
386
+ * number when no push name is set. Idempotent and best-effort.
387
+ */
388
+ async ensureAccountPushNameAppState() {
389
+ if (this.pushNameSeeded) {
390
+ return;
391
+ }
392
+ const credentials = this.authClient.getCurrentCredentials();
393
+ const meJidUser = credentials?.meJid?.split('@')[0]?.split(':')[0];
394
+ const name = credentials?.pushName?.trim() || meJidUser;
395
+ if (!name) {
396
+ throw new Error('cannot seed setting_pushName: primary has no pushName or meJid');
397
+ }
398
+ await this.chatCoordinator.set({ schema: 'SettingPushName', name });
399
+ this.pushNameSeeded = true;
400
+ this.logger.info('seeded primary setting_pushName into critical_block app-state', {
401
+ name
402
+ });
403
+ }
404
+ async completeLink(input) {
405
+ const accountIdentityKeyPair = this.requirePrimaryIdentityKeyPair();
406
+ await this.ensureAccountPushNameAppState().catch((error) => {
407
+ this.logger.warn('failed to seed primary setting_pushName app-state', {
408
+ message: (0, primitives_1.toError)(error).message
409
+ });
410
+ });
411
+ const epoch = await this.ensureEpoch();
412
+ const keyIndex = this.nextKeyIndex(epoch);
413
+ const validIndexes = [...this.validKeyIndexes(epoch), keyIndex].sort((a, b) => a - b);
414
+ const timestampSeconds = Math.floor(Date.now() / 1000);
415
+ const record = {
416
+ deviceJid: '',
417
+ keyIndex,
418
+ companionIdentityPublicKey: input.companionIdentityPublicKey,
419
+ addedAtSeconds: timestampSeconds
420
+ };
421
+ const signed = await (0, companion_host_1.buildSignedCompanionIdentity)({
422
+ accountIdentityKeyPair,
423
+ companionIdentityPublicKey: input.companionIdentityPublicKey,
424
+ advSecretKey: input.advSecretKey,
425
+ rawId: epoch.rawId,
426
+ keyIndex,
427
+ timestampSeconds,
428
+ validIndexes
429
+ });
430
+ const iq = (0, mobile_1.buildPairDeviceIq)({
431
+ ref: input.ref,
432
+ companionNoisePublicKey: input.companionNoisePublicKey,
433
+ deviceIdentityBytes: signed.deviceIdentityBytes,
434
+ keyIndexListBytes: signed.keyIndexListBytes,
435
+ keyIndexListTimestampSeconds: timestampSeconds,
436
+ clientProps: this.buildClientPairingProps(),
437
+ pem: this.includePem
438
+ ? {
439
+ aesGcmKeyBytes: (0, companion_host_1.derivePemKey)(input.advSecretKey, input.companionNoisePublicKey),
440
+ ttlSeconds: PEM_TTL_SECONDS
441
+ }
442
+ : undefined
443
+ });
444
+ this.logger.info('linking companion', { platform: input.platform, keyIndex });
445
+ const result = await this.queryWithContext('companion-host.pair-device', iq, PAIR_DEVICE_TIMEOUT_MS);
446
+ (0, _transport_1.assertIqResult)(result, 'companion-host.pair-device');
447
+ const wrapper = (0, _transport_1.getFirstNodeChild)(result);
448
+ const deviceNode = (0, _transport_1.findNodeChild)(result, nodes_1.WA_NODE_TAGS.DEVICE) ??
449
+ (wrapper ? (0, _transport_1.findNodeChild)(wrapper, nodes_1.WA_NODE_TAGS.DEVICE) : undefined);
450
+ const deviceJid = deviceNode?.attrs.jid;
451
+ if (!deviceJid) {
452
+ throw new Error('pair-device result missing <device jid>');
453
+ }
454
+ this.epoch = {
455
+ rawId: epoch.rawId,
456
+ currentKeyIndex: keyIndex,
457
+ companions: [...epoch.companions, { ...record, deviceJid }]
458
+ };
459
+ await this.persist(this.epoch);
460
+ const linked = { deviceJid, keyIndex };
461
+ this.logger.info('companion linked', { deviceJid, keyIndex });
462
+ this.emitEvent('companion_host_linked', linked);
463
+ this.provisionLinkedCompanion(deviceJid);
464
+ return linked;
465
+ }
466
+ /**
467
+ * Best-effort background provisioning after a companion links: pushes the
468
+ * history-sync bootstrap and shares the app-state sync key. Fire-and-forget
469
+ * and retried, because the companion needs a moment to upload prekeys before
470
+ * the outbound Signal session these peer messages ride on can be established.
471
+ */
472
+ provisionLinkedCompanion(deviceJid) {
473
+ void this.provisionLinkedCompanionWithRetry(deviceJid).catch((error) => {
474
+ const normalized = (0, primitives_1.toError)(error);
475
+ this.logger.warn('companion provisioning failed', {
476
+ deviceJid,
477
+ message: normalized.message
478
+ });
479
+ this.emitEvent('companion_host_error', normalized);
480
+ });
481
+ }
482
+ async provisionLinkedCompanionWithRetry(deviceJid) {
483
+ let lastError;
484
+ for (let attempt = 1; attempt <= COMPANION_PROVISION_ATTEMPTS; attempt += 1) {
485
+ if (attempt > 1) {
486
+ await (0, async_1.delay)(COMPANION_PROVISION_RETRY_MS);
487
+ }
488
+ try {
489
+ await this.sendHistorySyncBootstrap(deviceJid);
490
+ await this.shareAppStateSyncKeys(deviceJid).catch((error) => {
491
+ this.logger.debug('companion app-state key share (best-effort) failed', {
492
+ deviceJid,
493
+ message: (0, primitives_1.toError)(error).message
494
+ });
495
+ });
496
+ this.logger.info('companion provisioned', { deviceJid, attempts: attempt });
497
+ return;
498
+ }
499
+ catch (error) {
500
+ lastError = error;
501
+ this.logger.debug('companion provisioning attempt failed', {
502
+ deviceJid,
503
+ attempt,
504
+ message: (0, primitives_1.toError)(error).message
505
+ });
506
+ }
507
+ }
508
+ throw (0, primitives_1.toError)(lastError);
509
+ }
510
+ handleNotification(node) {
511
+ if (node.attrs.type === 'account_sync') {
512
+ this.updateAccountKeyIndexes(node);
513
+ void this.reconcileFromAccountSync(node).catch((error) => {
514
+ this.logger.debug('companion reconcile on account_sync failed', {
515
+ message: (0, primitives_1.toError)(error).message
516
+ });
517
+ });
518
+ return Promise.resolve(false);
519
+ }
520
+ const linkCode = (0, _transport_1.findNodeChild)(node, nodes_1.WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
521
+ if (linkCode) {
522
+ const stage = linkCode.attrs.stage;
523
+ if (stage === 'companion_hello') {
524
+ this.storeCompanionHello(linkCode);
525
+ }
526
+ else if (stage === 'companion_finish') {
527
+ this.resolveCompanionFinish(linkCode);
528
+ }
529
+ }
530
+ return Promise.resolve(false);
531
+ }
532
+ /**
533
+ * Tracks the account's live device key-indexes from an `account_sync`
534
+ * notification so a new companion gets a non-colliding key index and the
535
+ * published key-index list keeps every existing device valid.
536
+ */
537
+ updateAccountKeyIndexes(node) {
538
+ const devices = (0, _transport_1.findNodeChild)(node, nodes_1.WA_NODE_TAGS.DEVICES);
539
+ if (!devices) {
540
+ return;
541
+ }
542
+ const indexes = new Set([0]);
543
+ for (const child of (0, _transport_1.getNodeChildren)(devices)) {
544
+ if (child.tag !== nodes_1.WA_NODE_TAGS.DEVICE) {
545
+ continue;
546
+ }
547
+ const rawIndex = child.attrs['key-index'];
548
+ const parsed = rawIndex ? Number.parseInt(rawIndex, 10) : Number.NaN;
549
+ if (Number.isInteger(parsed) && parsed >= 0) {
550
+ indexes.add(parsed);
551
+ }
552
+ }
553
+ this.accountKeyIndexes = indexes;
554
+ this.logger.debug('account device key-indexes updated', { indexes: [...indexes] });
555
+ }
556
+ forgetAccountKeyIndex(index) {
557
+ if (!this.accountKeyIndexes.has(index)) {
558
+ return;
559
+ }
560
+ const next = new Set(this.accountKeyIndexes);
561
+ next.delete(index);
562
+ this.accountKeyIndexes = next;
563
+ }
564
+ /**
565
+ * The key indexes currently valid for this account - index 0 (the primary),
566
+ * the account's own live devices, and every tracked companion. Sorted
567
+ * ascending. Deliberately excludes `epoch.currentKeyIndex`, which is an
568
+ * allocation high-water mark, not proof a device still exists: after revoking
569
+ * the last-linked companion its index must drop out of the published list.
570
+ */
571
+ validKeyIndexes(epoch) {
572
+ const indexes = new Set([0, ...this.accountKeyIndexes]);
573
+ for (const companion of epoch.companions) {
574
+ indexes.add(companion.keyIndex);
575
+ }
576
+ return [...indexes].sort((a, b) => a - b);
577
+ }
578
+ /**
579
+ * Allocates the next companion key index, advancing strictly past both the
580
+ * epoch's monotonic high-water mark and every currently-valid index so a
581
+ * revoked index is never reused (reuse breaks previously linked devices).
582
+ */
583
+ nextKeyIndex(epoch) {
584
+ return Math.max(epoch.currentKeyIndex, ...this.validKeyIndexes(epoch)) + 1;
585
+ }
586
+ storeCompanionHello(linkCode) {
587
+ const ref = (0, _transport_1.getNodeTextContent)((0, _transport_1.findNodeChild)(linkCode, nodes_1.WA_NODE_TAGS.LINK_CODE_PAIRING_REF));
588
+ const wrapped = this.nodeBytes(linkCode, nodes_1.WA_NODE_TAGS.LINK_CODE_PAIRING_WRAPPED_COMPANION_EPHEMERAL_PUB);
589
+ const authKey = this.nodeBytes(linkCode, nodes_1.WA_NODE_TAGS.COMPANION_SERVER_AUTH_KEY_PUB);
590
+ if (!ref || !wrapped || !authKey) {
591
+ return;
592
+ }
593
+ this.pendingCompanionHello = {
594
+ ref,
595
+ wrappedCompanionEphemeralPub: wrapped,
596
+ companionServerAuthKeyPub: authKey
597
+ };
598
+ this.logger.debug('recorded link-code companion_hello', { ref });
599
+ }
600
+ resolveCompanionFinish(linkCode) {
601
+ const ref = (0, _transport_1.getNodeTextContent)((0, _transport_1.findNodeChild)(linkCode, nodes_1.WA_NODE_TAGS.LINK_CODE_PAIRING_REF));
602
+ const wrappedKeyBundle = this.nodeBytes(linkCode, nodes_1.WA_NODE_TAGS.LINK_CODE_PAIRING_WRAPPED_KEY_BUNDLE);
603
+ const companionIdentityPub = this.nodeBytes(linkCode, nodes_1.WA_NODE_TAGS.COMPANION_IDENTITY_PUBLIC);
604
+ if (ref && wrappedKeyBundle && companionIdentityPub && this.pendingFinish?.ref === ref) {
605
+ this.pendingFinish.resolve({ companionIdentityPub, wrappedKeyBundle });
606
+ this.pendingFinish = null;
607
+ }
608
+ }
609
+ nodeBytes(parent, tag) {
610
+ const content = (0, _transport_1.findNodeChild)(parent, tag)?.content;
611
+ return content instanceof Uint8Array ? content : null;
612
+ }
613
+ requirePrimaryIdentityKeyPair() {
614
+ if (!this.isMobilePrimary()) {
615
+ throw new Error('client.mobile requires a mobile-primary session (connect via mobileTransport / a registered phone identity)');
616
+ }
617
+ const credentials = this.authClient.getCurrentCredentials();
618
+ if (!credentials?.meJid) {
619
+ throw new Error('companion-host requires a registered primary session (no meJid)');
620
+ }
621
+ return credentials.registrationInfo.identityKeyPair;
622
+ }
623
+ async ensureEpoch() {
624
+ if (this.epoch) {
625
+ return this.epoch;
626
+ }
627
+ const loaded = this.persistence ? await this.persistence.load() : null;
628
+ if (loaded) {
629
+ this.epoch = loaded;
630
+ return loaded;
631
+ }
632
+ this.epoch = { rawId: await this.generateRawId(), currentKeyIndex: 0, companions: [] };
633
+ await this.persist(this.epoch);
634
+ return this.epoch;
635
+ }
636
+ async generateRawId() {
637
+ return (0, _crypto_1.randomIntAsync)(1, 2147483647);
638
+ }
639
+ async persist(state) {
640
+ if (this.persistence) {
641
+ await this.persistence.save(state);
642
+ }
643
+ }
644
+ }
645
+ exports.WaMobileCoordinator = WaMobileCoordinator;
@@ -64,6 +64,7 @@ class WaRetryCoordinator {
64
64
  signalProtocol: options.signalProtocol,
65
65
  sessionResolver: options.sessionResolver,
66
66
  getCurrentCredentials: options.getCurrentCredentials,
67
+ isMobilePrimary: options.isMobilePrimary,
67
68
  resolveUserIcdc: options.resolveUserIcdc,
68
69
  resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
69
70
  });