zapo-js 1.4.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 (94) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +10 -0
  3. package/dist/auth/WaAuthClient.js +17 -8
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/companion-host.d.ts +105 -0
  7. package/dist/auth/pairing/companion-host.js +148 -0
  8. package/dist/auth/types.d.ts +20 -9
  9. package/dist/client/WaClient.d.ts +15 -5
  10. package/dist/client/WaClient.js +46 -12
  11. package/dist/client/WaClientFactory.d.ts +3 -0
  12. package/dist/client/WaClientFactory.js +24 -6
  13. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  14. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  15. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  16. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  17. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  18. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  19. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  20. package/dist/client/messaging/companion-host.d.ts +43 -0
  21. package/dist/client/messaging/companion-host.js +61 -0
  22. package/dist/client/messaging/key-protocol.js +25 -1
  23. package/dist/client/persistence/companion-host.d.ts +36 -0
  24. package/dist/client/persistence/companion-host.js +57 -0
  25. package/dist/client/plugins/install.d.ts +2 -1
  26. package/dist/client/plugins/install.js +44 -11
  27. package/dist/client/types.d.ts +71 -7
  28. package/dist/esm/auth/WaAuthClient.js +18 -9
  29. package/dist/esm/auth/credentials-flow.js +36 -3
  30. package/dist/esm/auth/pairing/companion-host.js +140 -0
  31. package/dist/esm/client/WaClient.js +46 -12
  32. package/dist/esm/client/WaClientFactory.js +25 -7
  33. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  34. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  35. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  36. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  37. package/dist/esm/client/messaging/companion-host.js +56 -0
  38. package/dist/esm/client/messaging/key-protocol.js +25 -1
  39. package/dist/esm/client/persistence/companion-host.js +54 -0
  40. package/dist/esm/client/plugins/install.js +44 -11
  41. package/dist/esm/index.js +5 -2
  42. package/dist/esm/message/WaMessageClient.js +3 -2
  43. package/dist/esm/message/context-info.js +4 -0
  44. package/dist/esm/protocol/constants.js +4 -3
  45. package/dist/esm/protocol/device-identity.js +18 -0
  46. package/dist/esm/protocol/group.js +6 -0
  47. package/dist/esm/protocol/message.js +12 -0
  48. package/dist/esm/protocol/nodes.js +15 -1
  49. package/dist/esm/retry/replay.js +3 -3
  50. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  51. package/dist/esm/signal/attestation/constants.js +2 -0
  52. package/dist/esm/signal/index.js +1 -0
  53. package/dist/esm/transport/index.js +1 -1
  54. package/dist/esm/transport/node/builders/group.js +4 -3
  55. package/dist/esm/transport/node/builders/mobile.js +113 -0
  56. package/dist/esm/transport/noise/WaClientPayload.js +16 -7
  57. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  58. package/dist/esm/util/index.js +1 -0
  59. package/dist/index.d.ts +10 -4
  60. package/dist/index.js +13 -4
  61. package/dist/message/WaMessageClient.js +2 -1
  62. package/dist/message/context-info.d.ts +2 -0
  63. package/dist/message/context-info.js +5 -0
  64. package/dist/protocol/constants.d.ts +6 -4
  65. package/dist/protocol/constants.js +8 -2
  66. package/dist/protocol/device-identity.d.ts +25 -0
  67. package/dist/protocol/device-identity.js +21 -0
  68. package/dist/protocol/group.d.ts +6 -0
  69. package/dist/protocol/group.js +7 -1
  70. package/dist/protocol/message.d.ts +14 -0
  71. package/dist/protocol/message.js +13 -1
  72. package/dist/protocol/nodes.d.ts +14 -0
  73. package/dist/protocol/nodes.js +15 -1
  74. package/dist/retry/replay.js +2 -2
  75. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  76. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  77. package/dist/signal/attestation/constants.d.ts +2 -0
  78. package/dist/signal/attestation/constants.js +3 -1
  79. package/dist/signal/index.d.ts +1 -0
  80. package/dist/signal/index.js +13 -1
  81. package/dist/transport/index.d.ts +2 -2
  82. package/dist/transport/index.js +4 -3
  83. package/dist/transport/node/builders/group.js +4 -3
  84. package/dist/transport/node/builders/mobile.d.ts +68 -0
  85. package/dist/transport/node/builders/mobile.js +120 -0
  86. package/dist/transport/noise/WaClientPayload.js +16 -7
  87. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  88. package/dist/transport/wa-version-fetcher.js +156 -0
  89. package/dist/util/index.d.ts +1 -0
  90. package/dist/util/index.js +3 -1
  91. package/package.json +1 -1
  92. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  93. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  94. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -28,14 +28,13 @@ export type WaAppStateMutationInput = {
28
28
  readonly collection: AppStateCollectionName;
29
29
  readonly operation: 'remove';
30
30
  readonly index: string;
31
- readonly previousValue: Proto.ISyncActionValue;
32
31
  readonly version: number;
33
32
  readonly timestamp: number;
34
33
  };
