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