zapo-js 1.3.0 → 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 (34) 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/WaClientFactory.js +3 -1
  10. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  11. package/dist/client/types.d.ts +19 -0
  12. package/dist/esm/auth/WaAuthClient.js +48 -1
  13. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  14. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  15. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  16. package/dist/esm/client/WaClientFactory.js +3 -1
  17. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  18. package/dist/esm/protocol/nodes.js +2 -0
  19. package/dist/esm/protocol/notification.js +3 -1
  20. package/dist/esm/retry/replay.js +4 -12
  21. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  22. package/dist/esm/transport/noise/WaClientPayload.js +1 -1
  23. package/dist/index.d.ts +1 -0
  24. package/dist/protocol/nodes.d.ts +2 -0
  25. package/dist/protocol/nodes.js +2 -0
  26. package/dist/protocol/notification.d.ts +2 -0
  27. package/dist/protocol/notification.js +3 -1
  28. package/dist/retry/replay.d.ts +1 -0
  29. package/dist/retry/replay.js +4 -12
  30. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  31. package/dist/transport/node/builders/shortcake.js +57 -0
  32. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  33. package/dist/transport/noise/WaClientPayload.js +1 -0
  34. package/package.json +1 -1
@@ -1,5 +1,6 @@
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';
@@ -193,6 +194,14 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
193
194
  * disables a security check the production code path enforces.
194
195
  */
195
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;
196
205
  }
