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
@@ -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: effectiveMobileTransport.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 = await resolveVersion(clientOptions.version);
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 bytesToCrockford(bytes) {
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 ?? bytesToCrockford(codeBytes);
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-web-version-fetcher.js';
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.disposePlugins = installWaClientPlugins(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
- }, this.options.plugins ?? []);
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 Web version is outdated. ' +
89
- 'Auto-recovering by fetching the current version from web.whatsapp.com – ' +
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 latest = await fetchLatestWaWebVersion();
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
- this.deps.authClient.setNextConnectVersion(latest.version);
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 `connection` close event
333
- * with reason `client_disconnected`. Does not clear stored credentials -
334
- * call {@link connect} again to resume the same session. There is no
335
- * built-in auto-reconnect; subscribe to `connection: { status: 'close' }`
336
- * and decide your own backoff.
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.