zapo-js 1.2.1 → 1.4.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 (67) hide show
  1. package/dist/auth/WaAuthClient.d.ts +15 -0
  2. package/dist/auth/WaAuthClient.js +47 -0
  3. package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
  4. package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
  5. package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
  6. package/dist/auth/pairing/pairing-code-crypto.js +3 -2
  7. package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
  8. package/dist/auth/pairing/shortcake-crypto.js +106 -0
  9. package/dist/client/WaClient.d.ts +84 -49
  10. package/dist/client/WaClient.js +70 -50
  11. package/dist/client/WaClientFactory.d.ts +7 -0
  12. package/dist/client/WaClientFactory.js +4 -1
  13. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  14. package/dist/client/plugins/define.d.ts +23 -0
  15. package/dist/client/plugins/define.js +22 -0
  16. package/dist/client/plugins/index.d.ts +4 -0
  17. package/dist/client/plugins/index.js +9 -0
  18. package/dist/client/plugins/install.d.ts +18 -0
  19. package/dist/client/plugins/install.js +83 -0
  20. package/dist/client/plugins/types.d.ts +76 -0
  21. package/dist/client/plugins/types.js +6 -0
  22. package/dist/client/types.d.ts +25 -0
  23. package/dist/esm/auth/WaAuthClient.js +48 -1
  24. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  25. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  26. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  27. package/dist/esm/client/WaClient.js +70 -49
  28. package/dist/esm/client/WaClientFactory.js +4 -1
  29. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  30. package/dist/esm/client/plugins/define.js +19 -0
  31. package/dist/esm/client/plugins/index.js +3 -0
  32. package/dist/esm/client/plugins/install.js +80 -0
  33. package/dist/esm/client/plugins/types.js +3 -0
  34. package/dist/esm/index.js +2 -0
  35. package/dist/esm/infra/log/ConsoleLogger.js +3 -9
  36. package/dist/esm/infra/log/PinoLogger.js +24 -12
  37. package/dist/esm/infra/log/types.js +8 -1
  38. package/dist/esm/protocol/nodes.js +2 -0
  39. package/dist/esm/protocol/notification.js +3 -1
  40. package/dist/esm/retry/replay.js +4 -12
  41. package/dist/esm/transport/index.js +2 -0
  42. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  43. package/dist/esm/transport/noise/WaClientPayload.js +1 -1
  44. package/dist/esm/util/index.js +1 -1
  45. package/dist/index.d.ts +5 -0
  46. package/dist/index.js +7 -2
  47. package/dist/infra/log/ConsoleLogger.d.ts +4 -2
  48. package/dist/infra/log/ConsoleLogger.js +5 -11
  49. package/dist/infra/log/PinoLogger.d.ts +4 -2
  50. package/dist/infra/log/PinoLogger.js +24 -12
  51. package/dist/infra/log/types.d.ts +8 -1
  52. package/dist/infra/log/types.js +9 -1
  53. package/dist/protocol/nodes.d.ts +2 -0
  54. package/dist/protocol/nodes.js +2 -0
  55. package/dist/protocol/notification.d.ts +2 -0
  56. package/dist/protocol/notification.js +3 -1
  57. package/dist/retry/replay.d.ts +1 -0
  58. package/dist/retry/replay.js +4 -12
  59. package/dist/transport/index.d.ts +3 -0
  60. package/dist/transport/index.js +11 -1
  61. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  62. package/dist/transport/node/builders/shortcake.js +57 -0
  63. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  64. package/dist/transport/noise/WaClientPayload.js +1 -0
  65. package/dist/util/index.d.ts +1 -1
  66. package/dist/util/index.js +4 -1
  67. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import type { AppStateCollectionName } from '../appstate/types';
2
2
  import type { DataForKey, WaAppstateActionKey, WaAppstateIndexArgs } from '../appstate-spec';
3
+ import type { WaShortcakeAssertionSigner } from '../auth/pairing/WaShortcakeFlow';
3
4
  import type { WaAuthClientOptions, WaAuthCredentials, WaAuthDangerousOptions, WaAuthSocketOptions } from '../auth/types';
4
5
  import type { WaCallGroupParticipant, WaCallType } from './events/call';