35
34
  export interface WaAppStateMutation {
36
35
  readonly collection: AppStateCollectionName;
37
36
  readonly operation: 'set' | 'remove';
38
- readonly source: 'snapshot' | 'patch';
37
+ readonly source: 'snapshot' | 'patch' | 'local';
39
38
  readonly index: string;
40
39
  readonly value: Proto.ISyncActionValue | null;
41
40
  readonly version: number;
@@ -51,6 +51,7 @@ export declare class WaAuthClient {
51
51
  private readonly shortcakeFlow;
52
52
  private credentials;
53
53
  private versionOverride;
54
+ private mobileAppVersionOverride;
54
55
  constructor(options: WaAuthClientOptions, deps: WaAuthClientDeps);
55
56
  /**
56
57
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -85,6 +86,15 @@ export declare class WaAuthClient {
85
86
  * options.
86
87
  */
87
88
  setNextConnectVersion(version: string): void;
89
+ /**
90
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
91
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
92
+ * its version from `deviceInfo.appVersion` (the login payload), not the
93
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
94
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
95
+ * inject a fresh Android app version without mutating the user's options.
96
+ */
97
+ setNextConnectMobileAppVersion(appVersion: string): void;
88
98
  /** Clears the in-memory QR and pairing sessions without touching storage. */
89
99
  clearTransientState(): Promise<void>;
90
100
  /**
@@ -10,7 +10,6 @@ const global_1 = require("../transport/node/builders/global");
10
10
  const WaClientPayload_1 = require("../transport/noise/WaClientPayload");
11
11
  const bytes_1 = require("../util/bytes");
12
12
  const primitives_1 = require("../util/primitives");
13
- const runtime_1 = require("../util/runtime");
14
13
  /**
15
14
  * Owns the auth/pairing lifecycle and persistence of {@link WaAuthCredentials}.
16
15
  * Exposed as `client.auth` on a {@link WaClient}.
@@ -23,12 +22,8 @@ const runtime_1 = require("../util/runtime");
23
22
  class WaAuthClient {
24
23
  constructor(options, deps) {
25
24
  this.versionOverride = null;
26
- const deviceBrowser = options.deviceBrowser ?? constants_1.WA_DEFAULTS.DEVICE_BROWSER;
27
- const device = Object.freeze({
28
- browser: deviceBrowser,
29
- osDisplayName: options.deviceOsDisplayName ?? (0, runtime_1.getRuntimeOsDisplayName)(),
30
- platform: options.devicePlatform ?? (0, constants_1.getWaCompanionPlatformId)(deviceBrowser)
31
- });
25
+ this.mobileAppVersionOverride = null;
26
+ const device = (0, constants_1.resolveWaDeviceIdentity)(options);
32
27
  this.options = Object.freeze({
33
28
  ...options,
34
29
  deviceBrowser: device.browser,
@@ -79,7 +74,7 @@ class WaAuthClient {
79
74
  getCredentials: () => this.credentials,
80
75
  updateCredentials: this.updateCredentials.bind(this)
81
76
  },
82
- deviceType: (0, WaClientPayload_1.resolveDevicePropsPlatformType)(deviceBrowser)
77
+ deviceType: (0, WaClientPayload_1.resolveDevicePropsPlatformType)(device.browser)
83
78
  })
84
79
  : null;
85
80
  }
@@ -127,12 +122,15 @@ class WaAuthClient {
127
122
  this.logger.trace('auth client building comms config');
128
123
  const override = this.versionOverride;
129
124
  this.versionOverride = null;
125
+ const mobileAppVersionOverride = this.mobileAppVersionOverride;
126
+ this.mobileAppVersionOverride = null;
130
127
  return (0, credentials_flow_1.buildCommsConfig)(this.logger, this.requireCredentials(), socketOptions, {
131
128
  deviceBrowser: this.options.deviceBrowser,
132
129
  deviceOsDisplayName: this.options.deviceOsDisplayName,
133
130
  requireFullSync: this.options.requireFullSync,
134
131
  version: override ?? this.options.version,
135
132
  mobileTransport: this.options.mobileTransport,
133
+ mobileAppVersionOverride: mobileAppVersionOverride ?? undefined,
136
134
  noiseTrustedRootCa: overrides.noiseTrustedRootCa,
137
135
  disableNoiseCertificateChainVerification: overrides.disableNoiseCertificateChainVerification ??
138
136
  this.options.dangerous?.disableNoiseCertificateChainVerification
@@ -148,6 +146,17 @@ class WaAuthClient {
148
146
  setNextConnectVersion(version) {
149
147
  this.versionOverride = version;
150
148
  }
149
+ /**
150
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
151
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
152
+ * its version from `deviceInfo.appVersion` (the login payload), not the
153
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
154
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
155
+ * inject a fresh Android app version without mutating the user's options.
156
+ */
157
+ setNextConnectMobileAppVersion(appVersion) {
158
+ this.mobileAppVersionOverride = appVersion;
159
+ }
151
160
  /** Clears the in-memory QR and pairing sessions without touching storage. */
152
161
  // eslint-disable-next-line @typescript-eslint/require-await
153
162
  async clearTransientState() {
@@ -17,5 +17,13 @@ export declare function persistCredentials(args: WaAuthCredentialsFlowArgs, cred
17
17
  export declare function buildCommsConfig(logger: Logger, credentials: WaAuthCredentials, socketOptions: WaAuthSocketOptions, clientOptions: Pick<WaAuthClientOptions, 'deviceBrowser' | 'deviceOsDisplayName' | 'requireFullSync' | 'version' | 'mobileTransport'> & {
18
18
  readonly noiseTrustedRootCa?: WaNoiseRootCa;
19
19
  readonly disableNoiseCertificateChainVerification?: boolean;
20
+ /**
21
+ * One-shot override for the mobile `deviceInfo.appVersion` on this
22
+ * connect, applied on top of the resolved mobile transport. Used by
23
+ * the `recoverFromClientTooOld` auto-retry to inject a fresh Android
24
+ * app version fetched from the public listing. Ignored for web
25
+ * sessions.
26
+ */
27
+ readonly mobileAppVersionOverride?: string;
20
28
  }): Promise<WaCommsConfig>;
21
29
  export {};
@@ -66,6 +66,26 @@ async function resolveVersion(version) {
66
66
  }
67
67
  return resolved;
68
68
  }
69
+ /**
70
+ * Validates the user-supplied `version` string against the transport it will
71
+ * feed. A mobile session sends the version as the four-part Android app
72
+ * version (`2.YY.WW.RR`) in the login payload, so it must have exactly four
73
+ * numeric parts. A web session advertises up to five parts (`2.3000.x[.y.z]`),
74
+ * so three to five numeric parts are accepted.
75
+ */
76
+ function assertValidVersion(version, mobile) {
77
+ const parts = version.split('.');
78
+ const allNumeric = parts.every((part) => (0, primitives_1.parseOptionalInt)(part) !== undefined);
79
+ if (mobile) {
80
+ if (parts.length !== 4 || !allNumeric) {
81
+ throw new Error(`mobile session requires a 4-part numeric version (e.g. 2.26.27.70), got: ${version}`);
82
+ }
83
+ return;
84
+ }
85
+ if (parts.length < 3 || parts.length > 5 || !allNumeric) {
86
+ throw new Error(`web session requires a 3- to 5-part numeric version (e.g. 2.3000.1040229458), got: ${version}`);
87
+ }
88
+ }
69
89
  async function buildCommsConfig(logger, credentials, socketOptions, clientOptions) {
70
90
  const meJid = credentials.meJid;
71
91
  const registered = meJid !== null && meJid !== undefined;
@@ -86,6 +106,12 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
86
106
  ? 'credentials'
87
107
  : 'none'
88
108
  });
109
+ const resolvedVersion = effectiveMobileTransport && clientOptions.mobileAppVersionOverride !== undefined
110
+ ? undefined
111
+ : await resolveVersion(clientOptions.version);
112
+ if (resolvedVersion !== undefined) {
113
+ assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
114
+ }
89
115
  if (effectiveMobileTransport) {
90
116
  if (wsProxy) {
91
117
  throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
@@ -93,11 +119,18 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
93
119
  if (!loginIdentity) {
94
120
  throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
95
121
  }
122
+ if (clientOptions.mobileAppVersionOverride !== undefined) {
123
+ assertValidVersion(clientOptions.mobileAppVersionOverride, true);
124
+ }
125
+ const appVersionOverride = clientOptions.mobileAppVersionOverride ?? resolvedVersion;
126
+ const deviceInfo = appVersionOverride
127
+ ? { ...effectiveMobileTransport.deviceInfo, appVersion: appVersionOverride }
128
+ : effectiveMobileTransport.deviceInfo;
96
129
  const loginPayload = (0, WaMobileClientPayload_1.buildMobileLoginPayload)({
97
130
  username: loginIdentity.username,
98
131
  device: loginIdentity.device,
99
132
  passive: effectiveMobileTransport.passive ?? false,
100
- deviceInfo: effectiveMobileTransport.deviceInfo,
133
+ deviceInfo,
101
134
  pushName: effectiveMobileTransport.pushName,
102
135
  yearClass: effectiveMobileTransport.yearClass,
103
136
  memClass: effectiveMobileTransport.memClass
@@ -122,7 +155,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
122
155
  }
123
156
  };
124
157
  }
125
- const versionBase = await resolveVersion(clientOptions.version);
158
+ const versionBase = resolvedVersion;
126
159
  return {
127
160
  url: socketOptions.url,
128
161
  urls: socketOptions.urls,
@@ -0,0 +1,105 @@
1
+ import { type SignalKeyPair } from '../../crypto';
2
+ /**
3
+ * `@sensitive` `advSecretKey` is companion secret material; consumers must not
4
+ * persist it without encryption at rest.
5
+ *
6
+ * Fields decoded from a companion pairing QR string. The companion (the device
7
+ * being linked) generates these; the primary consumes them to sign the device
8
+ * identity. `advSecretKey` is the companion's secret, not the primary's.
9
+ */
10
+ export interface ParsedCompanionQr {
11
+ readonly ref: string;
12
+ readonly noisePublicKey: Uint8Array;
13
+ readonly identityPublicKey: Uint8Array;
14
+ readonly advSecretKey: Uint8Array;
15
+ readonly platform: string;
16
+ }
17
+ /**
18
+ * Parses a companion pairing QR of the form
19
+ * `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
20
+ * contain commas, so the four trailing fields are taken from the end.
21
+ *
22
+ * @throws when fewer than 5 comma-separated fields are present.
23
+ */
24
+ export declare function parseCompanionQr(qr: string): ParsedCompanionQr;
25
+ /** `@sensitive` Carries the primary's `accountIdentityKeyPair` private key. */
26
+ export interface BuildSignedKeyIndexListInput {
27
+ readonly accountIdentityKeyPair: SignalKeyPair;
28
+ readonly rawId: number;
29
+ readonly currentIndex: number;
30
+ readonly timestampSeconds: number;
31
+ readonly validIndexes: readonly number[];
32
+ }
33
+ /**
34
+ * Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
35
+ * set. The zero-valued `accountType` enum is omitted to match the phone's byte
36
+ * layout.
37
+ */
38
+ export declare function buildSignedKeyIndexList(input: BuildSignedKeyIndexListInput): Promise<Uint8Array>;
39
+ /**
40
+ * `@sensitive` Carries the primary's `accountIdentityKeyPair` private key and the
41
+ * companion's `advSecretKey`; do not persist unencrypted.
42
+ */
43
+ export interface BuildSignedCompanionIdentityInput {
44
+ readonly accountIdentityKeyPair: SignalKeyPair;
45
+ readonly companionIdentityPublicKey: Uint8Array;
46
+ readonly advSecretKey: Uint8Array;
47
+ readonly rawId: number;
48
+ readonly keyIndex: number;
49
+ readonly timestampSeconds: number;
50
+ readonly validIndexes: readonly number[];
51
+ }
52
+ export interface SignedCompanionIdentity {
53
+ /** `ADVSignedDeviceIdentityHMAC` bytes for the `<device-identity>` node. */
54
+ readonly deviceIdentityBytes: Uint8Array;
55
+ /** `ADVSignedKeyIndexList` bytes for the `<key-index-list>` node. */
56
+ readonly keyIndexListBytes: Uint8Array;
57
+ }
58
+ /**
59
+ * Builds the signed device identity and key-index list for a linking companion.
60
+ * The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
61
+ * reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
62
+ * so the signed `details` match what the companion verifies.
63
+ */
64
+ export declare function buildSignedCompanionIdentity(input: BuildSignedCompanionIdentityInput): Promise<SignedCompanionIdentity>;
65
+ /**
66
+ * Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
67
+ * `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
68
+ * verified against a live server from this implementation; see
69
+ * `CompanionHostOptions.includePem`.
70
+ */
71
+ export declare function derivePemKey(advSecretKey: Uint8Array, companionNoisePublicKey: Uint8Array): Uint8Array;
72
+ /**
73
+ * `@sensitive` Holds the `primaryEphemeralKeyPair` private key and the derived
74
+ * `sharedEphemeral`; keep in memory only, never persist unencrypted.
75
+ */
76
+ export interface LinkCodePrimaryHello {
77
+ readonly primaryEphemeralKeyPair: SignalKeyPair;
78
+ /** `salt(32) || counter(16) || AES-CTR(primaryEphemeralPub)` for `primary_hello`. */
79
+ readonly wrappedPrimaryEphemeralPub: Uint8Array;
80
+ /** ECDH(primaryEphemeralPriv, companionEphemeralPub); stashed for the finish step. */
81
+ readonly sharedEphemeral: Uint8Array;
82
+ }
83
+ /**
84
+ * Link-code handshake, primary step 1: unwrap the companion ephemeral with the
85
+ * pairing code, generate the primary ephemeral, compute the shared ECDH secret,
86
+ * and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
87
+ * companion's `createCompanionHello` + primary-ephemeral unwrap.
88
+ */
89
+ export declare function preparePrimaryHello(args: {
90
+ readonly pairingCode: string;
91
+ readonly wrappedCompanionEphemeralPub: Uint8Array;
92
+ }): Promise<LinkCodePrimaryHello>;
93
+ /**
94
+ * Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
95
+ * and derive the shared `advSecret` (byte-identical to what the companion
96
+ * derives). Validates that the bundle binds both identities.
97
+ *
98
+ * @throws when the bundle's embedded identities do not match.
99
+ */
100
+ export declare function completePrimaryHandshake(args: {
101
+ readonly sharedEphemeral: Uint8Array;
102
+ readonly wrappedKeyBundle: Uint8Array;
103
+ readonly companionIdentityPub: Uint8Array;
104
+ readonly primaryIdentityKeyPair: SignalKeyPair;
105
+ }): Promise<Uint8Array>;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseCompanionQr = parseCompanionQr;
4
+ exports.buildSignedKeyIndexList = buildSignedKeyIndexList;
5
+ exports.buildSignedCompanionIdentity = buildSignedCompanionIdentity;
6
+ exports.derivePemKey = derivePemKey;
7
+ exports.preparePrimaryHello = preparePrimaryHello;
8
+ exports.completePrimaryHandshake = completePrimaryHandshake;
9
+ const _crypto_1 = require("../../crypto");
10
+ const _proto_1 = require("../../proto");
11
+ const _protocol_1 = require("../../protocol");
12
+ const _signal_1 = require("../../signal");
13
+ const bytes_1 = require("../../util/bytes");
14
+ const PEM_HKDF_INFO = bytes_1.TEXT_ENCODER.encode('Canonical Ent Companion Nonce Encrypt');
15
+ const PBKDF2_ITERATIONS = 131072;
16
+ const PAIRING_AES_KEY_BYTES = 32;
17
+ /**
18
+ * Parses a companion pairing QR of the form
19
+ * `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
20
+ * contain commas, so the four trailing fields are taken from the end.
21
+ *
22
+ * @throws when fewer than 5 comma-separated fields are present.
23
+ */
24
+ function parseCompanionQr(qr) {
25
+ const parts = qr.split(',');
26
+ if (parts.length < 5) {
27
+ throw new Error(`companion qr must have at least 5 comma-separated parts, got ${parts.length}`);
28
+ }
29
+ const platform = parts[parts.length - 1];
30
+ const advSecretB64 = parts[parts.length - 2];
31
+ const identityPubB64 = parts[parts.length - 3];
32
+ const noisePubB64 = parts[parts.length - 4];
33
+ const ref = parts.slice(0, parts.length - 4).join(',');
34
+ return {
35
+ ref,
36
+ noisePublicKey: (0, bytes_1.decodeBase64Url)(noisePubB64, 'companionQr.noisePublicKey'),
37
+ identityPublicKey: (0, bytes_1.decodeBase64Url)(identityPubB64, 'companionQr.identityPublicKey'),
38
+ advSecretKey: (0, bytes_1.decodeBase64Url)(advSecretB64, 'companionQr.advSecretKey'),
39
+ platform
40
+ };
41
+ }
42
+ /**
43
+ * Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
44
+ * set. The zero-valued `accountType` enum is omitted to match the phone's byte
45
+ * layout.
46
+ */
47
+ async function buildSignedKeyIndexList(input) {
48
+ const details = _proto_1.proto.ADVKeyIndexList.encode({
49
+ rawId: input.rawId,
50
+ timestamp: input.timestampSeconds,
51
+ currentIndex: input.currentIndex,
52
+ validIndexes: [...input.validIndexes]
53
+ }).finish();
54
+ const accountSignature = await (0, _signal_1.signKeyIndexList)(details, input.accountIdentityKeyPair);
55
+ return _proto_1.proto.ADVSignedKeyIndexList.encode({ details, accountSignature }).finish();
56
+ }
57
+ /**
58
+ * Builds the signed device identity and key-index list for a linking companion.
59
+ * The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
60
+ * reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
61
+ * so the signed `details` match what the companion verifies.
62
+ */
63
+ async function buildSignedCompanionIdentity(input) {
64
+ const accountSignatureKey = (0, _crypto_1.toRawPubKey)(input.accountIdentityKeyPair.pubKey);
65
+ const details = _proto_1.proto.ADVDeviceIdentity.encode({
66
+ rawId: input.rawId,
67
+ timestamp: input.timestampSeconds,
68
+ keyIndex: input.keyIndex
69
+ }).finish();
70
+ const accountSignature = await (0, _signal_1.generateDeviceIdentityAccountSignature)(details, input.companionIdentityPublicKey, input.accountIdentityKeyPair);
71
+ const signedIdentity = _proto_1.proto.ADVSignedDeviceIdentity.encode({
72
+ details,
73
+ accountSignatureKey,
74
+ accountSignature
75
+ }).finish();
76
+ const hmac = (0, _signal_1.computeAdvIdentityHmac)(input.advSecretKey, signedIdentity);
77
+ const deviceIdentityBytes = _proto_1.proto.ADVSignedDeviceIdentityHMAC.encode({
78
+ details: signedIdentity,
79
+ hmac
80
+ }).finish();
81
+ const keyIndexListBytes = await buildSignedKeyIndexList({
82
+ accountIdentityKeyPair: input.accountIdentityKeyPair,
83
+ rawId: input.rawId,
84
+ currentIndex: input.keyIndex,
85
+ timestampSeconds: input.timestampSeconds,
86
+ validIndexes: input.validIndexes
87
+ });
88
+ return { deviceIdentityBytes, keyIndexListBytes };
89
+ }
90
+ /**
91
+ * Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
92
+ * `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
93
+ * verified against a live server from this implementation; see
94
+ * `CompanionHostOptions.includePem`.
95
+ */
96
+ function derivePemKey(advSecretKey, companionNoisePublicKey) {
97
+ return (0, _crypto_1.hkdf)(advSecretKey, companionNoisePublicKey, PEM_HKDF_INFO, 32);
98
+ }
99
+ /**
100
+ * Link-code handshake, primary step 1: unwrap the companion ephemeral with the
101
+ * pairing code, generate the primary ephemeral, compute the shared ECDH secret,
102
+ * and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
103
+ * companion's `createCompanionHello` + primary-ephemeral unwrap.
104
+ */
105
+ async function preparePrimaryHello(args) {
106
+ if (args.wrappedCompanionEphemeralPub.length < 48) {
107
+ throw new Error(`wrapped companion ephemeral pub too short: ${args.wrappedCompanionEphemeralPub.length} bytes (need >= 48)`);
108
+ }
109
+ const codeBytes = bytes_1.TEXT_ENCODER.encode(args.pairingCode);
110
+ const companionCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, args.wrappedCompanionEphemeralPub.subarray(0, 32), PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
111
+ const companionEphemeralPub = (0, _crypto_1.aesCtrDecrypt)(companionCipherKey, args.wrappedCompanionEphemeralPub.subarray(32, 48), args.wrappedCompanionEphemeralPub.subarray(48));
112
+ const [primaryEphemeralKeyPair, salt, counter] = await Promise.all([
113
+ _crypto_1.X25519.generateKeyPair(),
114
+ (0, _crypto_1.randomBytesAsync)(32),
115
+ (0, _crypto_1.randomBytesAsync)(16)
116
+ ]);
117
+ const primaryCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
118
+ const encrypted = (0, _crypto_1.aesCtrEncrypt)(primaryCipherKey, counter, primaryEphemeralKeyPair.pubKey);
119
+ const sharedEphemeral = await _crypto_1.X25519.scalarMult(primaryEphemeralKeyPair.privKey, companionEphemeralPub);
120
+ return {
121
+ primaryEphemeralKeyPair,
122
+ wrappedPrimaryEphemeralPub: (0, bytes_1.concatBytes)([salt, counter, encrypted]),
123
+ sharedEphemeral
124
+ };
125
+ }
126
+ /**
127
+ * Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
128
+ * and derive the shared `advSecret` (byte-identical to what the companion
129
+ * derives). Validates that the bundle binds both identities.
130
+ *
131
+ * @throws when the bundle's embedded identities do not match.
132
+ */
133
+ async function completePrimaryHandshake(args) {
134
+ const bundleEncryptionKey = (0, _crypto_1.hkdf)(args.sharedEphemeral, args.wrappedKeyBundle.subarray(0, 32), _protocol_1.WA_PAIRING_KDF_INFO.LINK_CODE_BUNDLE, 32);
135
+ const plaintextBundle = (0, _crypto_1.aesGcmDecrypt)(bundleEncryptionKey, args.wrappedKeyBundle.subarray(32, 44), args.wrappedKeyBundle.subarray(44));
136
+ const bundleCompanionIdentity = plaintextBundle.subarray(0, 32);
137
+ const bundlePrimaryIdentity = plaintextBundle.subarray(32, 64);
138
+ const bundleSecret = plaintextBundle.subarray(64);
139
+ const rawCompanionIdentity = (0, _crypto_1.toRawPubKey)(args.companionIdentityPub);
140
+ if (!(0, bytes_1.uint8Equal)(bundlePrimaryIdentity, (0, _crypto_1.toRawPubKey)(args.primaryIdentityKeyPair.pubKey))) {
141
+ throw new Error('link-code bundle primary identity mismatch');
142
+ }
143
+ if (!(0, bytes_1.uint8Equal)(bundleCompanionIdentity, rawCompanionIdentity)) {
144
+ throw new Error('link-code bundle companion identity mismatch');
145
+ }
146
+ const sharedIdentity = await _crypto_1.X25519.scalarMult(args.primaryIdentityKeyPair.privKey, rawCompanionIdentity);
147
+ return (0, _crypto_1.hkdf)((0, bytes_1.concatBytes)([args.sharedEphemeral, sharedIdentity, bundleSecret]), null, _protocol_1.WA_PAIRING_KDF_INFO.ADV_SECRET, 32);
148
+ }
@@ -39,10 +39,11 @@ export type WaAuthSocketOptions = Pick<WaCommsConfig, 'url' | 'urls' | 'protocol
39
39
  };
40
40
  };
41
41
  /**
42
- * WhatsApp Web version supplied to {@link WaAuthClientOptions.version}. Either
43
- * a literal `'x.y.z'` string or an async resolver invoked once per connect,
44
- * letting callers refresh the version (e.g. via `fetchLatestWaWebVersion`)
45
- * without rebuilding the client.
42
+ * Version supplied to {@link WaAuthClientOptions.version}. Either a literal
43
+ * dotted-numeric string or an async resolver invoked once per connect,
44
+ * letting callers refresh the version (e.g. via `fetchLatestWaWebVersion` or
45
+ * `fetchLatestWaMobileVersion`) without rebuilding the client. See
46
+ * {@link WaAuthClientOptions.version} for the per-transport part-count rules.
46
47
  */
47
48
  export type WaVersionResolver = string | (() => string | Promise<string>);
48
49
  export interface WaAuthClientOptions {
@@ -70,11 +71,21 @@ export interface WaAuthClientOptions {
70
71
  */
71
72
  readonly requireFullSync?: boolean;
72
73
  /**
73
- * WhatsApp Web version the client advertises. Either a `'x.y.z'` literal
74
- * or a (sync/async) resolver invoked once per connect – use the resolver
75
- * form together with {@link fetchLatestWaWebVersion} to refresh the
76
- * version when the hardcoded default starts hitting `failure_client_too_old`.
77
- * Defaults to a tested production version.
74
+ * Version the client advertises. Either a dotted-numeric literal or a
75
+ * (sync/async) resolver invoked once per connect – use the resolver form
76
+ * together with {@link fetchLatestWaWebVersion} /
77
+ * {@link fetchLatestWaMobileVersion} to refresh the version when the
78
+ * hardcoded default starts hitting `failure_client_too_old`.
79
+ *
80
+ * The accepted shape depends on the transport resolved for the connect:
81
+ * - **Web** sessions take a 3- to 5-part version (`2.3000.x[.y.z]`); all
82
+ * supplied parts are advertised in the noise payload.
83
+ * - **Mobile** sessions take exactly a 4-part Android app version
84
+ * (`2.26.27.70`); it overrides `mobileTransport.deviceInfo.appVersion`
85
+ * in the login payload.
86
+ *
87
+ * An invalid part count for the resolved transport throws on connect.
88
+ * Defaults to a tested production version per transport.
78
89
  */
79
90
  readonly version?: WaVersionResolver;
80
91
  /**
@@ -8,6 +8,7 @@ import type { WaEmailCoordinator } from './coordinators/WaEmailCoordinator';
8
8
  import type { WaGroupCoordinator } from './coordinators/WaGroupCoordinator';
9
9
  import type { WaLowLevelCoordinator } from './coordinators/WaLowLevelCoordinator';
10
10
  import type { WaMessageCoordinator } from './coordinators/WaMessageCoordinator';
11
+ import type { WaMobileCoordinator } from './coordinators/WaMobileCoordinator';
11
12
  import type { WaNewsletterCoordinator } from './coordinators/WaNewsletterCoordinator';
12
13
  import type { WaPresenceCoordinator } from './coordinators/WaPresenceCoordinator';
13
14
  import type { WaPrivacyCoordinator } from './coordinators/WaPrivacyCoordinator';
@@ -31,11 +32,13 @@ declare class WaClientImpl extends EventEmitter {
31
32
  private activeIncomingHandlers;
32
33
  private readonly incomingHandlersDrainedWaiters;
33
34
  private disposePlugins;
35
+ private readonly pluginInstallInput;
34
36
  /**
35
37
  * @param options Client configuration (store, transport, addons, history...).
36
38
  * @param logger Optional structured logger. Defaults to a `ConsoleLogger('info')`.
37
39
  */
38
40
  constructor(options: WaClientOptions, logger?: Logger);
41
+ private installPlugins;
39
42
  private runClientTooOldRecover;
40
43
  /** Strongly-typed `EventEmitter#on` over {@link WaClientEventMap}. */
41
44
  on<K extends keyof WaClientEventMap>(event: K, listener: WaClientEventMap[K]): this;
@@ -100,11 +103,12 @@ declare class WaClientImpl extends EventEmitter {
100
103
  connect(): Promise<void>;
101
104
  /**
102
105
  * Closes the transport gracefully: pauses incoming events, flushes the
103
- * write-behind persistence queue, and emits a `connection` close event
104
- * with reason `client_disconnected`. Does not clear stored credentials -
105
- * call {@link connect} again to resume the same session. There is no
106
- * built-in auto-reconnect; subscribe to `connection: { status: 'close' }`
107
- * and decide your own backoff.
106
+ * write-behind persistence queue, disposes installed plugins, and emits a
107
+ * `connection` close event with reason `client_disconnected`. Does not
108
+ * clear stored credentials - call {@link connect} again to resume the same
109
+ * session, which reinstalls the plugins. There is no built-in
110
+ * auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
111
+ * your own backoff.
108
112
  */
109
113
  disconnect(): Promise<void>;
110
114
  /**
@@ -166,6 +170,12 @@ declare class WaClientImpl extends EventEmitter {
166
170
  * via `options.mobileTransport`. See {@link WaEmailCoordinator}.
167
171
  */
168
172
  get email(): WaEmailCoordinator;
173
+ /**
174
+ * Mobile coordinator: host and manage companion devices linked to this
175
+ * mobile-primary account (link via QR or pairing code, list, revoke).
176
+ * Requires a registered primary session. See {@link WaMobileCoordinator}.
177
+ */
178
+ get mobile(): WaMobileCoordinator;
169
179
  /**
170
180
  * Unpairs this companion device by removing it server-side. Requires an
171
181
  * authenticated session; throws when no `meJid` is present.
@@ -15,7 +15,7 @@ const jid_1 = require("../protocol/jid");
15
15
  const stream_1 = require("../protocol/stream");
16
16
  const device_1 = require("../transport/node/builders/device");
17
17
  const query_1 = require("../transport/node/query");
18
- const wa_web_version_fetcher_1 = require("../transport/wa-web-version-fetcher");
18
+ const wa_version_fetcher_1 = require("../transport/wa-version-fetcher");
19
19
  const primitives_1 = require("../util/primitives");
20
20
  const SYNC_RELATED_PROTOCOL_TYPES = new Set([
21
21
  _proto_1.proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_REQUEST,
@@ -73,13 +73,14 @@ class WaClientImpl extends node_events_1.EventEmitter {
73
73
  this.deps = dependencies;
74
74
  this.appStateSync = dependencies.appStateSync;
75
75
  this.mediaTransfer = dependencies.mediaTransfer;
76
- this.disposePlugins = (0, install_1.installWaClientPlugins)(this, {
76
+ this.pluginInstallInput = {
77
77
  options: this.options,
78
78
  logger: this.logger,
79
79
  stores: this.stores,
80
80
  deps: this.deps,
81
81
  queryWithContext: this.queryWithContext.bind(this)
82
- }, this.options.plugins ?? []);
82
+ };
83
+ this.installPlugins();
83
84
  this.bindNodeTransportEvents();
84
85
  this.on('connection', (event) => {
85
86
  if (event.status !== 'close')
@@ -88,22 +89,43 @@ class WaClientImpl extends node_events_1.EventEmitter {
88
89
  return;
89
90
  if (event.reason !== stream_1.WA_DISCONNECT_REASONS.FAILURE_CLIENT_TOO_OLD)
90
91
  return;
91
- this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA Web version is outdated. ' +
92
- 'Auto-recovering by fetching the current version from web.whatsapp.com – ' +
92
+ this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA version is outdated. ' +
93
+ 'Auto-recovering by fetching the current version from the public source – ' +
93
94
  'please upgrade zapo so the shipped default catches up.');
94
95
  void this.runClientTooOldRecover();
95
96
  });
97
+ this.on('connection', (event) => {
98
+ if (event.status !== 'open')
99
+ return;
100
+ void this.deps.mobileCoordinator.reconcileCompanions().catch((error) => {
101
+ this.logger.debug('companion reconcile on connect failed', {
102
+ message: (0, primitives_1.toError)(error).message
103
+ });
104
+ });
105
+ });
106
+ }
107
+ installPlugins() {
108
+ this.disposePlugins = (0, install_1.installWaClientPlugins)(this, this.pluginInstallInput, this.options.plugins ?? []);
96
109
  }
97
110
  async runClientTooOldRecover() {
98
111
  try {
99
112
  if (this.connectPromise) {
100
113
  await this.connectPromise.catch(() => undefined);
101
114
  }
102
- const latest = await (0, wa_web_version_fetcher_1.fetchLatestWaWebVersion)();
115
+ const mobile = this.deps.isMobilePrimary();
116
+ const latest = mobile
117
+ ? await (0, wa_version_fetcher_1.fetchLatestWaMobileVersion)()
118
+ : await (0, wa_version_fetcher_1.fetchLatestWaWebVersion)();
103
119
  this.logger.info('client_too_old auto-recover: reconnecting', {
120
+ transport: mobile ? 'mobile' : 'web',
104
121
  version: latest.version
105
122
  });
106
- this.deps.authClient.setNextConnectVersion(latest.version);
123
+ if (mobile) {
124
+ this.deps.authClient.setNextConnectMobileAppVersion(latest.version);
125
+ }
126
+ else {
127
+ this.deps.authClient.setNextConnectVersion(latest.version);
128
+ }
107
129
  await this.connect();
108
130
  }
109
131
  catch (error) {
@@ -309,6 +331,9 @@ class WaClientImpl extends node_events_1.EventEmitter {
309
331
  this.logger.trace('wa client connect already in-flight');
310
332
  return this.connectPromise;
311
333
  }
334
+ if (!this.disposePlugins) {
335
+ this.installPlugins();
336
+ }
312
337
  this.writeBehind.restart();
313
338
  this.acceptingIncomingEvents = true;
314
339
  this.connectPromise = this.deps.connectionManager
@@ -332,11 +357,12 @@ class WaClientImpl extends node_events_1.EventEmitter {
332
357
  }
333
358
  /**
334
359
  * Closes the transport gracefully: pauses incoming events, flushes the
335
- * write-behind persistence queue, and emits a `connection` close event
336
- * with reason `client_disconnected`. Does not clear stored credentials -
337
- * call {@link connect} again to resume the same session. There is no
338
- * built-in auto-reconnect; subscribe to `connection: { status: 'close' }`
339
- * and decide your own backoff.
360
+ * write-behind persistence queue, disposes installed plugins, and emits a
361
+ * `connection` close event with reason `client_disconnected`. Does not
362
+ * clear stored credentials - call {@link connect} again to resume the same
363
+ * session, which reinstalls the plugins. There is no built-in
364
+ * auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
365
+ * your own backoff.
340
366
  */
341
367
  async disconnect() {
342
368
  await this.pauseIncomingEventsAndWaitDrain();
@@ -453,6 +479,14 @@ class WaClientImpl extends node_events_1.EventEmitter {
453
479
  get email() {
454
480
  return this.deps.emailCoordinator;
455
481
  }
482
+ /**
483
+ * Mobile coordinator: host and manage companion devices linked to this
484
+ * mobile-primary account (link via QR or pairing code, list, revoke).
485
+ * Requires a registered primary session. See {@link WaMobileCoordinator}.
486
+ */
487
+ get mobile() {
488
+ return this.deps.mobileCoordinator;
489
+ }
456
490
  /**
457
491
  * Unpairs this companion device by removing it server-side. Requires an
458
492
  * authenticated session; throws when no `meJid` is present.