197
206
  export interface WaClientDangerousOptions extends WaAuthDangerousOptions {
198
207
  /**
@@ -1071,6 +1080,16 @@ export interface WaClientEventMap {
1071
1080
  readonly auth_paired: (event: {
1072
1081
  readonly credentials: WaAuthCredentials;
1073
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;
1074
1093
  /**
1075
1094
  * Connection-state transitions: `'open'` (handshake + auth complete),
1076
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
+ }
@@ -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);
@@ -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
  });
@@ -50,6 +50,8 @@ export const WA_NODE_TAGS = Object.freeze({
50
50
  COUNTRY_CODE: 'country_code',
51
51
  DEVICE_IDENTITY: 'device-identity',
52
52
  PLATFORM: 'platform',
53
+ PASSKEY_REQUEST_OPTIONS: 'passkey_request_options',
54
+ PRIMARY_EPHEMERAL_IDENTITY: 'primary_ephemeral_identity',
53
55
  SUBJECT: 'subject',
54
56
  BODY: 'body',
55
57
  PARTICIPANT: 'participant',
@@ -6,7 +6,9 @@ export const WA_NOTIFICATION_TYPES = Object.freeze({
6
6
  REGISTRATION: 'registration',
7
7
  NEWSLETTER: 'newsletter',
8
8
  BUSINESS: 'business',
9
- PICTURE: 'picture'
9
+ PICTURE: 'picture',
10
+ PASSKEY_PROLOGUE_REQUEST: 'passkey_prologue_request',
11
+ CRSC_CONTINUATION: 'crsc_continuation'
10
12
  });
11
13
  export const WA_BUSINESS_NOTIFICATION_TAGS = Object.freeze({
12
14
  VERIFIED_NAME: 'verified_name',
@@ -85,9 +85,9 @@ export class WaRetryReplayService {
85
85
  resolveSignedDeviceIdentity(context) {
86
86
  const signedIdentity = this.options.getCurrentCredentials()?.signedIdentity;
87
87
  if (!signedIdentity) {
88
- this.options.logger.warn('retry request missing signed identity for pkmsg envelope', {
89
- context
90
- });
88
+ if (!this.options.isMobilePrimary?.()) {
89
+ this.options.logger.warn('retry request missing signed identity for pkmsg envelope', { context });
90
+ }
91
91
  return undefined;
92
92
  }
93
93
  return proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
@@ -179,15 +179,7 @@ export class WaRetryReplayService {
179
179
  logContext: 'group'
180
180
  })) ?? payload.plaintext;
181
181
  const encrypted = await this.options.signalProtocol.encryptMessage(requesterAddress, plaintext);
182
- let deviceIdentity;
183
- if (encrypted.type === 'pkmsg') {
184
- const signedIdentity = this.options.getCurrentCredentials()?.signedIdentity;
185
- if (!signedIdentity) {
186
- this.options.logger.warn('retry request rejected: missing signed identity for pkmsg group retry');
187
- return 'ineligible';
188
- }
189
- deviceIdentity = proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
190
- }
182
+ const deviceIdentity = encrypted.type === 'pkmsg' ? this.resolveSignedDeviceIdentity('group') : undefined;
191
183
  const isStatus = isStatusBroadcastJid(payload.to);
192
184
  const metaAttrs = {};
193
185
  if (isStatus) {
@@ -0,0 +1,50 @@
1
+ import { WA_DEFAULTS, WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from '../../../protocol/constants.js';
2
+ import { buildIqNode } from '../../node/query.js';
3
+ /**
4
+ * IQ builders for the WhatsApp "Shortcake" companion-linking protocol
5
+ * (`xmlns="md"`). Wire shapes mirror the official client's
6
+ * `WASmaxOutMd…Request` builders. The stanza `id` is assigned by the socket
7
+ * query layer, matching the other pairing builders.
8
+ */
9
+ function mdIq(type, content) {
10
+ return buildIqNode(type, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.MD, content);
11
+ }
12
+ /** `<iq type="get" xmlns="md"><passkey_request_options/></iq>` */
13
+ export function buildGetPasskeyRequestOptionsRequestNode() {
14
+ return mdIq(WA_IQ_TYPES.GET, [
15
+ { tag: WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS, attrs: {}, content: undefined }
16
+ ]);
17
+ }
18
+ /** `<iq type="get" xmlns="md"><ref/></iq>` */
19
+ export function buildShortcakeGetRefRequestNode() {
20
+ return mdIq(WA_IQ_TYPES.GET, [{ tag: WA_NODE_TAGS.REF, attrs: {}, content: undefined }]);
21
+ }
22
+ /**
23
+ * `<iq type="set" xmlns="md"><passkey_prologue><credential_id/><webauthn_assertion/>`
24
+ * `<prologue_payload/>[<pairing_handoff_proof/>]</passkey_prologue></iq>`
25
+ */
26
+ export function buildSetPasskeyPrologueRequestNode(args) {
27
+ const children = [
28
+ { tag: 'credential_id', attrs: {}, content: args.credentialId },
29
+ { tag: 'webauthn_assertion', attrs: {}, content: args.webauthnAssertion },
30
+ { tag: 'prologue_payload', attrs: {}, content: args.prologuePayload }
31
+ ];
32
+ if (args.pairingHandoffProof) {
33
+ children.push({
34
+ tag: 'pairing_handoff_proof',
35
+ attrs: {},
36
+ content: args.pairingHandoffProof
37
+ });
38
+ }
39
+ return mdIq(WA_IQ_TYPES.SET, [{ tag: 'passkey_prologue', attrs: {}, content: children }]);
40
+ }
41
+ /** `<iq type="set" xmlns="md"><companion_nonce/></iq>` */
42
+ export function buildSetCompanionNonceRequestNode(companionNonce) {
43
+ return mdIq(WA_IQ_TYPES.SET, [{ tag: 'companion_nonce', attrs: {}, content: companionNonce }]);
44
+ }
45
+ /** `<iq type="set" xmlns="md"><encrypted_pairing_request/></iq>` */
46
+ export function buildSetEncryptedPairingRequestRequestNode(encryptedPairingRequest) {
47
+ return mdIq(WA_IQ_TYPES.SET, [
48
+ { tag: 'encrypted_pairing_request', attrs: {}, content: encryptedPairingRequest }
49
+ ]);
50
+ }
@@ -33,7 +33,7 @@ function resolveLocale() {
33
33
  function defaultWebSubPlatform() {
34
34
  return proto.ClientPayload.WebInfo.WebSubPlatform.WEB_BROWSER;
35
35
  }
36
- function resolveDevicePropsPlatformType(deviceBrowser) {
36
+ export function resolveDevicePropsPlatformType(deviceBrowser) {
37
37
  const normalized = deviceBrowser?.trim().toLowerCase();
38
38
  switch (normalized) {
39
39
  case 'chrome':
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export type { WaSendContextInfo } from './message/context-info';
30
30
  export type { WaLinkPreviewFetcher, WaLinkPreviewOptions, WaLinkPreviewOverride, WaLinkPreviewResolved, WaLinkPreviewThumbnailBytes, WaLinkPreviewThumbnailInput, WaLinkPreviewThumbnailStream, WaLinkPreviewType } from './message/addons/link-preview/types';
31
31
  export type { SignalLidSyncResult } from './signal/api/SignalDeviceSyncApi';
32
32
  export type { WaAuthCredentials, WaVersionResolver } from './auth/types';
33
+ export type { WaShortcakeAssertionSigner } from './auth/pairing/WaShortcakeFlow';
33
34
  export type { BinaryNode } from './transport/types';
34
35
  export { fetchLatestWaWebVersion } from './transport/wa-web-version-fetcher';
35
36
  export type { WaFetchLatestWebVersionOptions, WaLatestWebVersion } from './transport/wa-web-version-fetcher';
@@ -50,6 +50,8 @@ export declare const WA_NODE_TAGS: Readonly<{
50
50
  readonly COUNTRY_CODE: "country_code";
51
51
  readonly DEVICE_IDENTITY: "device-identity";
52
52
  readonly PLATFORM: "platform";
53
+ readonly PASSKEY_REQUEST_OPTIONS: "passkey_request_options";
54
+ readonly PRIMARY_EPHEMERAL_IDENTITY: "primary_ephemeral_identity";
53
55
  readonly SUBJECT: "subject";
54
56
  readonly BODY: "body";
55
57
  readonly PARTICIPANT: "participant";
@@ -53,6 +53,8 @@ exports.WA_NODE_TAGS = Object.freeze({
53
53
  COUNTRY_CODE: 'country_code',
54
54
  DEVICE_IDENTITY: 'device-identity',
55
55
  PLATFORM: 'platform',
56
+ PASSKEY_REQUEST_OPTIONS: 'passkey_request_options',
57
+ PRIMARY_EPHEMERAL_IDENTITY: 'primary_ephemeral_identity',
56
58
  SUBJECT: 'subject',
57
59
  BODY: 'body',
58
60
  PARTICIPANT: 'participant',
@@ -7,6 +7,8 @@ export declare const WA_NOTIFICATION_TYPES: Readonly<{
7
7
  readonly NEWSLETTER: "newsletter";
8
8
  readonly BUSINESS: "business";
9
9
  readonly PICTURE: "picture";
10
+ readonly PASSKEY_PROLOGUE_REQUEST: "passkey_prologue_request";
11
+ readonly CRSC_CONTINUATION: "crsc_continuation";
10
12
  }>;
11
13
  export declare const WA_BUSINESS_NOTIFICATION_TAGS: Readonly<{
12
14
  readonly VERIFIED_NAME: "verified_name";
@@ -9,7 +9,9 @@ exports.WA_NOTIFICATION_TYPES = Object.freeze({
9
9
  REGISTRATION: 'registration',
10
10
  NEWSLETTER: 'newsletter',
11
11
  BUSINESS: 'business',
12
- PICTURE: 'picture'
12
+ PICTURE: 'picture',
13
+ PASSKEY_PROLOGUE_REQUEST: 'passkey_prologue_request',
14
+ CRSC_CONTINUATION: 'crsc_continuation'
13
15
  });
14
16
  exports.WA_BUSINESS_NOTIFICATION_TAGS = Object.freeze({
15
17
  VERIFIED_NAME: 'verified_name',
@@ -12,6 +12,7 @@ export interface WaRetryReplayServiceOptions {
12
12
  readonly signalProtocol: SignalProtocol;
13
13
  readonly sessionResolver: SignalSessionResolver;
14
14
  readonly getCurrentCredentials: () => WaAuthCredentials | null;
15
+ readonly isMobilePrimary?: () => boolean;
15
16
  readonly resolveUserIcdc?: (userJid: string) => Promise<IcdcMeta | null>;
16
17
  /**
17
18
  * Resolves the trusted-contact (privacy) token node for a recipient user