5
6
  import type { IncomingPresenceType, PresenceLastSeen } from './events/presence';
7
+ import type { WaClientPluginDefinition } from './plugins/types';
6
8
  import type { WaMediaProcessor } from '../media/processor';
7
9
  import type { WaLinkPreviewOptions } from '../message/addons/link-preview/types';
8
10
  import type { WaQuoteRef, WaSendContextInfo } from '../message/context-info';
@@ -173,6 +175,11 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
173
175
  * default auto-fetch globally.
174
176
  */
175
177
  readonly linkPreview?: WaLinkPreviewOptions;
178
+ /**
179
+ * Optional client plugins – behavior hooks and/or coordinators exposed at
180
+ * `client[exposeAs]`. See {@link defineWaClientPlugin}.
181
+ */
182
+ readonly plugins?: readonly WaClientPluginDefinition[];
176
183
  /**
177
184
  * Test-only overrides intended for running against a fake server.
178
185
  *
@@ -187,6 +194,14 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
187
194
  * disables a security check the production code path enforces.
188
195
  */
189
196
  readonly dangerous?: WaClientDangerousOptions;
197
+ /**
198
+ * External WebAuthn signer. Configuring it enables handling a server-forced
199
+ * passkey ("Shortcake") prologue – e.g. when the server demands a passkey
200
+ * right after a successful pairing-code `companion_finish`. Called with the
201
+ * server's request options; returns the assertion + credential id. The
202
+ * credential source (real/virtual authenticator) stays outside the library.
203
+ */
204
+ readonly signPasskeyAssertion?: WaShortcakeAssertionSigner;
190
205
  }
