zapo-js 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/appstate/sync/WaAppStateSyncClient.d.ts +11 -1
- package/dist/appstate/sync/WaAppStateSyncClient.js +36 -15
- package/dist/auth/credentials-flow.js +3 -1
- package/dist/auth/pairing/WaPairingFlow.js +2 -0
- package/dist/client/WaClient.js +26 -20
- package/dist/client/WaClientFactory.js +28 -6
- package/dist/client/connection/WaConnectionManager.js +3 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
- package/dist/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +6 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +5 -5
- package/dist/client/coordinators/WaPassiveTasksCoordinator.d.ts +6 -1
- package/dist/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
- package/dist/client/coordinators/WaProfileCoordinator.d.ts +11 -6
- package/dist/client/coordinators/WaProfileCoordinator.js +4 -1
- package/dist/client/coordinators/WaRetryCoordinator.d.ts +18 -1
- package/dist/client/coordinators/WaRetryCoordinator.js +83 -30
- package/dist/client/messaging/ignore-key.js +4 -2
- package/dist/client/types.d.ts +13 -10
- package/dist/esm/appstate/sync/WaAppStateSyncClient.js +36 -15
- package/dist/esm/auth/credentials-flow.js +3 -1
- package/dist/esm/auth/pairing/WaPairingFlow.js +2 -0
- package/dist/esm/client/WaClient.js +26 -20
- package/dist/esm/client/WaClientFactory.js +28 -6
- package/dist/esm/client/connection/WaConnectionManager.js +3 -0
- package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
- package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +6 -6
- package/dist/esm/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
- package/dist/esm/client/coordinators/WaProfileCoordinator.js +4 -1
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +84 -31
- package/dist/esm/client/messaging/ignore-key.js +4 -2
- package/dist/esm/message/WaMessageClient.js +3 -0
- package/dist/esm/message/primitives/incoming.js +20 -5
- package/dist/esm/protocol/constants.js +1 -1
- package/dist/esm/protocol/message.js +22 -0
- package/dist/esm/retry/replay.js +36 -2
- package/dist/esm/signal/session/SignalRatchet.js +2 -2
- package/dist/esm/transport/WaComms.js +4 -0
- package/dist/esm/transport/keepalive/WaKeepAlive.js +4 -0
- package/dist/esm/transport/node/WaNodeOrchestrator.js +2 -2
- package/dist/esm/transport/node/builders/global.js +3 -0
- package/dist/message/WaMessageClient.js +3 -0
- package/dist/message/primitives/incoming.d.ts +7 -1
- package/dist/message/primitives/incoming.js +20 -5
- package/dist/message/types.d.ts +5 -0
- package/dist/protocol/constants.d.ts +1 -1
- package/dist/protocol/constants.js +3 -2
- package/dist/protocol/message.d.ts +22 -0
- package/dist/protocol/message.js +23 -1
- package/dist/retry/replay.d.ts +12 -0
- package/dist/retry/replay.js +36 -2
- package/dist/signal/session/SignalRatchet.js +2 -2
- package/dist/transport/WaComms.js +4 -0
- package/dist/transport/keepalive/WaKeepAlive.js +4 -0
- package/dist/transport/node/WaNodeOrchestrator.d.ts +6 -1
- package/dist/transport/node/WaNodeOrchestrator.js +2 -2
- package/dist/transport/node/builders/global.d.ts +1 -0
- package/dist/transport/node/builders/global.js +3 -0
- package/package.json +1 -1
|
@@ -33,6 +33,21 @@ interface WaRetryCoordinatorOptions {
|
|
|
33
33
|
readonly resolveUserIcdc?: (userJid: string) => Promise<IcdcMeta | null>;
|
|
34
34
|
readonly peerDataOperation?: PeerDataOperationRequester;
|
|
35
35
|
readonly emitIncomingMessage?: (event: WaIncomingMessageEvent) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Placeholder resend asks the primary phone (a peer) for the plaintext. A
|
|
38
|
+
* mobile primary is itself the phone and has no peer to ask, so when this
|
|
39
|
+
* resolves true the coordinator skips the resend and falls back to plain
|
|
40
|
+
* retry receipts. Resolved per failure (post-connect, after credentials
|
|
41
|
+
* load) so a registered mobile session reconnecting without an explicit
|
|
42
|
+
* `mobileTransport` option still takes the fallback path.
|
|
43
|
+
*/
|
|
44
|
+
readonly isMobilePrimary?: () => boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the trusted-contact (privacy) token node for a recipient user
|
|
47
|
+
* jid: retry resends must carry the same `<tctoken>` the original send did,
|
|
48
|
+
* or privacy-gated recipients nack them with error 463.
|
|
49
|
+
*/
|
|
50
|
+
readonly resolvePrivacyTokenNode?: (recipientJid: string) => Promise<BinaryNode | null>;
|
|
36
51
|
}
|
|
37
52
|
export declare class WaRetryCoordinator {
|
|
38
53
|
private readonly deps;
|
|
@@ -44,13 +59,15 @@ export declare class WaRetryCoordinator {
|
|
|
44
59
|
private readonly placeholderInFlight;
|
|
45
60
|
private placeholderQueue;
|
|
46
61
|
private placeholderTimer;
|
|
62
|
+
private readonly decryptFailureQueue;
|
|
47
63
|
constructor(options: WaRetryCoordinatorOptions);
|
|
48
64
|
onDecryptFailure(context: WaRetryDecryptFailureContext, error: unknown): Promise<boolean>;
|
|
65
|
+
private handleDecryptFailure;
|
|
49
66
|
handleIncomingRetryReceipt(receiptNode: BinaryNode): Promise<void>;
|
|
50
67
|
private isRetryReceiptNode;
|
|
51
68
|
private prepareDecryptFailureRetry;
|
|
52
69
|
private sendDecryptFailureRetryReceipt;
|
|
53
|
-
private
|
|
70
|
+
private sendDecryptFailureAck;
|
|
54
71
|
private handleParsedRetryRequest;
|
|
55
72
|
private processRetryRequest;
|
|
56
73
|
private prepareRetryResend;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WaRetryCoordinator = void 0;
|
|
4
|
+
const keys_1 = require("../../crypto/core/keys");
|
|
5
|
+
const BoundedTaskQueue_1 = require("../../infra/perf/BoundedTaskQueue");
|
|
4
6
|
const incoming_1 = require("../../message/primitives/incoming");
|
|
5
7
|
const _proto_1 = require("../../proto");
|
|
6
8
|
const constants_1 = require("../../protocol/constants");
|
|
@@ -17,6 +19,10 @@ const collections_1 = require("../../util/collections");
|
|
|
17
19
|
const primitives_1 = require("../../util/primitives");
|
|
18
20
|
const RETRY_CLEANUP_INTERVAL_MS = 30000;
|
|
19
21
|
const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
|
|
22
|
+
// Decrypt-failure handling runs off the inbound pipeline (keys-section builds
|
|
23
|
+
// serialize on the prekey lock and hit the store); excess under flood is dropped.
|
|
24
|
+
const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
|
|
25
|
+
const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
|
|
20
26
|
const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
|
|
21
27
|
const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
|
|
22
28
|
const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
|
|
@@ -58,22 +64,46 @@ class WaRetryCoordinator {
|
|
|
58
64
|
signalProtocol: options.signalProtocol,
|
|
59
65
|
sessionResolver: options.sessionResolver,
|
|
60
66
|
getCurrentCredentials: options.getCurrentCredentials,
|
|
61
|
-
resolveUserIcdc: options.resolveUserIcdc
|
|
67
|
+
resolveUserIcdc: options.resolveUserIcdc,
|
|
68
|
+
resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
|
|
62
69
|
});
|
|
63
70
|
this.retryProcessingByMessageId = new Map();
|
|
64
71
|
this.retrySessionBaseKeys = new Map();
|
|
72
|
+
this.decryptFailureQueue = new BoundedTaskQueue_1.BoundedTaskQueue(DECRYPT_FAILURE_QUEUE_MAX_SIZE, DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY);
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
onDecryptFailure(context, error) {
|
|
75
|
+
// Deferred to the bounded queue: building the receipt inline would stall
|
|
76
|
+
// the inbound node pipeline.
|
|
77
|
+
void this.decryptFailureQueue
|
|
78
|
+
.enqueue(() => this.handleDecryptFailure(context, error))
|
|
79
|
+
.catch((queueError) => {
|
|
80
|
+
if (queueError instanceof BoundedTaskQueue_1.BoundedTaskQueueFullError) {
|
|
81
|
+
this.deps.logger.warn('decrypt-failure retry dropped: queue saturated', {
|
|
82
|
+
id: context.stanzaId,
|
|
83
|
+
from: context.from,
|
|
84
|
+
participant: context.participant
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.deps.logger.warn('failed to schedule decrypt-failure retry', {
|
|
89
|
+
id: context.stanzaId,
|
|
90
|
+
from: context.from,
|
|
91
|
+
participant: context.participant,
|
|
92
|
+
message: (0, primitives_1.toError)(queueError).message
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return Promise.resolve(true);
|
|
96
|
+
}
|
|
97
|
+
async handleDecryptFailure(context, error) {
|
|
67
98
|
try {
|
|
68
99
|
const prepared = await this.prepareDecryptFailureRetry(context, error);
|
|
69
|
-
if (!prepared) {
|
|
70
|
-
|
|
100
|
+
if (prepared && !prepared.delegatedToPlaceholderResend) {
|
|
101
|
+
await this.sendDecryptFailureRetryReceipt(context, prepared);
|
|
71
102
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await this.
|
|
76
|
-
return true;
|
|
103
|
+
// Ack the failed stanza even on the give-up path: the retry receipt
|
|
104
|
+
// asks for a resend but does not consume the message, so without the
|
|
105
|
+
// ack it is redelivered on every offline resume.
|
|
106
|
+
await this.sendDecryptFailureAck(context);
|
|
77
107
|
}
|
|
78
108
|
catch (sendError) {
|
|
79
109
|
this.deps.logger.warn('failed to send retry receipt for decrypt failure', {
|
|
@@ -82,7 +112,6 @@ class WaRetryCoordinator {
|
|
|
82
112
|
participant: context.participant,
|
|
83
113
|
message: (0, primitives_1.toError)(sendError).message
|
|
84
114
|
});
|
|
85
|
-
return false;
|
|
86
115
|
}
|
|
87
116
|
}
|
|
88
117
|
async handleIncomingRetryReceipt(receiptNode) {
|
|
@@ -141,6 +170,17 @@ class WaRetryCoordinator {
|
|
|
141
170
|
const requester = context.participant ?? context.from;
|
|
142
171
|
const expiresAtMs = nowMs + this.retryTtlMs;
|
|
143
172
|
const retryCount = await this.deps.retryStore.incrementInboundCounter(context.stanzaId, requester, nowMs, expiresAtMs);
|
|
173
|
+
if (retryCount > constants_2.MAX_RETRY_ATTEMPTS) {
|
|
174
|
+
// Give up past the ceiling: each attempt rebuilds an expensive keys
|
|
175
|
+
// section, so an uncapped retry on redelivered backlog hammers the store.
|
|
176
|
+
this.deps.logger.debug('retry receipt skipped: inbound retry limit exceeded', {
|
|
177
|
+
id: context.stanzaId,
|
|
178
|
+
from: context.from,
|
|
179
|
+
participant: context.participant,
|
|
180
|
+
retryCount
|
|
181
|
+
});
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
144
184
|
const delegatedToPlaceholderResend = retryCount >= PLACEHOLDER_RESEND_RETRY_THRESHOLD &&
|
|
145
185
|
this.enqueuePlaceholderResend(context);
|
|
146
186
|
if (delegatedToPlaceholderResend) {
|
|
@@ -169,7 +209,7 @@ class WaRetryCoordinator {
|
|
|
169
209
|
};
|
|
170
210
|
}
|
|
171
211
|
async sendDecryptFailureRetryReceipt(context, prepared) {
|
|
172
|
-
const recipient = context
|
|
212
|
+
const { recipient } = context;
|
|
173
213
|
const retryReceiptNode = (0, retry_1.buildRetryReceiptNode)({
|
|
174
214
|
stanzaId: context.stanzaId,
|
|
175
215
|
to: context.from,
|
|
@@ -194,24 +234,33 @@ class WaRetryCoordinator {
|
|
|
194
234
|
withKeys: prepared.retryKeys !== undefined
|
|
195
235
|
});
|
|
196
236
|
}
|
|
197
|
-
|
|
198
|
-
if (!context.
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
const meLid = this.deps.getCurrentCredentials()?.meLid;
|
|
202
|
-
if (!meLid) {
|
|
203
|
-
return undefined;
|
|
237
|
+
async sendDecryptFailureAck(context) {
|
|
238
|
+
if (!context.stanzaId || !context.from) {
|
|
239
|
+
return;
|
|
204
240
|
}
|
|
205
241
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
242
|
+
await this.deps.sendNode((0, global_1.buildAckNode)({
|
|
243
|
+
kind: 'message',
|
|
244
|
+
node: context.messageNode,
|
|
245
|
+
id: context.stanzaId,
|
|
246
|
+
to: context.from,
|
|
247
|
+
participant: context.participant,
|
|
248
|
+
from: this.deps.getCurrentCredentials()?.meJid ?? undefined,
|
|
249
|
+
error: constants_1.WA_NACK_REASONS.UNHANDLED_ERROR
|
|
250
|
+
}));
|
|
251
|
+
this.deps.logger.trace('acked undecryptable stanza', {
|
|
252
|
+
id: context.stanzaId,
|
|
253
|
+
from: context.from,
|
|
254
|
+
participant: context.participant
|
|
255
|
+
});
|
|
212
256
|
}
|
|
213
|
-
catch {
|
|
214
|
-
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.deps.logger.warn('failed to ack undecryptable stanza', {
|
|
259
|
+
id: context.stanzaId,
|
|
260
|
+
from: context.from,
|
|
261
|
+
participant: context.participant,
|
|
262
|
+
message: (0, primitives_1.toError)(error).message
|
|
263
|
+
});
|
|
215
264
|
}
|
|
216
265
|
}
|
|
217
266
|
async handleParsedRetryRequest(receiptNode, request) {
|
|
@@ -387,14 +436,14 @@ class WaRetryCoordinator {
|
|
|
387
436
|
await this.deps.preKeyStore.markKeyAsUploaded(preKey.keyId);
|
|
388
437
|
const signedIdentity = this.deps.getCurrentCredentials()?.signedIdentity;
|
|
389
438
|
return {
|
|
390
|
-
identity,
|
|
439
|
+
identity: (0, keys_1.toRawPubKey)(identity),
|
|
391
440
|
key: {
|
|
392
441
|
id: preKey.keyId,
|
|
393
|
-
publicKey: preKey.keyPair.pubKey
|
|
442
|
+
publicKey: (0, keys_1.toRawPubKey)(preKey.keyPair.pubKey)
|
|
394
443
|
},
|
|
395
444
|
skey: {
|
|
396
445
|
id: signedPreKey.keyId,
|
|
397
|
-
publicKey: signedPreKey.keyPair.pubKey,
|
|
446
|
+
publicKey: (0, keys_1.toRawPubKey)(signedPreKey.keyPair.pubKey),
|
|
398
447
|
signature: signedPreKey.signature
|
|
399
448
|
},
|
|
400
449
|
deviceIdentity: signedIdentity
|
|
@@ -722,6 +771,9 @@ class WaRetryCoordinator {
|
|
|
722
771
|
if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
|
|
723
772
|
return false;
|
|
724
773
|
}
|
|
774
|
+
if (this.deps.isMobilePrimary?.()) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
725
777
|
const subtype = context.messageNode.attrs.subtype;
|
|
726
778
|
if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
|
|
727
779
|
return false;
|
|
@@ -779,6 +831,7 @@ class WaRetryCoordinator {
|
|
|
779
831
|
}
|
|
780
832
|
}))
|
|
781
833
|
});
|
|
834
|
+
const meJid = this.deps.getCurrentCredentials()?.meJid;
|
|
782
835
|
for (const result of results) {
|
|
783
836
|
const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
|
|
784
837
|
if (!bytes) {
|
|
@@ -786,7 +839,7 @@ class WaRetryCoordinator {
|
|
|
786
839
|
}
|
|
787
840
|
try {
|
|
788
841
|
const recovered = _proto_1.proto.WebMessageInfo.decode(bytes);
|
|
789
|
-
emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered));
|
|
842
|
+
emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered, meJid));
|
|
790
843
|
}
|
|
791
844
|
catch (error) {
|
|
792
845
|
this.deps.logger.warn('placeholder resend: failed to decode WebMessageInfo', {
|
|
@@ -88,12 +88,14 @@ function extractIgnoreKeyContext(node, meJid) {
|
|
|
88
88
|
const me = tryParseJid(meJid);
|
|
89
89
|
const fromCandidates = collectFromCandidates(kind, a);
|
|
90
90
|
const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
|
|
91
|
+
// Device-stripped to match the JID form used by events/keys; a userless
|
|
92
|
+
// server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
|
|
91
93
|
return {
|
|
92
94
|
kind,
|
|
93
|
-
remoteJid: a.from ?? null,
|
|
95
|
+
remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
|
|
94
96
|
fromMe,
|
|
95
97
|
id: a.id,
|
|
96
|
-
participant: a.participant ?? null
|
|
98
|
+
participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
/** Pure matcher. Exported for direct testing without a coordinator. */
|
package/dist/client/types.d.ts
CHANGED
|
@@ -423,27 +423,30 @@ export interface WaIgnoreKey {
|
|
|
423
423
|
* Lib derives `kind` from the stanza tag and resolves `fromMe` by comparing
|
|
424
424
|
* every from-candidate (`from`, `sender_pn`, `sender_lid`) against `meJid`.
|
|
425
425
|
*
|
|
426
|
-
* `remoteJid` and `participant`
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
426
|
+
* `remoteJid` and `participant` are the `from` / `participant` attrs with the
|
|
427
|
+
* `:device` segment stripped (bare `user@server`), matching the JID form used
|
|
428
|
+
* by message events and keys. A value that does not parse as a JID (e.g. a
|
|
429
|
+
* userless server `from` like `s.whatsapp.net`) is passed through unchanged.
|
|
430
|
+
* They do NOT include the descriptor-style
|
|
431
|
+
* alt-attr lookups (`sender_pn` / `sender_lid` / `participant_pn` /
|
|
432
|
+
* `participant_lid`) or PN↔LID normalization, so they stay in whichever
|
|
433
|
+
* addressing mode the stanza arrived in. To match by user identity regardless
|
|
434
|
+
* of addressing mode, use the descriptor form, which handles it.
|
|
432
435
|
*/
|
|
433
436
|
export interface WaIgnoreKeyContext {
|
|
434
437
|
readonly kind: WaIgnoreStanzaKind;
|
|
435
|
-
/**
|
|
438
|
+
/** `from` attr without `:device` (group JID for groups, PN or LID user JID for 1:1). */
|
|
436
439
|
readonly remoteJid: string | null;
|
|
437
440
|
readonly fromMe: boolean;
|
|
438
441
|
readonly id: string | undefined;
|
|
439
|
-
/**
|
|
442
|
+
/** `participant` attr without `:device`; `null` for non-group stanzas. */
|
|
440
443
|
readonly participant: string | null;
|
|
441
444
|
}
|
|
442
445
|
/**
|
|
443
446
|
* Predicate form of {@link WaClient.ignoreKey}. Return `true` to drop the
|
|
444
447
|
* stanza, `false` to let it through. Receives a {@link WaIgnoreKeyContext}
|
|
445
|
-
* with the
|
|
446
|
-
*
|
|
448
|
+
* with the device-stripped `from`/`participant` (see the context's JSDoc for
|
|
449
|
+
* the addressing-mode caveat) plus lib-resolved `kind` and `fromMe`.
|
|
447
450
|
*/
|
|
448
451
|
export type WaIgnoreKeyPredicate = (ctx: WaIgnoreKeyContext) => boolean;
|
|
449
452
|
export interface WaIncomingBaseEvent {
|
|
@@ -35,20 +35,33 @@ export class WaAppStateSyncClient {
|
|
|
35
35
|
this.sendKeyShare = options.sendKeyShare;
|
|
36
36
|
this.triggerSync = options.triggerSync;
|
|
37
37
|
this.crypto = new WaAppStateCrypto(undefined, options.skipMacVerification === true);
|
|
38
|
-
this.mobilePrimary = options.mobilePrimary ?? false;
|
|
38
|
+
this.mobilePrimary = options.mobilePrimary ?? (() => false);
|
|
39
39
|
this.syncContext = null;
|
|
40
40
|
this.syncPromise = null;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* Returns the active app-state sync key, generating and persisting a new
|
|
44
44
|
* one when the store is empty (used during initial setup).
|
|
45
|
+
*
|
|
46
|
+
* The key id mirrors the primary device layout: 2 big-endian bytes of
|
|
47
|
+
* device id followed by a 4 big-endian byte epoch. `keyEpoch` and
|
|
48
|
+
* `pickActiveSyncKey` read that structure, so a shorter id would make the
|
|
49
|
+
* generated key invisible to active-key selection.
|
|
45
50
|
*/
|
|
46
51
|
async ensureInitialSyncKey() {
|
|
47
52
|
const existing = await this.store.getActiveSyncKey();
|
|
48
53
|
if (existing) {
|
|
49
54
|
return existing;
|
|
50
55
|
}
|
|
51
|
-
const
|
|
56
|
+
const deviceId = this.resolveDeviceIndex() ?? 0;
|
|
57
|
+
const epoch = await randomIntAsync(1, 65537);
|
|
58
|
+
const keyIdBytes = new Uint8Array(6);
|
|
59
|
+
keyIdBytes[0] = (deviceId >>> 8) & 0xff;
|
|
60
|
+
keyIdBytes[1] = deviceId & 0xff;
|
|
61
|
+
keyIdBytes[2] = (epoch >>> 24) & 0xff;
|
|
62
|
+
keyIdBytes[3] = (epoch >>> 16) & 0xff;
|
|
63
|
+
keyIdBytes[4] = (epoch >>> 8) & 0xff;
|
|
64
|
+
keyIdBytes[5] = epoch & 0xff;
|
|
52
65
|
const keyData = await randomBytesAsync(32);
|
|
53
66
|
const rawId = await randomIntAsync(0, 4294967295);
|
|
54
67
|
const key = {
|
|
@@ -61,6 +74,7 @@ export class WaAppStateSyncClient {
|
|
|
61
74
|
this.crypto.clearCache();
|
|
62
75
|
this.logger.info('app-state initial sync key generated (mobile primary)', {
|
|
63
76
|
keyId: bytesToHex(keyIdBytes),
|
|
77
|
+
epoch,
|
|
64
78
|
rawId
|
|
65
79
|
});
|
|
66
80
|
return key;
|
|
@@ -386,17 +400,18 @@ export class WaAppStateSyncClient {
|
|
|
386
400
|
async buildCollectionSyncRequest(collection, pendingByCollection, activeSyncKey) {
|
|
387
401
|
const collectionState = await this.getCollectionState(collection);
|
|
388
402
|
const hasPersistedState = collectionState.initialized;
|
|
403
|
+
const requestSnapshot = !this.mobilePrimary() && !hasPersistedState;
|
|
389
404
|
const attrs = {
|
|
390
405
|
name: collection,
|
|
391
406
|
version: String(hasPersistedState ? collectionState.version : APP_STATE_DEFAULT_COLLECTION_VERSION),
|
|
392
|
-
return_snapshot:
|
|
407
|
+
return_snapshot: requestSnapshot ? 'true' : 'false'
|
|
393
408
|
};
|
|
394
409
|
const children = [];
|
|
395
410
|
const pendingMutations = pendingByCollection.get(collection) ?? [];
|
|
396
411
|
let outgoingContext;
|
|
397
412
|
let skippedUpload = false;
|
|
398
413
|
if (pendingMutations.length > 0) {
|
|
399
|
-
if (!hasPersistedState) {
|
|
414
|
+
if (!hasPersistedState && !this.mobilePrimary()) {
|
|
400
415
|
skippedUpload = true;
|
|
401
416
|
this.logger.debug('app-state skipped outgoing patch upload until snapshot bootstrap', {
|
|
402
417
|
collection,
|
|
@@ -435,7 +450,7 @@ export class WaAppStateSyncClient {
|
|
|
435
450
|
content: [
|
|
436
451
|
{
|
|
437
452
|
tag: WA_NODE_TAGS.SYNC,
|
|
438
|
-
attrs: this.mobilePrimary ? { data_namespace: '3' } : {},
|
|
453
|
+
attrs: this.mobilePrimary() ? { data_namespace: '3' } : {},
|
|
439
454
|
content: collectionNodes
|
|
440
455
|
}
|
|
441
456
|
]
|
|
@@ -470,20 +485,19 @@ export class WaAppStateSyncClient {
|
|
|
470
485
|
return this.createCollectionOutcome(collection, payload.state, payload.version);
|
|
471
486
|
}
|
|
472
487
|
const pendingMutationsCount = pendingByCollection.get(collection)?.length ?? 0;
|
|
473
|
-
|
|
474
|
-
payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE
|
|
488
|
+
const isConflict = payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT ||
|
|
489
|
+
payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE;
|
|
490
|
+
if (isConflict) {
|
|
475
491
|
shouldRefetch =
|
|
476
492
|
payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE ||
|
|
477
493
|
pendingMutationsCount > 0;
|
|
478
|
-
return this.createCollectionOutcome(collection, payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT
|
|
479
|
-
? pendingMutationsCount > 0
|
|
480
|
-
? WA_APP_STATE_COLLECTION_STATES.CONFLICT
|
|
481
|
-
: WA_APP_STATE_COLLECTION_STATES.SUCCESS
|
|
482
|
-
: payload.state, payload.version, shouldRefetch);
|
|
483
494
|
}
|
|
484
495
|
try {
|
|
485
496
|
let appliedMutations = [];
|
|
486
|
-
if (payload.snapshotReference) {
|
|
497
|
+
if (payload.snapshotReference && this.mobilePrimary()) {
|
|
498
|
+
collectionLogger.debug('app-state ignoring server snapshot on primary device');
|
|
499
|
+
}
|
|
500
|
+
else if (payload.snapshotReference) {
|
|
487
501
|
const downloader = options.downloadExternalBlob;
|
|
488
502
|
if (!downloader) {
|
|
489
503
|
throw new Error(`snapshot for ${payload.collection} requires external blob downloader`);
|
|
@@ -522,12 +536,19 @@ export class WaAppStateSyncClient {
|
|
|
522
536
|
payload.state === WA_APP_STATE_COLLECTION_STATES.SUCCESS_HAS_MORE ||
|
|
523
537
|
(payload.state === WA_APP_STATE_COLLECTION_STATES.SUCCESS &&
|
|
524
538
|
skippedUploadCollections.has(collection));
|
|
539
|
+
const resolvedState = !isConflict
|
|
540
|
+
? payload.state
|
|
541
|
+
: payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT
|
|
542
|
+
? pendingMutationsCount > 0
|
|
543
|
+
? WA_APP_STATE_COLLECTION_STATES.CONFLICT
|
|
544
|
+
: WA_APP_STATE_COLLECTION_STATES.SUCCESS
|
|
545
|
+
: payload.state;
|
|
525
546
|
collectionLogger.debug('app-state collection processed', {
|
|
526
|
-
state:
|
|
547
|
+
state: resolvedState,
|
|
527
548
|
version: payload.version,
|
|
528
549
|
appliedMutations: appliedMutations.length
|
|
529
550
|
});
|
|
530
|
-
return this.createCollectionOutcome(collection,
|
|
551
|
+
return this.createCollectionOutcome(collection, resolvedState, payload.version, shouldRefetch, collectionStateChanged, appliedMutations);
|
|
531
552
|
}
|
|
532
553
|
catch (error) {
|
|
533
554
|
if (error instanceof WaAppStateMissingKeyError) {
|
|
@@ -30,6 +30,8 @@ export async function loadOrCreateCredentials(args) {
|
|
|
30
30
|
}
|
|
31
31
|
await restoreSignalStore(args.signalStore, args.preKeyStore, existing);
|
|
32
32
|
args.logger.trace('auth credentials restored into signal store');
|
|
33
|
+
// A mobile primary has no self-signed device-identity and no key-index-list:
|
|
34
|
+
// both are companion-only (set at pairing). Do not re-add them here.
|
|
33
35
|
return existing;
|
|
34
36
|
}
|
|
35
37
|
export async function persistCredentials(args, credentials) {
|
|
@@ -129,7 +131,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
129
131
|
noise: {
|
|
130
132
|
clientStaticKeyPair: credentials.noiseKeyPair,
|
|
131
133
|
isRegistered: registered,
|
|
132
|
-
serverStaticKey: credentials.serverStaticKey,
|
|
134
|
+
serverStaticKey: registered ? credentials.serverStaticKey : undefined,
|
|
133
135
|
routingInfo: credentials.routingInfo,
|
|
134
136
|
trustedRootCa: clientOptions.noiseTrustedRootCa,
|
|
135
137
|
verifyCertificateChain: clientOptions.disableNoiseCertificateChainVerification
|
|
@@ -8,6 +8,7 @@ import { ADV_PREFIX_HOSTED_ACCOUNT_SIGNATURE, computeAdvIdentityHmac, generateDe
|
|
|
8
8
|
import { buildAckNode, buildIqResultNode } from '../../transport/node/builders/global.js';
|
|
9
9
|
import { buildCompanionFinishRequestNode, buildCompanionHelloRequestNode, buildGetCountryCodeRequestNode } from '../../transport/node/builders/pairing.js';
|
|
10
10
|
import { decodeNodeContentUtf8OrBytes, findNodeChild, findNodeChildrenByTags, getFirstNodeChild, getNodeChildrenNonEmptyUtf8ByTag, hasNodeChild } from '../../transport/node/helpers.js';
|
|
11
|
+
import { assertIqResult } from '../../transport/node/query.js';
|
|
11
12
|
import { concatBytes, decodeProtoBytes, uint8Equal, uint8TimingSafeEqual } from '../../util/bytes.js';
|
|
12
13
|
export class WaPairingFlow {
|
|
13
14
|
constructor(options) {
|
|
@@ -46,6 +47,7 @@ export class WaPairingFlow {
|
|
|
46
47
|
responseTag: response.tag,
|
|
47
48
|
responseType: response.attrs.type
|
|
48
49
|
});
|
|
50
|
+
assertIqResult(response, 'companion hello');
|
|
49
51
|
const linkCodeNode = findNodeChild(response, WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
|
|
50
52
|
if (!linkCodeNode) {
|
|
51
53
|
throw new Error('companion hello response missing link_code_companion_reg');
|
|
@@ -255,10 +255,28 @@ export class WaClient extends EventEmitter {
|
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
257
|
if (protocolType === proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION) {
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
258
|
+
if (!protocolMessage.historySyncNotification) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const peerRemoteJid = event.key.remoteJid;
|
|
262
|
+
const peerStanzaId = event.key.id;
|
|
263
|
+
const sendHistSyncReceipt = peerRemoteJid && peerStanzaId
|
|
264
|
+
? async () => {
|
|
265
|
+
try {
|
|
266
|
+
await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
|
|
267
|
+
type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
this.logger.warn('failed to send hist_sync receipt', {
|
|
272
|
+
id: peerStanzaId,
|
|
273
|
+
to: peerRemoteJid,
|
|
274
|
+
message: toError(err).message
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
: undefined;
|
|
279
|
+
if (this.options.history?.enabled !== false) {
|
|
262
280
|
await runHistorySyncNotification({
|
|
263
281
|
logger: this.logger,
|
|
264
282
|
mediaTransfer: this.mediaTransfer,
|
|
@@ -266,24 +284,12 @@ export class WaClient extends EventEmitter {
|
|
|
266
284
|
emitEvent: this.emit.bind(this),
|
|
267
285
|
onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
|
|
268
286
|
onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
|
|
269
|
-
onProcessed:
|
|
270
|
-
? async () => {
|
|
271
|
-
try {
|
|
272
|
-
await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
|
|
273
|
-
type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
catch (err) {
|
|
277
|
-
this.logger.warn('failed to send hist_sync receipt', {
|
|
278
|
-
id: peerStanzaId,
|
|
279
|
-
to: peerRemoteJid,
|
|
280
|
-
message: toError(err).message
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
: undefined
|
|
287
|
+
onProcessed: sendHistSyncReceipt
|
|
285
288
|
}, protocolMessage.historySyncNotification);
|
|
286
289
|
}
|
|
290
|
+
else if (sendHistSyncReceipt) {
|
|
291
|
+
await sendHistSyncReceipt();
|
|
292
|
+
}
|
|
287
293
|
return;
|
|
288
294
|
}
|
|
289
295
|
if (SYNC_RELATED_PROTOCOL_TYPES.has(protocolType)) {
|
|
@@ -210,7 +210,7 @@ export function buildWaClientDependencies(input) {
|
|
|
210
210
|
logger,
|
|
211
211
|
defaultTimeoutMs: options.nodeQueryTimeoutMs,
|
|
212
212
|
hostDomain: WA_DEFAULTS.HOST_DOMAIN,
|
|
213
|
-
mobileIqIdFormat:
|
|
213
|
+
mobileIqIdFormat: () => isMobilePrimary()
|
|
214
214
|
});
|
|
215
215
|
const keepAlive = new WaKeepAlive({
|
|
216
216
|
logger,
|
|
@@ -352,6 +352,7 @@ export function buildWaClientDependencies(input) {
|
|
|
352
352
|
}
|
|
353
353
|
});
|
|
354
354
|
const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
|
|
355
|
+
const isMobilePrimary = () => options.mobileTransport !== undefined || Boolean(getCurrentCredentials()?.deviceInfo);
|
|
355
356
|
const groupCoordinator = createGroupCoordinator({
|
|
356
357
|
queryWithContext: runtime.queryWithContext,
|
|
357
358
|
mexSocket: { query: runtime.query }
|
|
@@ -477,7 +478,7 @@ export function buildWaClientDependencies(input) {
|
|
|
477
478
|
additionalAttributes: sendOptions.additionalAttributes
|
|
478
479
|
}),
|
|
479
480
|
getIcdcHashLength: () => abPropsCoordinator.getConfigValue('md_icdc_hash_length'),
|
|
480
|
-
mobileMessageIdFormat:
|
|
481
|
+
mobileMessageIdFormat: isMobilePrimary,
|
|
481
482
|
serverClock
|
|
482
483
|
});
|
|
483
484
|
const presenceCoordinator = createPresenceCoordinator({
|
|
@@ -517,12 +518,15 @@ export function buildWaClientDependencies(input) {
|
|
|
517
518
|
sendNode: runtime.sendNode,
|
|
518
519
|
getCurrentCredentials,
|
|
519
520
|
resolveUserIcdc: (userJid) => messageDispatch.resolveUserIcdc(userJid),
|
|
521
|
+
resolvePrivacyTokenNode: (recipientJid) => trustedContactToken.resolveTokenForMessage(recipientJid),
|
|
522
|
+
// Placeholder resend asks the primary phone (a peer) for the plaintext.
|
|
520
523
|
peerDataOperation,
|
|
521
524
|
emitIncomingMessage: (event) => {
|
|
522
525
|
void runtime
|
|
523
526
|
.handleIncomingMessageEvent(event)
|
|
524
527
|
.catch((err) => runtime.handleError(toError(err)));
|
|
525
|
-
}
|
|
528
|
+
},
|
|
529
|
+
isMobilePrimary
|
|
526
530
|
});
|
|
527
531
|
const botCoordinator = createBotCoordinator({
|
|
528
532
|
logger,
|
|
@@ -546,7 +550,7 @@ export function buildWaClientDependencies(input) {
|
|
|
546
550
|
await messageDispatch.requestAppStateSyncKeys(keyIds);
|
|
547
551
|
},
|
|
548
552
|
skipMacVerification: options.dangerous?.disableAppStateMacVerification,
|
|
549
|
-
mobilePrimary:
|
|
553
|
+
mobilePrimary: isMobilePrimary,
|
|
550
554
|
isOwnAccountDevice: (deviceJid) => {
|
|
551
555
|
const credentials = getCurrentCredentials();
|
|
552
556
|
if (!credentials)
|
|
@@ -560,6 +564,18 @@ export function buildWaClientDependencies(input) {
|
|
|
560
564
|
await runtime.syncAppState();
|
|
561
565
|
}
|
|
562
566
|
});
|
|
567
|
+
// Persists a pushName change and re-broadcasts presence carrying it (how the
|
|
568
|
+
// name reaches peers on primary connections). No-op when unchanged, which
|
|
569
|
+
// also collapses the app-state echo of our own SettingPushName write.
|
|
570
|
+
const applyOwnPushName = async (name) => {
|
|
571
|
+
if (getCurrentCredentials()?.meDisplayName === name) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
await authClient.persistSuccessAttributes({ meDisplayName: name });
|
|
575
|
+
if (connectionManager?.isConnected()) {
|
|
576
|
+
await presenceCoordinator.send();
|
|
577
|
+
}
|
|
578
|
+
};
|
|
563
579
|
const appStateMutations = new WaAppStateMutationCoordinator({
|
|
564
580
|
logger,
|
|
565
581
|
messageStore: sessionStore.messages,
|
|
@@ -570,7 +586,12 @@ export function buildWaClientDependencies(input) {
|
|
|
570
586
|
emitSnapshotMutations: options.chatEvents?.emitSnapshotMutations === true,
|
|
571
587
|
emitMutation: (event) => runtime.emitEvent('mutation', event),
|
|
572
588
|
nctSaltSink: (salt) => trustedContactToken.handleNctSaltSync(salt),
|
|
573
|
-
contactSink: runtime.persistContact
|
|
589
|
+
contactSink: runtime.persistContact,
|
|
590
|
+
pushNameSink: (name) => {
|
|
591
|
+
void applyOwnPushName(name).catch((error) => logger.debug('apply own pushName from app-state sync failed', {
|
|
592
|
+
message: toError(error).message
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
574
595
|
});
|
|
575
596
|
const profileCoordinator = createProfileCoordinator({
|
|
576
597
|
queryWithContext: runtime.queryWithContext,
|
|
@@ -578,6 +599,7 @@ export function buildWaClientDependencies(input) {
|
|
|
578
599
|
mexSocket: { query: runtime.query },
|
|
579
600
|
queryLidsByPhoneJids: (phoneJids) => signalDeviceSync.queryLidsByPhoneJids(phoneJids),
|
|
580
601
|
mutations: appStateMutations,
|
|
602
|
+
applyOwnPushName,
|
|
581
603
|
logger
|
|
582
604
|
});
|
|
583
605
|
const statusCoordinator = createStatusCoordinator({
|
|
@@ -975,7 +997,7 @@ export function buildWaClientDependencies(input) {
|
|
|
975
997
|
abPropsCoordinator,
|
|
976
998
|
markOnlineOnConnect: options.markOnlineOnConnect ?? false
|
|
977
999
|
}),
|
|
978
|
-
mobilePrimary:
|
|
1000
|
+
mobilePrimary: isMobilePrimary,
|
|
979
1001
|
appStateSync
|
|
980
1002
|
});
|
|
981
1003
|
const lowLevelCoordinator = createLowLevelCoordinator({
|
|
@@ -246,6 +246,9 @@ export class WaConnectionManager {
|
|
|
246
246
|
if (!serverStaticKey) {
|
|
247
247
|
this.logger.trace('no server static key available to persist');
|
|
248
248
|
}
|
|
249
|
+
else if (!credentials.meJid) {
|
|
250
|
+
this.logger.trace('skipping server static key persist while unregistered');
|
|
251
|
+
}
|
|
249
252
|
else {
|
|
250
253
|
await this.authClient.persistServerStaticKey(serverStaticKey);
|
|
251
254
|
this.assertLifecycleCurrent(lifecycleGeneration, 'start comms');
|