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.
- package/dist/appstate/types.d.ts +1 -2
- package/dist/auth/WaAuthClient.d.ts +25 -0
- package/dist/auth/WaAuthClient.js +63 -7
- package/dist/auth/credentials-flow.d.ts +8 -0
- package/dist/auth/credentials-flow.js +35 -2
- package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
- package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
- package/dist/auth/pairing/companion-host.d.ts +105 -0
- package/dist/auth/pairing/companion-host.js +148 -0
- package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
- package/dist/auth/pairing/pairing-code-crypto.js +3 -2
- package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
- package/dist/auth/pairing/shortcake-crypto.js +106 -0
- package/dist/auth/types.d.ts +20 -9
- package/dist/client/WaClient.d.ts +15 -5
- package/dist/client/WaClient.js +46 -12
- package/dist/client/WaClientFactory.d.ts +3 -0
- package/dist/client/WaClientFactory.js +27 -7
- package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
- package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
- package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/client/messaging/companion-host.d.ts +43 -0
- package/dist/client/messaging/companion-host.js +61 -0
- package/dist/client/messaging/key-protocol.js +25 -1
- package/dist/client/persistence/companion-host.d.ts +36 -0
- package/dist/client/persistence/companion-host.js +57 -0
- package/dist/client/plugins/install.d.ts +2 -1
- package/dist/client/plugins/install.js +44 -11
- package/dist/client/types.d.ts +90 -7
- package/dist/esm/auth/WaAuthClient.js +64 -8
- package/dist/esm/auth/credentials-flow.js +36 -3
- package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
- package/dist/esm/auth/pairing/companion-host.js +140 -0
- package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
- package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
- package/dist/esm/client/WaClient.js +46 -12
- package/dist/esm/client/WaClientFactory.js +28 -8
- package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
- package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/esm/client/messaging/companion-host.js +56 -0
- package/dist/esm/client/messaging/key-protocol.js +25 -1
- package/dist/esm/client/persistence/companion-host.js +54 -0
- package/dist/esm/client/plugins/install.js +44 -11
- package/dist/esm/index.js +5 -2
- package/dist/esm/message/WaMessageClient.js +3 -2
- package/dist/esm/message/context-info.js +4 -0
- package/dist/esm/protocol/constants.js +4 -3
- package/dist/esm/protocol/device-identity.js +18 -0
- package/dist/esm/protocol/group.js +6 -0
- package/dist/esm/protocol/message.js +12 -0
- package/dist/esm/protocol/nodes.js +17 -1
- package/dist/esm/protocol/notification.js +3 -1
- package/dist/esm/retry/replay.js +7 -15
- package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
- package/dist/esm/signal/attestation/constants.js +2 -0
- package/dist/esm/signal/index.js +1 -0
- package/dist/esm/transport/index.js +1 -1
- package/dist/esm/transport/node/builders/group.js +4 -3
- package/dist/esm/transport/node/builders/mobile.js +113 -0
- package/dist/esm/transport/node/builders/shortcake.js +50 -0
- package/dist/esm/transport/noise/WaClientPayload.js +17 -8
- package/dist/esm/transport/wa-version-fetcher.js +152 -0
- package/dist/esm/util/index.js +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +13 -4
- package/dist/message/WaMessageClient.js +2 -1
- package/dist/message/context-info.d.ts +2 -0
- package/dist/message/context-info.js +5 -0
- package/dist/protocol/constants.d.ts +6 -4
- package/dist/protocol/constants.js +8 -2
- package/dist/protocol/device-identity.d.ts +25 -0
- package/dist/protocol/device-identity.js +21 -0
- package/dist/protocol/group.d.ts +6 -0
- package/dist/protocol/group.js +7 -1
- package/dist/protocol/message.d.ts +14 -0
- package/dist/protocol/message.js +13 -1
- package/dist/protocol/nodes.d.ts +16 -0
- package/dist/protocol/nodes.js +17 -1
- package/dist/protocol/notification.d.ts +2 -0
- package/dist/protocol/notification.js +3 -1
- package/dist/retry/replay.d.ts +1 -0
- package/dist/retry/replay.js +6 -14
- package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
- package/dist/signal/attestation/WaAdvSignature.js +71 -1
- package/dist/signal/attestation/constants.d.ts +2 -0
- package/dist/signal/attestation/constants.js +3 -1
- package/dist/signal/index.d.ts +1 -0
- package/dist/signal/index.js +13 -1
- package/dist/transport/index.d.ts +2 -2
- package/dist/transport/index.js +4 -3
- package/dist/transport/node/builders/group.js +4 -3
- package/dist/transport/node/builders/mobile.d.ts +68 -0
- package/dist/transport/node/builders/mobile.js +120 -0
- package/dist/transport/node/builders/shortcake.d.ts +19 -0
- package/dist/transport/node/builders/shortcake.js +57 -0
- package/dist/transport/noise/WaClientPayload.d.ts +2 -0
- package/dist/transport/noise/WaClientPayload.js +17 -7
- package/dist/transport/wa-version-fetcher.d.ts +92 -0
- package/dist/transport/wa-version-fetcher.js +156 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +3 -1
- package/package.json +1 -1
- package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
- package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
- package/dist/transport/wa-web-version-fetcher.js +0 -94
|
@@ -6,7 +6,7 @@ import { createAndStoreInitialKeys } from '../signal/index.js';
|
|
|
6
6
|
import { WaMobileTcpSocketCtor } from '../transport/node/WaMobileTcpSocket.js';
|
|
7
7
|
import { buildMobileLoginPayload } from '../transport/noise/WaMobileClientPayload.js';
|
|
8
8
|
import { toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
|
|
9
|
-
import { toError } from '../util/primitives.js';
|
|
9
|
+
import { parseOptionalInt, toError } from '../util/primitives.js';
|
|
10
10
|
export async function loadOrCreateCredentials(args) {
|
|
11
11
|
args.logger.trace('auth credentials loadOrCreate start');
|
|
12
12
|
const existing = await args.authStore.load();
|
|
@@ -61,6 +61,26 @@ async function resolveVersion(version) {
|
|
|
61
61
|
}
|
|
62
62
|
return resolved;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Validates the user-supplied `version` string against the transport it will
|
|
66
|
+
* feed. A mobile session sends the version as the four-part Android app
|
|
67
|
+
* version (`2.YY.WW.RR`) in the login payload, so it must have exactly four
|
|
68
|
+
* numeric parts. A web session advertises up to five parts (`2.3000.x[.y.z]`),
|
|
69
|
+
* so three to five numeric parts are accepted.
|
|
70
|
+
*/
|
|
71
|
+
function assertValidVersion(version, mobile) {
|
|
72
|
+
const parts = version.split('.');
|
|
73
|
+
const allNumeric = parts.every((part) => parseOptionalInt(part) !== undefined);
|
|
74
|
+
if (mobile) {
|
|
75
|
+
if (parts.length !== 4 || !allNumeric) {
|
|
76
|
+
throw new Error(`mobile session requires a 4-part numeric version (e.g. 2.26.27.70), got: ${version}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (parts.length < 3 || parts.length > 5 || !allNumeric) {
|
|
81
|
+
throw new Error(`web session requires a 3- to 5-part numeric version (e.g. 2.3000.1040229458), got: ${version}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
64
84
|
export async function buildCommsConfig(logger, credentials, socketOptions, clientOptions) {
|
|
65
85
|
const meJid = credentials.meJid;
|
|
66
86
|
const registered = meJid !== null && meJid !== undefined;
|
|
@@ -81,6 +101,12 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
81
101
|
? 'credentials'
|
|
82
102
|
: 'none'
|
|
83
103
|
});
|
|
104
|
+
const resolvedVersion = effectiveMobileTransport && clientOptions.mobileAppVersionOverride !== undefined
|
|
105
|
+
? undefined
|
|
106
|
+
: await resolveVersion(clientOptions.version);
|
|
107
|
+
if (resolvedVersion !== undefined) {
|
|
108
|
+
assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
|
|
109
|
+
}
|
|
84
110
|
if (effectiveMobileTransport) {
|
|
85
111
|
if (wsProxy) {
|
|
86
112
|
throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
|
|
@@ -88,11 +114,18 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
88
114
|
if (!loginIdentity) {
|
|
89
115
|
throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
|
|
90
116
|
}
|
|
117
|
+
if (clientOptions.mobileAppVersionOverride !== undefined) {
|
|
118
|
+
assertValidVersion(clientOptions.mobileAppVersionOverride, true);
|
|
119
|
+
}
|
|
120
|
+
const appVersionOverride = clientOptions.mobileAppVersionOverride ?? resolvedVersion;
|
|
121
|
+
const deviceInfo = appVersionOverride
|
|
122
|
+
? { ...effectiveMobileTransport.deviceInfo, appVersion: appVersionOverride }
|
|
123
|
+
: effectiveMobileTransport.deviceInfo;
|
|
91
124
|
const loginPayload = buildMobileLoginPayload({
|
|
92
125
|
username: loginIdentity.username,
|
|
93
126
|
device: loginIdentity.device,
|
|
94
127
|
passive: effectiveMobileTransport.passive ?? false,
|
|
95
|
-
deviceInfo
|
|
128
|
+
deviceInfo,
|
|
96
129
|
pushName: effectiveMobileTransport.pushName,
|
|
97
130
|
yearClass: effectiveMobileTransport.yearClass,
|
|
98
131
|
memClass: effectiveMobileTransport.memClass
|
|
@@ -117,7 +150,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
117
150
|
}
|
|
118
151
|
};
|
|
119
152
|
}
|
|
120
|
-
const versionBase =
|
|
153
|
+
const versionBase = resolvedVersion;
|
|
121
154
|
return {
|
|
122
155
|
url: socketOptions.url,
|
|
123
156
|
urls: socketOptions.urls,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { decodePrimaryEphemeralIdentity, deriveEncryptionKey, deriveVerificationCode, encryptPairingRequest, generateCompanionEphemeralIdentity } from '../pairing/shortcake-crypto.js';
|
|
2
|
+
import { hkdf, hmacSha256Sign, randomBytesAsync } from '../../crypto/index.js';
|
|
3
|
+
import { proto } from '../../proto.js';
|
|
4
|
+
import { WA_DEFAULTS, WA_NODE_TAGS, WA_NOTIFICATION_TYPES } from '../../protocol/constants.js';
|
|
5
|
+
import { buildAckNode } from '../../transport/node/builders/global.js';
|
|
6
|
+
import { buildGetPasskeyRequestOptionsRequestNode, buildSetCompanionNonceRequestNode, buildSetEncryptedPairingRequestRequestNode, buildSetPasskeyPrologueRequestNode, buildShortcakeGetRefRequestNode } from '../../transport/node/builders/shortcake.js';
|
|
7
|
+
import { decodeNodeContentUtf8OrBytes, findNodeChild } from '../../transport/node/helpers.js';
|
|
8
|
+
import { assertIqResult } from '../../transport/node/query.js';
|
|
9
|
+
import { TEXT_DECODER, TEXT_ENCODER } from '../../util/bytes.js';
|
|
10
|
+
/** HKDF info for the pairing handoff HMAC key (derived from the ADV secret). */
|
|
11
|
+
const HANDOFF_KEY_INFO = TEXT_ENCODER.encode('shortcake-passkey-handoff-v1');
|
|
12
|
+
const HANDOFF_KEY_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
const Stage = Object.freeze({
|
|
14
|
+
Idle: 'idle',
|
|
15
|
+
WaitingForPrimaryIdentity: 'waiting_for_primary_identity',
|
|
16
|
+
WaitingForConfirmation: 'waiting_for_confirmation',
|
|
17
|
+
WaitingForPairing: 'waiting_for_pairing'
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Drives the companion side of the WhatsApp "Shortcake" passkey-linking
|
|
21
|
+
* handshake (the `xmlns="md"` IQ exchange + commit/reveal ECDH). It owns the
|
|
22
|
+
* wire protocol and crypto only; the WebAuthn assertion and the registration
|
|
23
|
+
* payload are injected by the caller.
|
|
24
|
+
*/
|
|
25
|
+
export class WaShortcakeFlow {
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.opts = options;
|
|
28
|
+
this.session = null;
|
|
29
|
+
this.handoffKey = null;
|
|
30
|
+
}
|
|
31
|
+
hasSession() {
|
|
32
|
+
return this.session !== null;
|
|
33
|
+
}
|
|
34
|
+
clearSession() {
|
|
35
|
+
this.session = null;
|
|
36
|
+
this.handoffKey = null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Runs the prologue: fetch the request options, obtain the WebAuthn
|
|
40
|
+
* assertion (external), fetch the ref, build the companion ephemeral
|
|
41
|
+
* identity + commitment, and send the `passkey_prologue` IQ.
|
|
42
|
+
*/
|
|
43
|
+
async executePrologue(args = {}) {
|
|
44
|
+
const deviceType = this.opts.deviceType ?? proto.DeviceProps.PlatformType.CHROME;
|
|
45
|
+
const requestOptions = args.requestOptions ?? (await this.requestPasskeyRequestOptions());
|
|
46
|
+
const assertion = await this.opts.signAssertion(requestOptions);
|
|
47
|
+
const ref = await this.requestRef();
|
|
48
|
+
const companion = await generateCompanionEphemeralIdentity({ ref, deviceType });
|
|
49
|
+
let pairingHandoffProof = args.pairingHandoffProof;
|
|
50
|
+
const handoffKey = this.handoffKey;
|
|
51
|
+
this.handoffKey = null;
|
|
52
|
+
if (pairingHandoffProof === undefined &&
|
|
53
|
+
handoffKey !== null &&
|
|
54
|
+
Date.now() - handoffKey.ts < HANDOFF_KEY_TTL_MS) {
|
|
55
|
+
pairingHandoffProof = hmacSha256Sign(handoffKey.hmac, companion.prologuePayloadBytes);
|
|
56
|
+
}
|
|
57
|
+
const skipHandoffUx = pairingHandoffProof !== undefined;
|
|
58
|
+
const response = await this.opts.socket.query(buildSetPasskeyPrologueRequestNode({
|
|
59
|
+
credentialId: assertion.credentialId,
|
|
60
|
+
webauthnAssertion: assertion.webauthnAssertion,
|
|
61
|
+
prologuePayload: companion.prologuePayloadBytes,
|
|
62
|
+
pairingHandoffProof
|
|
63
|
+
}), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
64
|
+
assertIqResult(response, 'shortcake set-passkey-prologue');
|
|
65
|
+
this.session = {
|
|
66
|
+
companion,
|
|
67
|
+
ref,
|
|
68
|
+
deviceType,
|
|
69
|
+
skipHandoffUx,
|
|
70
|
+
stage: Stage.WaitingForPrimaryIdentity,
|
|
71
|
+
encryptionKey: null,
|
|
72
|
+
verificationCode: null
|
|
73
|
+
};
|
|
74
|
+
this.opts.logger.debug('shortcake prologue sent', { ref, skipHandoffUx });
|
|
75
|
+
this.opts.callbacks?.emitPrologueSent?.();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Routes the two server-pushed Shortcake notifications. Returns `true` when
|
|
79
|
+
* the notification was a Shortcake one (and was consumed):
|
|
80
|
+
* - `passkey_prologue_request` kicks the flow off (server forces passkey,
|
|
81
|
+
* often right after a pairing-code `companion_finish`); the WebAuthn
|
|
82
|
+
* options are usually embedded.
|
|
83
|
+
* - `crsc_continuation` carries the primary's ephemeral identity.
|
|
84
|
+
*/
|
|
85
|
+
async handleIncomingNotification(node) {
|
|
86
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
|
|
87
|
+
return this.handlePasskeyPrologueRequest(node);
|
|
88
|
+
}
|
|
89
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
|
|
90
|
+
return this.handlePrimaryEphemeralIdentity(node);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
async handlePasskeyPrologueRequest(node) {
|
|
95
|
+
await this.ackNotification(node);
|
|
96
|
+
await this.stashHandoffKeyAndRotateAdv();
|
|
97
|
+
const optionsNode = findNodeChild(node, WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
|
|
98
|
+
const requestOptions = optionsNode
|
|
99
|
+
? decodeNodeContentUtf8OrBytes(optionsNode.content, 'shortcake.passkey_request_options')
|
|
100
|
+
: undefined;
|
|
101
|
+
this.opts.logger.debug('shortcake prologue requested by server', {
|
|
102
|
+
embeddedOptions: optionsNode !== undefined
|
|
103
|
+
});
|
|
104
|
+
await this.executePrologue({ requestOptions });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Derives a pairing handoff HMAC key from the current ADV secret and rotates
|
|
109
|
+
* the ADV secret (matching the official clients). The derived key signs the
|
|
110
|
+
* next prologue's handoff proof; the rotated secret goes into the eventual
|
|
111
|
+
* `PairingRequest`.
|
|
112
|
+
*/
|
|
113
|
+
async stashHandoffKeyAndRotateAdv() {
|
|
114
|
+
const credentials = this.opts.auth.getCredentials();
|
|
115
|
+
if (!credentials) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.handoffKey = {
|
|
119
|
+
hmac: hkdf(credentials.advSecretKey, null, HANDOFF_KEY_INFO, 32),
|
|
120
|
+
ts: Date.now()
|
|
121
|
+
};
|
|
122
|
+
await this.opts.auth.updateCredentials({
|
|
123
|
+
...credentials,
|
|
124
|
+
advSecretKey: await randomBytesAsync(32)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async handlePrimaryEphemeralIdentity(node) {
|
|
128
|
+
const child = findNodeChild(node, WA_NODE_TAGS.PRIMARY_EPHEMERAL_IDENTITY);
|
|
129
|
+
if (!child) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const session = this.session;
|
|
133
|
+
if (!session || session.stage !== Stage.WaitingForPrimaryIdentity) {
|
|
134
|
+
this.opts.logger.warn('shortcake primary identity ignored: no active prologue');
|
|
135
|
+
await this.ackNotification(node);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
await this.ackNotification(node);
|
|
139
|
+
const primary = decodePrimaryEphemeralIdentity(decodeNodeContentUtf8OrBytes(child.content, 'shortcake.primary_ephemeral_identity'));
|
|
140
|
+
const nonceResponse = await this.opts.socket.query(buildSetCompanionNonceRequestNode(session.companion.companionNonce), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
141
|
+
assertIqResult(nonceResponse, 'shortcake set-companion-nonce');
|
|
142
|
+
const verificationCode = deriveVerificationCode(session.companion.companionNonce, primary);
|
|
143
|
+
const encryptionKey = await deriveEncryptionKey({
|
|
144
|
+
companionPrivKey: session.companion.keyPair.privKey,
|
|
145
|
+
primaryPublicKey: primary.publicKey,
|
|
146
|
+
deviceType: session.deviceType,
|
|
147
|
+
ref: session.ref
|
|
148
|
+
});
|
|
149
|
+
session.encryptionKey = encryptionKey;
|
|
150
|
+
session.verificationCode = verificationCode;
|
|
151
|
+
session.stage = Stage.WaitingForConfirmation;
|
|
152
|
+
this.opts.logger.debug('shortcake verification code ready');
|
|
153
|
+
this.opts.callbacks?.emitVerificationCode?.(verificationCode);
|
|
154
|
+
await this.confirmVerificationCode();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/** Current verification code, once derived. */
|
|
158
|
+
getVerificationCode() {
|
|
159
|
+
return this.session?.verificationCode ?? null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Confirms the verification code: builds + AES-GCM seals the pairing request
|
|
163
|
+
* and sends the `encrypted_pairing_request` IQ.
|
|
164
|
+
*/
|
|
165
|
+
async confirmVerificationCode() {
|
|
166
|
+
const session = this.session;
|
|
167
|
+
if (!session || session.stage !== Stage.WaitingForConfirmation || !session.encryptionKey) {
|
|
168
|
+
throw new Error('shortcake: no verification code awaiting confirmation');
|
|
169
|
+
}
|
|
170
|
+
const credentials = this.opts.auth.getCredentials();
|
|
171
|
+
if (!credentials) {
|
|
172
|
+
throw new Error('shortcake: credentials are not initialized');
|
|
173
|
+
}
|
|
174
|
+
const plaintext = proto.PairingRequest.encode({
|
|
175
|
+
companionPublicKey: credentials.noiseKeyPair.pubKey,
|
|
176
|
+
companionIdentityKey: credentials.registrationInfo.identityKeyPair.pubKey,
|
|
177
|
+
advSecret: credentials.advSecretKey
|
|
178
|
+
}).finish();
|
|
179
|
+
const envelope = await encryptPairingRequest(session.encryptionKey, plaintext);
|
|
180
|
+
const response = await this.opts.socket.query(buildSetEncryptedPairingRequestRequestNode(envelope), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
181
|
+
assertIqResult(response, 'shortcake set-encrypted-pairing-request');
|
|
182
|
+
session.stage = Stage.WaitingForPairing;
|
|
183
|
+
this.opts.logger.debug('shortcake encrypted pairing request sent');
|
|
184
|
+
}
|
|
185
|
+
async requestPasskeyRequestOptions() {
|
|
186
|
+
const response = await this.opts.socket.query(buildGetPasskeyRequestOptionsRequestNode(), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
187
|
+
assertIqResult(response, 'shortcake get-passkey-request-options');
|
|
188
|
+
const optionsNode = findNodeChild(response, WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
|
|
189
|
+
if (!optionsNode) {
|
|
190
|
+
throw new Error('shortcake: get-passkey-request-options response missing options');
|
|
191
|
+
}
|
|
192
|
+
return decodeNodeContentUtf8OrBytes(optionsNode.content, 'shortcake.passkey_request_options');
|
|
193
|
+
}
|
|
194
|
+
async requestRef() {
|
|
195
|
+
const response = await this.opts.socket.query(buildShortcakeGetRefRequestNode(), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
196
|
+
assertIqResult(response, 'shortcake get-ref');
|
|
197
|
+
const refNode = findNodeChild(response, WA_NODE_TAGS.REF);
|
|
198
|
+
if (!refNode) {
|
|
199
|
+
throw new Error('shortcake: get-ref response missing ref');
|
|
200
|
+
}
|
|
201
|
+
return TEXT_DECODER.decode(decodeNodeContentUtf8OrBytes(refNode.content, 'shortcake.ref'));
|
|
202
|
+
}
|
|
203
|
+
async ackNotification(node) {
|
|
204
|
+
await this.opts.socket.sendNode(buildAckNode({ kind: 'notification', node }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { aesCtrDecrypt, aesCtrEncrypt, aesGcmDecrypt, hkdf, pbkdf2Sha256, randomBytesAsync, toRawPubKey, X25519 } from '../../crypto/index.js';
|
|
2
|
+
import { proto } from '../../proto.js';
|
|
3
|
+
import { WA_PAIRING_KDF_INFO } from '../../protocol/index.js';
|
|
4
|
+
import { computeAdvIdentityHmac, generateDeviceIdentityAccountSignature, signKeyIndexList } from '../../signal/index.js';
|
|
5
|
+
import { concatBytes, decodeBase64Url, TEXT_ENCODER, uint8Equal } from '../../util/bytes.js';
|
|
6
|
+
const PEM_HKDF_INFO = TEXT_ENCODER.encode('Canonical Ent Companion Nonce Encrypt');
|
|
7
|
+
const PBKDF2_ITERATIONS = 131072;
|
|
8
|
+
const PAIRING_AES_KEY_BYTES = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Parses a companion pairing QR of the form
|
|
11
|
+
* `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
|
|
12
|
+
* contain commas, so the four trailing fields are taken from the end.
|
|
13
|
+
*
|
|
14
|
+
* @throws when fewer than 5 comma-separated fields are present.
|
|
15
|
+
*/
|
|
16
|
+
export function parseCompanionQr(qr) {
|
|
17
|
+
const parts = qr.split(',');
|
|
18
|
+
if (parts.length < 5) {
|
|
19
|
+
throw new Error(`companion qr must have at least 5 comma-separated parts, got ${parts.length}`);
|
|
20
|
+
}
|
|
21
|
+
const platform = parts[parts.length - 1];
|
|
22
|
+
const advSecretB64 = parts[parts.length - 2];
|
|
23
|
+
const identityPubB64 = parts[parts.length - 3];
|
|
24
|
+
const noisePubB64 = parts[parts.length - 4];
|
|
25
|
+
const ref = parts.slice(0, parts.length - 4).join(',');
|
|
26
|
+
return {
|
|
27
|
+
ref,
|
|
28
|
+
noisePublicKey: decodeBase64Url(noisePubB64, 'companionQr.noisePublicKey'),
|
|
29
|
+
identityPublicKey: decodeBase64Url(identityPubB64, 'companionQr.identityPublicKey'),
|
|
30
|
+
advSecretKey: decodeBase64Url(advSecretB64, 'companionQr.advSecretKey'),
|
|
31
|
+
platform
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
|
|
36
|
+
* set. The zero-valued `accountType` enum is omitted to match the phone's byte
|
|
37
|
+
* layout.
|
|
38
|
+
*/
|
|
39
|
+
export async function buildSignedKeyIndexList(input) {
|
|
40
|
+
const details = proto.ADVKeyIndexList.encode({
|
|
41
|
+
rawId: input.rawId,
|
|
42
|
+
timestamp: input.timestampSeconds,
|
|
43
|
+
currentIndex: input.currentIndex,
|
|
44
|
+
validIndexes: [...input.validIndexes]
|
|
45
|
+
}).finish();
|
|
46
|
+
const accountSignature = await signKeyIndexList(details, input.accountIdentityKeyPair);
|
|
47
|
+
return proto.ADVSignedKeyIndexList.encode({ details, accountSignature }).finish();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Builds the signed device identity and key-index list for a linking companion.
|
|
51
|
+
* The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
|
|
52
|
+
* reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
|
|
53
|
+
* so the signed `details` match what the companion verifies.
|
|
54
|
+
*/
|
|
55
|
+
export async function buildSignedCompanionIdentity(input) {
|
|
56
|
+
const accountSignatureKey = toRawPubKey(input.accountIdentityKeyPair.pubKey);
|
|
57
|
+
const details = proto.ADVDeviceIdentity.encode({
|
|
58
|
+
rawId: input.rawId,
|
|
59
|
+
timestamp: input.timestampSeconds,
|
|
60
|
+
keyIndex: input.keyIndex
|
|
61
|
+
}).finish();
|
|
62
|
+
const accountSignature = await generateDeviceIdentityAccountSignature(details, input.companionIdentityPublicKey, input.accountIdentityKeyPair);
|
|
63
|
+
const signedIdentity = proto.ADVSignedDeviceIdentity.encode({
|
|
64
|
+
details,
|
|
65
|
+
accountSignatureKey,
|
|
66
|
+
accountSignature
|
|
67
|
+
}).finish();
|
|
68
|
+
const hmac = computeAdvIdentityHmac(input.advSecretKey, signedIdentity);
|
|
69
|
+
const deviceIdentityBytes = proto.ADVSignedDeviceIdentityHMAC.encode({
|
|
70
|
+
details: signedIdentity,
|
|
71
|
+
hmac
|
|
72
|
+
}).finish();
|
|
73
|
+
const keyIndexListBytes = await buildSignedKeyIndexList({
|
|
74
|
+
accountIdentityKeyPair: input.accountIdentityKeyPair,
|
|
75
|
+
rawId: input.rawId,
|
|
76
|
+
currentIndex: input.keyIndex,
|
|
77
|
+
timestampSeconds: input.timestampSeconds,
|
|
78
|
+
validIndexes: input.validIndexes
|
|
79
|
+
});
|
|
80
|
+
return { deviceIdentityBytes, keyIndexListBytes };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
|
|
84
|
+
* `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
|
|
85
|
+
* verified against a live server from this implementation; see
|
|
86
|
+
* `CompanionHostOptions.includePem`.
|
|
87
|
+
*/
|
|
88
|
+
export function derivePemKey(advSecretKey, companionNoisePublicKey) {
|
|
89
|
+
return hkdf(advSecretKey, companionNoisePublicKey, PEM_HKDF_INFO, 32);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Link-code handshake, primary step 1: unwrap the companion ephemeral with the
|
|
93
|
+
* pairing code, generate the primary ephemeral, compute the shared ECDH secret,
|
|
94
|
+
* and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
|
|
95
|
+
* companion's `createCompanionHello` + primary-ephemeral unwrap.
|
|
96
|
+
*/
|
|
97
|
+
export async function preparePrimaryHello(args) {
|
|
98
|
+
if (args.wrappedCompanionEphemeralPub.length < 48) {
|
|
99
|
+
throw new Error(`wrapped companion ephemeral pub too short: ${args.wrappedCompanionEphemeralPub.length} bytes (need >= 48)`);
|
|
100
|
+
}
|
|
101
|
+
const codeBytes = TEXT_ENCODER.encode(args.pairingCode);
|
|
102
|
+
const companionCipherKey = await pbkdf2Sha256(codeBytes, args.wrappedCompanionEphemeralPub.subarray(0, 32), PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
103
|
+
const companionEphemeralPub = aesCtrDecrypt(companionCipherKey, args.wrappedCompanionEphemeralPub.subarray(32, 48), args.wrappedCompanionEphemeralPub.subarray(48));
|
|
104
|
+
const [primaryEphemeralKeyPair, salt, counter] = await Promise.all([
|
|
105
|
+
X25519.generateKeyPair(),
|
|
106
|
+
randomBytesAsync(32),
|
|
107
|
+
randomBytesAsync(16)
|
|
108
|
+
]);
|
|
109
|
+
const primaryCipherKey = await pbkdf2Sha256(codeBytes, salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
110
|
+
const encrypted = aesCtrEncrypt(primaryCipherKey, counter, primaryEphemeralKeyPair.pubKey);
|
|
111
|
+
const sharedEphemeral = await X25519.scalarMult(primaryEphemeralKeyPair.privKey, companionEphemeralPub);
|
|
112
|
+
return {
|
|
113
|
+
primaryEphemeralKeyPair,
|
|
114
|
+
wrappedPrimaryEphemeralPub: concatBytes([salt, counter, encrypted]),
|
|
115
|
+
sharedEphemeral
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
|
|
120
|
+
* and derive the shared `advSecret` (byte-identical to what the companion
|
|
121
|
+
* derives). Validates that the bundle binds both identities.
|
|
122
|
+
*
|
|
123
|
+
* @throws when the bundle's embedded identities do not match.
|
|
124
|
+
*/
|
|
125
|
+
export async function completePrimaryHandshake(args) {
|
|
126
|
+
const bundleEncryptionKey = hkdf(args.sharedEphemeral, args.wrappedKeyBundle.subarray(0, 32), WA_PAIRING_KDF_INFO.LINK_CODE_BUNDLE, 32);
|
|
127
|
+
const plaintextBundle = aesGcmDecrypt(bundleEncryptionKey, args.wrappedKeyBundle.subarray(32, 44), args.wrappedKeyBundle.subarray(44));
|
|
128
|
+
const bundleCompanionIdentity = plaintextBundle.subarray(0, 32);
|
|
129
|
+
const bundlePrimaryIdentity = plaintextBundle.subarray(32, 64);
|
|
130
|
+
const bundleSecret = plaintextBundle.subarray(64);
|
|
131
|
+
const rawCompanionIdentity = toRawPubKey(args.companionIdentityPub);
|
|
132
|
+
if (!uint8Equal(bundlePrimaryIdentity, toRawPubKey(args.primaryIdentityKeyPair.pubKey))) {
|
|
133
|
+
throw new Error('link-code bundle primary identity mismatch');
|
|
134
|
+
}
|
|
135
|
+
if (!uint8Equal(bundleCompanionIdentity, rawCompanionIdentity)) {
|
|
136
|
+
throw new Error('link-code bundle companion identity mismatch');
|
|
137
|
+
}
|
|
138
|
+
const sharedIdentity = await X25519.scalarMult(args.primaryIdentityKeyPair.privKey, rawCompanionIdentity);
|
|
139
|
+
return hkdf(concatBytes([args.sharedEphemeral, sharedIdentity, bundleSecret]), null, WA_PAIRING_KDF_INFO.ADV_SECRET, 32);
|
|
140
|
+
}
|
|
@@ -5,7 +5,7 @@ import { concatBytes, TEXT_ENCODER } from '../../util/bytes.js';
|
|
|
5
5
|
const CROCKFORD_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';
|
|
6
6
|
export const PBKDF2_ITERATIONS = 2 << 16;
|
|
7
7
|
const PAIRING_AES_KEY_BYTES = 32;
|
|
8
|
-
function
|
|
8
|
+
export function encodeBytesToCrockford(bytes) {
|
|
9
9
|
let bitCount = 0;
|
|
10
10
|
let value = 0;
|
|
11
11
|
let out = '';
|
|
@@ -42,7 +42,7 @@ export async function createCompanionHello(options = {}) {
|
|
|
42
42
|
randomBytesAsync(16),
|
|
43
43
|
normalizedCustomCode !== null ? Promise.resolve(null) : randomBytesAsync(5)
|
|
44
44
|
]);
|
|
45
|
-
const pairingCode = normalizedCustomCode ??
|
|
45
|
+
const pairingCode = normalizedCustomCode ?? encodeBytesToCrockford(codeBytes);
|
|
46
46
|
const cipherKey = await pbkdf2Sha256(TEXT_ENCODER.encode(pairingCode), salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
47
47
|
const encrypted = aesCtrEncrypt(cipherKey, counter, companionEphemeralKeyPair.pubKey);
|
|
48
48
|
return {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { encodeBytesToCrockford } from '../pairing/pairing-code-crypto.js';
|
|
2
|
+
import { aesGcmEncrypt, hkdf, randomBytesAsync, sha256 } from '../../crypto/index.js';
|
|
3
|
+
import { X25519 } from '../../crypto/curves/X25519.js';
|
|
4
|
+
import { proto } from '../../proto.js';
|
|
5
|
+
import { concatBytes, TEXT_ENCODER } from '../../util/bytes.js';
|
|
6
|
+
/**
|
|
7
|
+
* Cryptographic core of the WhatsApp "Shortcake" companion-linking protocol
|
|
8
|
+
* (the device-link flow the official clients call CRSC / Shortcake).
|
|
9
|
+
*
|
|
10
|
+
* This module implements ONLY the wire/crypto half: ephemeral X25519 key
|
|
11
|
+
* exchange, the commit-reveal nonce, the verification code, and the AES-GCM
|
|
12
|
+
* pairing envelope. The WebAuthn/passkey assertion that gates the prologue is
|
|
13
|
+
* NOT produced here – it is an opaque input supplied by the caller (so the
|
|
14
|
+
* credential source stays out of the protocol layer).
|
|
15
|
+
*
|
|
16
|
+
* Mirrors `WAWebShortcakeLinkingAlgorithm` from the web client.
|
|
17
|
+
*/
|
|
18
|
+
const NONCE_BYTES = 32;
|
|
19
|
+
const VERIFICATION_CODE_BYTES = 5;
|
|
20
|
+
const GCM_IV_BYTES = 12;
|
|
21
|
+
const ENCRYPTION_KEY_BYTES = 32;
|
|
22
|
+
const EPHEMERAL_PUBLIC_KEY_BYTES = 32;
|
|
23
|
+
/** HKDF `info` for the pairing-request encryption key. */
|
|
24
|
+
const ENCRYPTION_KEY_INFO = TEXT_ENCODER.encode('Pairing Information Encryption Key');
|
|
25
|
+
/**
|
|
26
|
+
* Generates the companion's ephemeral identity + commitment for a Shortcake
|
|
27
|
+
* prologue. The companion publishes a commitment to its nonce now and reveals
|
|
28
|
+
* the nonce later (after the primary's identity arrives) so the primary cannot
|
|
29
|
+
* grind the verification code.
|
|
30
|
+
*/
|
|
31
|
+
export async function generateCompanionEphemeralIdentity(args) {
|
|
32
|
+
const [keyPair, companionNonce] = await Promise.all([
|
|
33
|
+
X25519.generateKeyPair(),
|
|
34
|
+
randomBytesAsync(NONCE_BYTES)
|
|
35
|
+
]);
|
|
36
|
+
const companionEphemeralIdentityBytes = proto.CompanionEphemeralIdentity.encode({
|
|
37
|
+
publicKey: keyPair.pubKey,
|
|
38
|
+
deviceType: args.deviceType,
|
|
39
|
+
ref: args.ref
|
|
40
|
+
}).finish();
|
|
41
|
+
const commitmentHash = sha256(concatBytes([companionEphemeralIdentityBytes, companionNonce]));
|
|
42
|
+
const prologuePayloadBytes = proto.ProloguePayload.encode({
|
|
43
|
+
companionEphemeralIdentity: companionEphemeralIdentityBytes,
|
|
44
|
+
commitment: { hash: commitmentHash }
|
|
45
|
+
}).finish();
|
|
46
|
+
return {
|
|
47
|
+
keyPair,
|
|
48
|
+
companionNonce,
|
|
49
|
+
companionEphemeralIdentityBytes,
|
|
50
|
+
commitmentHash,
|
|
51
|
+
prologuePayloadBytes
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
|
|
55
|
+
export function decodePrimaryEphemeralIdentity(bytes) {
|
|
56
|
+
const decoded = proto.PrimaryEphemeralIdentity.decode(bytes);
|
|
57
|
+
const publicKey = decoded.publicKey;
|
|
58
|
+
const nonce = decoded.nonce;
|
|
59
|
+
if (!publicKey || publicKey.length !== EPHEMERAL_PUBLIC_KEY_BYTES) {
|
|
60
|
+
throw new Error('shortcake: PrimaryEphemeralIdentity.publicKey must be 32 bytes');
|
|
61
|
+
}
|
|
62
|
+
if (!nonce || nonce.length !== NONCE_BYTES) {
|
|
63
|
+
throw new Error('shortcake: PrimaryEphemeralIdentity.nonce must be 32 bytes');
|
|
64
|
+
}
|
|
65
|
+
return { publicKey, nonce };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Derives the short verification code shown on both devices:
|
|
69
|
+
* `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
|
|
70
|
+
*/
|
|
71
|
+
export function deriveVerificationCode(companionNonce, primary) {
|
|
72
|
+
const digest = sha256(concatBytes([companionNonce, primary.publicKey]));
|
|
73
|
+
const code = new Uint8Array(VERIFICATION_CODE_BYTES);
|
|
74
|
+
for (let i = 0; i < VERIFICATION_CODE_BYTES; i += 1) {
|
|
75
|
+
code[i] = primary.nonce[i] ^ digest[i];
|
|
76
|
+
}
|
|
77
|
+
return encodeBytesToCrockford(code);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Derives the AES-GCM key that protects the pairing request:
|
|
81
|
+
* `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
|
|
82
|
+
*/
|
|
83
|
+
export async function deriveEncryptionKey(args) {
|
|
84
|
+
const shared = await X25519.scalarMult(args.companionPrivKey, args.primaryPublicKey);
|
|
85
|
+
const salt = TEXT_ENCODER.encode(`Companion Pairing ${String(args.deviceType)} with ref ${args.ref}`);
|
|
86
|
+
return hkdf(shared, salt, ENCRYPTION_KEY_INFO, ENCRYPTION_KEY_BYTES);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Encrypts the pairing request plaintext under the derived key and returns the
|
|
90
|
+
* encoded `EncryptedPairingRequest` proto (random 12-byte IV).
|
|
91
|
+
*/
|
|
92
|
+
export async function encryptPairingRequest(encryptionKey, plaintext) {
|
|
93
|
+
if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
|
|
94
|
+
throw new Error('shortcake: encryption key must be 32 bytes');
|
|
95
|
+
}
|
|
96
|
+
const iv = await randomBytesAsync(GCM_IV_BYTES);
|
|
97
|
+
const encryptedPayload = aesGcmEncrypt(encryptionKey, iv, plaintext);
|
|
98
|
+
return proto.EncryptedPairingRequest.encode({ encryptedPayload, iv }).finish();
|
|
99
|
+
}
|
|
@@ -12,7 +12,7 @@ import { normalizeDeviceJid } from '../protocol/jid.js';
|
|
|
12
12
|
import { WA_DISCONNECT_REASONS, WA_LOGOUT_REASONS } from '../protocol/stream.js';
|
|
13
13
|
import { buildRemoveCompanionDeviceIq } from '../transport/node/builders/device.js';
|
|
14
14
|
import { assertIqResult, queryWithContext as queryNodeWithContext } from '../transport/node/query.js';
|
|
15
|
-
import { fetchLatestWaWebVersion } from '../transport/wa-
|
|
15
|
+
import { fetchLatestWaMobileVersion, fetchLatestWaWebVersion } from '../transport/wa-version-fetcher.js';
|
|
16
16
|
import { toError } from '../util/primitives.js';
|
|
17
17
|
const SYNC_RELATED_PROTOCOL_TYPES = new Set([
|
|
18
18
|
proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_REQUEST,
|
|
@@ -70,13 +70,14 @@ class WaClientImpl extends EventEmitter {
|
|
|
70
70
|
this.deps = dependencies;
|
|
71
71
|
this.appStateSync = dependencies.appStateSync;
|
|
72
72
|
this.mediaTransfer = dependencies.mediaTransfer;
|
|
73
|
-
this.
|
|
73
|
+
this.pluginInstallInput = {
|
|
74
74
|
options: this.options,
|
|
75
75
|
logger: this.logger,
|
|
76
76
|
stores: this.stores,
|
|
77
77
|
deps: this.deps,
|
|
78
78
|
queryWithContext: this.queryWithContext.bind(this)
|
|
79
|
-
}
|
|
79
|
+
};
|
|
80
|
+
this.installPlugins();
|
|
80
81
|
this.bindNodeTransportEvents();
|
|
81
82
|
this.on('connection', (event) => {
|
|
82
83
|
if (event.status !== 'close')
|
|
@@ -85,22 +86,43 @@ class WaClientImpl extends EventEmitter {
|
|
|
85
86
|
return;
|
|
86
87
|
if (event.reason !== WA_DISCONNECT_REASONS.FAILURE_CLIENT_TOO_OLD)
|
|
87
88
|
return;
|
|
88
|
-
this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA
|
|
89
|
-
'Auto-recovering by fetching the current version from
|
|
89
|
+
this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA version is outdated. ' +
|
|
90
|
+
'Auto-recovering by fetching the current version from the public source – ' +
|
|
90
91
|
'please upgrade zapo so the shipped default catches up.');
|
|
91
92
|
void this.runClientTooOldRecover();
|
|
92
93
|
});
|
|
94
|
+
this.on('connection', (event) => {
|
|
95
|
+
if (event.status !== 'open')
|
|
96
|
+
return;
|
|
97
|
+
void this.deps.mobileCoordinator.reconcileCompanions().catch((error) => {
|
|
98
|
+
this.logger.debug('companion reconcile on connect failed', {
|
|
99
|
+
message: toError(error).message
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
installPlugins() {
|
|
105
|
+
this.disposePlugins = installWaClientPlugins(this, this.pluginInstallInput, this.options.plugins ?? []);
|
|
93
106
|
}
|
|
94
107
|
async runClientTooOldRecover() {
|
|
95
108
|
try {
|
|
96
109
|
if (this.connectPromise) {
|
|
97
110
|
await this.connectPromise.catch(() => undefined);
|
|
98
111
|
}
|
|
99
|
-
const
|
|
112
|
+
const mobile = this.deps.isMobilePrimary();
|
|
113
|
+
const latest = mobile
|
|
114
|
+
? await fetchLatestWaMobileVersion()
|
|
115
|
+
: await fetchLatestWaWebVersion();
|
|
100
116
|
this.logger.info('client_too_old auto-recover: reconnecting', {
|
|
117
|
+
transport: mobile ? 'mobile' : 'web',
|
|
101
118
|
version: latest.version
|
|
102
119
|
});
|
|
103
|
-
|
|
120
|
+
if (mobile) {
|
|
121
|
+
this.deps.authClient.setNextConnectMobileAppVersion(latest.version);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.deps.authClient.setNextConnectVersion(latest.version);
|
|
125
|
+
}
|
|
104
126
|
await this.connect();
|
|
105
127
|
}
|
|
106
128
|
catch (error) {
|
|
@@ -306,6 +328,9 @@ class WaClientImpl extends EventEmitter {
|
|
|
306
328
|
this.logger.trace('wa client connect already in-flight');
|
|
307
329
|
return this.connectPromise;
|
|
308
330
|
}
|
|
331
|
+
if (!this.disposePlugins) {
|
|
332
|
+
this.installPlugins();
|
|
333
|
+
}
|
|
309
334
|
this.writeBehind.restart();
|
|
310
335
|
this.acceptingIncomingEvents = true;
|
|
311
336
|
this.connectPromise = this.deps.connectionManager
|
|
@@ -329,11 +354,12 @@ class WaClientImpl extends EventEmitter {
|
|
|
329
354
|
}
|
|
330
355
|
/**
|
|
331
356
|
* Closes the transport gracefully: pauses incoming events, flushes the
|
|
332
|
-
* write-behind persistence queue, and emits a
|
|
333
|
-
* with reason `client_disconnected`. Does not
|
|
334
|
-
* call {@link connect} again to resume the same
|
|
335
|
-
*
|
|
336
|
-
*
|
|
357
|
+
* write-behind persistence queue, disposes installed plugins, and emits a
|
|
358
|
+
* `connection` close event with reason `client_disconnected`. Does not
|
|
359
|
+
* clear stored credentials - call {@link connect} again to resume the same
|
|
360
|
+
* session, which reinstalls the plugins. There is no built-in
|
|
361
|
+
* auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
|
|
362
|
+
* your own backoff.
|
|
337
363
|
*/
|
|
338
364
|
async disconnect() {
|
|
339
365
|
await this.pauseIncomingEventsAndWaitDrain();
|
|
@@ -450,6 +476,14 @@ class WaClientImpl extends EventEmitter {
|
|
|
450
476
|
get email() {
|
|
451
477
|
return this.deps.emailCoordinator;
|
|
452
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Mobile coordinator: host and manage companion devices linked to this
|
|
481
|
+
* mobile-primary account (link via QR or pairing code, list, revoke).
|
|
482
|
+
* Requires a registered primary session. See {@link WaMobileCoordinator}.
|
|
483
|
+
*/
|
|
484
|
+
get mobile() {
|
|
485
|
+
return this.deps.mobileCoordinator;
|
|
486
|
+
}
|
|
453
487
|
/**
|
|
454
488
|
* Unpairs this companion device by removing it server-side. Requires an
|
|
455
489
|
* authenticated session; throws when no `meJid` is present.
|