191
206
  export interface WaClientDangerousOptions extends WaAuthDangerousOptions {
192
207
  /**
@@ -1065,6 +1080,16 @@ export interface WaClientEventMap {
1065
1080
  readonly auth_paired: (event: {
1066
1081
  readonly credentials: WaAuthCredentials;
1067
1082
  }) => void;
1083
+ /**
1084
+ * The server is forcing a passkey (Shortcake) to link this device – emitted
1085
+ * when the server pushes the prologue request. `hasSigner` is `true` when a
1086
+ * `signPasskeyAssertion` is configured (the handshake proceeds) and `false`
1087
+ * when none is set (the request is acked but the link cannot complete
1088
+ * headless, so the user should be told a passkey is required).
1089
+ */
1090
+ readonly auth_passkey_required: (event: {
1091
+ readonly hasSigner: boolean;
1092
+ }) => void;
1068
1093
  /**
1069
1094
  * Connection-state transitions: `'open'` (handshake + auth complete),
1070
1095
  * `'connecting'`, or `'close'` (with a `reason` and optional `code`). The
@@ -1,7 +1,10 @@
1
1
  import { buildCommsConfig, loadOrCreateCredentials, persistCredentials } from './credentials-flow.js';
2
2
  import { WaPairingFlow } from './pairing/WaPairingFlow.js';
3
3
  import { WaQrFlow } from './pairing/WaQrFlow.js';
4
- import { getWaCompanionPlatformId, WA_DEFAULTS } from '../protocol/constants.js';
4
+ import { WaShortcakeFlow } from './pairing/WaShortcakeFlow.js';
5
+ import { getWaCompanionPlatformId, WA_DEFAULTS, WA_NOTIFICATION_TYPES } from '../protocol/constants.js';
6
+ import { buildAckNode } from '../transport/node/builders/global.js';
7
+ import { resolveDevicePropsPlatformType } from '../transport/noise/WaClientPayload.js';
5
8
  import { uint8Equal } from '../util/bytes.js';
6
9
  import { toError } from '../util/primitives.js';
7
10
  import { getRuntimeOsDisplayName } from '../util/runtime.js';
@@ -59,6 +62,23 @@ export class WaAuthClient {
59
62
  },
60
63
  dangerous: options.dangerous
61
64
  });
65
+ this.socket = deps.socket;
66
+ const signAssertion = deps.callbacks?.signPasskeyAssertion;
67
+ this.shortcakeFlow = signAssertion
68
+ ? new WaShortcakeFlow({
69
+ logger: this.logger,
70
+ socket: {
71
+ sendNode: deps.socket.sendNode,
72
+ query: (node, timeoutMs) => deps.socket.query(node, timeoutMs)
73
+ },
74
+ signAssertion,
75
+ auth: {
76
+ getCredentials: () => this.credentials,
77
+ updateCredentials: this.updateCredentials.bind(this)
78
+ },
79
+ deviceType: resolveDevicePropsPlatformType(deviceBrowser)
80
+ })
81
+ : null;
62
82
  }
63
83
  /**
64
84
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -131,6 +151,7 @@ export class WaAuthClient {
131
151
  this.logger.trace('auth client clear transient state');
132
152
  this.qrFlow.clear();
133
153
  this.pairingFlow.clearSession();
154
+ this.shortcakeFlow?.clearSession();
134
155
  }
135
156
  /**
136
157
  * Wipes credentials from memory **and** from the auth store. Does
@@ -289,9 +310,35 @@ export class WaAuthClient {
289
310
  }
290
311
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
291
312
  async handleLinkCodeNotification(node) {
313
+ const type = node.attrs.type;
314
+ if (type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST ||
315
+ type === WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
316
+ return this.runHandled(() => this.handleShortcakeNotification(node));
317
+ }
292
318
  this.logger.trace('auth client handleLinkCodeNotification', { id: node.attrs.id });
293
319
  return this.runHandled(() => this.pairingFlow.handleLinkCodeNotification(node));
294
320
  }
321
+ /**
322
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
323
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
324
+ * and warn instead of silently stalling – which is what happens today when
325
+ * the server demands a passkey after a successful pairing-code
326
+ * `companion_finish`.
327
+ */
328
+ async handleShortcakeNotification(node) {
329
+ if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
330
+ this.callbacks.onPasskeyRequired?.(this.shortcakeFlow !== null);
331
+ }
332
+ if (this.shortcakeFlow) {
333
+ return this.shortcakeFlow.handleIncomingNotification(node);
334
+ }
335
+ if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
336
+ this.logger.warn('auth client: server requested passkey prologue but no signPasskeyAssertion configured', { id: node.attrs.id });
337
+ await this.socket.sendNode(buildAckNode({ kind: 'notification', node }));
338
+ return true;
339
+ }
340
+ return false;
341
+ }
295
342
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
296
343
  async handleCompanionRegRefreshNotification(node) {
297
344
  this.logger.trace('auth client handleCompanionRegRefreshNotification', {
@@ -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
+ }
@@ -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
+ }
@@ -3,6 +3,7 @@ import { createIgnoreKeyFilter, validateIgnoreKey } from './messaging/ignore-key
3
3
  import { runHistorySyncNotification } from './persistence/history-sync.js';
4
4
  import { persistIncomingMailboxEntities } from './persistence/mailbox.js';
5
5
  import { WriteBehindPersistence } from './persistence/WriteBehindPersistence.js';
6
+ import { installWaClientPlugins } from './plugins/install.js';
6
7
  import { buildWaClientDependencies, resolveWaClientBase } from './WaClientFactory.js';
7
8
  import { ConsoleLogger } from '../infra/log/ConsoleLogger.js';
8
9
  import { proto } from '../proto.js';
@@ -19,55 +20,8 @@ const SYNC_RELATED_PROTOCOL_TYPES = new Set([
19
20
  proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_MESSAGE,
20
21
  proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE
21
22
  ]);
22
- /**
23
- * Top-level WhatsApp client. Owns the transport, auth, signal, and per-feature
24
- * coordinators (accessible via getters such as {@link message}, {@link group},
25
- * {@link newsletter}, etc.) and re-emits every {@link WaClientEventMap} event.
26
- *
27
- * Lifecycle: construct with {@link WaClientOptions}, call {@link connect} to
28
- * open the socket, react to `connection`/`auth_qr`/`auth_pairing_code` events,
29
- * then use the coordinator getters to drive the session. Call {@link disconnect}
30
- * to shut down cleanly or {@link logout} to remove the companion device.
31
- *
32
- * @example
33
- * ```ts
34
- * import { createPinoLogger, createStore, WaClient } from '..'
35
- * import { createSqliteStore } from '@zapo-js/store-sqlite'
36
- *
37
- * const store = createStore({
38
- * backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite' }) },
39
- * providers: {
40
- * auth: 'sqlite',
41
- * signal: 'sqlite',
42
- * preKey: 'sqlite',
43
- * session: 'sqlite',
44
- * identity: 'sqlite',
45
- * senderKey: 'sqlite',
46
- * appState: 'sqlite',
47
- * privacyToken: 'sqlite',
48
- * messages: 'sqlite',
49
- * threads: 'sqlite',
50
- * contacts: 'sqlite'
51
- * }
52
- * })
53
- *
54
- * const client = new WaClient(
55
- * { store, sessionId: 'default' },
56
- * await createPinoLogger({ level: 'info', pretty: true })
57
- * )
58
- *
59
- * client.on('auth_qr', ({ qr, ttlMs }) => console.log('scan:', qr, ttlMs))
60
- * client.on('connection', (event) => console.log('connection', event))
61
- * client.on('message', async (event) => {
62
- * if (event.message?.conversation === 'ping') {
63
- * await client.message.send(event.chatJid!, 'pong')
64
- * }
65
- * })
66
- *
67
- * await client.connect()
68
- * ```
69
- */
70
- export class WaClient extends EventEmitter {
23
+ /** @internal Implementation backing the exported {@link WaClient}. */
24
+ class WaClientImpl extends EventEmitter {
71
25
  /**
72
26
  * @param options Client configuration (store, transport, addons, history...).
73
27
  * @param logger Optional structured logger. Defaults to a `ConsoleLogger('info')`.
@@ -78,6 +32,7 @@ export class WaClient extends EventEmitter {
78
32
  this.acceptingIncomingEvents = true;
79
33
  this.activeIncomingHandlers = 0;
80
34
  this.incomingHandlersDrainedWaiters = [];
35
+ this.disposePlugins = null;
81
36
  const base = resolveWaClientBase(options, logger);
82
37
  this.options = base.options;
83
38
  this.logger = base.logger;
@@ -115,6 +70,13 @@ export class WaClient extends EventEmitter {
115
70
  this.deps = dependencies;
116
71
  this.appStateSync = dependencies.appStateSync;
117
72
  this.mediaTransfer = dependencies.mediaTransfer;
73
+ this.disposePlugins = installWaClientPlugins(this, {
74
+ options: this.options,
75
+ logger: this.logger,
76
+ stores: this.stores,
77
+ deps: this.deps,
78
+ queryWithContext: this.queryWithContext.bind(this)
79
+ }, this.options.plugins ?? []);
118
80
  this.bindNodeTransportEvents();
119
81
  this.on('connection', (event) => {
120
82
  if (event.status !== 'close')
@@ -381,6 +343,11 @@ export class WaClient extends EventEmitter {
381
343
  remaining: writeBehindFlush.remaining
382
344
  });
383
345
  }
346
+ if (this.disposePlugins) {
347
+ const dispose = this.disposePlugins;
348
+ this.disposePlugins = null;
349
+ await dispose();
350
+ }
384
351
  await this.deps.connectionManager.disconnect();
385
352
  this.emit('connection', {
386
353
  status: 'close',
@@ -603,3 +570,57 @@ export class WaClient extends EventEmitter {
603
570
  this.emit('debug_client_error', { error });
604
571
  }
605
572
  }
573
+ /**
574
+ * Top-level WhatsApp client. Owns the transport, auth, signal, and per-feature
575
+ * coordinators (accessible via getters such as {@link message}, {@link group},
576
+ * {@link newsletter}, etc.) and re-emits every {@link WaClientEventMap} event.
577
+ *
578
+ * Lifecycle: construct with {@link WaClientOptions}, call {@link connect} to
579
+ * open the socket, react to `connection`/`auth_qr`/`auth_pairing_code` events,
580
+ * then use the coordinator getters to drive the session. Call {@link disconnect}
581
+ * to shut down cleanly or {@link logout} to remove the companion device.
582
+ *
583
+ * Pass `plugins` to expose plugin getters and events on the returned client,
584
+ * typed from the values you pass: e.g. `plugins: [voipPlugin()]` adds
585
+ * `client.voip` and the `voip_*` events, and only then. See
586
+ * {@link WaClientConstructor}.
587
+ *
588
+ * @example
589
+ * ```ts
590
+ * import { createPinoLogger, createStore, WaClient } from '..'
591
+ * import { createSqliteStore } from '@zapo-js/store-sqlite'
592
+ *
593
+ * const store = createStore({
594
+ * backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite' }) },
595
+ * providers: {
596
+ * auth: 'sqlite',
597
+ * signal: 'sqlite',
598
+ * preKey: 'sqlite',
599
+ * session: 'sqlite',
600
+ * identity: 'sqlite',
601
+ * senderKey: 'sqlite',
602
+ * appState: 'sqlite',
603
+ * privacyToken: 'sqlite',
604
+ * messages: 'sqlite',
605
+ * threads: 'sqlite',
606
+ * contacts: 'sqlite'
607
+ * }
608
+ * })
609
+ *
610
+ * const client = new WaClient(
611
+ * { store, sessionId: 'default' },
612
+ * await createPinoLogger({ level: 'info', pretty: true })
613
+ * )
614
+ *
615
+ * client.on('auth_qr', ({ qr, ttlMs }) => console.log('scan:', qr, ttlMs))
616
+ * client.on('connection', (event) => console.log('connection', event))
617
+ * client.on('message', async (event) => {
618
+ * if (event.message?.conversation === 'ping') {
619
+ * await client.message.send(event.chatJid!, 'pong')
620
+ * }
621
+ * })
622
+ *
623
+ * await client.connect()
624
+ * ```
625
+ */
626
+ export const WaClient = WaClientImpl;
@@ -348,7 +348,9 @@ export function buildWaClientDependencies(input) {
348
348
  runtime.emitEvent('auth_paired', { credentials });
349
349
  scheduleReconnectAfterPairing();
350
350
  },
351
- onError: (error) => runtime.handleError(error)
351
+ onError: (error) => runtime.handleError(error),
352
+ onPasskeyRequired: (hasSigner) => runtime.emitEvent('auth_passkey_required', { hasSigner }),
353
+ signPasskeyAssertion: options.signPasskeyAssertion
352
354
  }
353
355
  });
354
356
  const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
@@ -1026,6 +1028,7 @@ export function buildWaClientDependencies(input) {
1026
1028
  signalMissingPreKeysSync,
1027
1029
  signalRotateKey,
1028
1030
  signalSessionSync,
1031
+ sessionResolver,
1029
1032
  authClient,
1030
1033
  messageDispatch,
1031
1034
  messageCoordinator,
@@ -61,6 +61,7 @@ export class WaRetryCoordinator {
61
61
  signalProtocol: options.signalProtocol,
62
62
  sessionResolver: options.sessionResolver,
63
63
  getCurrentCredentials: options.getCurrentCredentials,
64
+ isMobilePrimary: options.isMobilePrimary,
64
65
  resolveUserIcdc: options.resolveUserIcdc,
65
66
  resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
66
67
  });
@@ -0,0 +1,19 @@
1
+ export function defineWaClientPlugin(input) {
2
+ if ('exposeAs' in input && input.exposeAs !== undefined) {
3
+ const exposeInput = input;
4
+ return {
5
+ id: exposeInput.id,
6
+ exposeAs: exposeInput.exposeAs,
7
+ setup: exposeInput.setup,
8
+ dispose: exposeInput.dispose
9
+ ? (instance, ctx) => exposeInput.dispose(instance, ctx)
10
+ : undefined
11
+ };
12
+ }
13
+ const behaviorInput = input;
14
+ return {
15
+ id: behaviorInput.id,
16
+ setup: behaviorInput.setup,
17
+ dispose: behaviorInput.dispose ? (_instance, ctx) => behaviorInput.dispose(ctx) : undefined
18
+ };
19
+ }
@@ -0,0 +1,3 @@
1
+ export { defineWaClientPlugin } from '../plugins/define.js';
2
+ export { installWaClientPlugins } from '../plugins/install.js';
3
+ export { isWaClientExposePluginDefinition } from '../plugins/types.js';