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,3 +1,4 @@
1
+ import { type WaShortcakeAssertionSigner } from './pairing/WaShortcakeFlow';
1
2
  import type { WaAuthClientOptions, WaAuthCredentials, WaAuthSocketOptions, WaSuccessPersistAttributes } from './types';
2
3
  import type { Logger } from '../infra/log/types';
3
4
  import type { WaAuthStore } from '../store/contracts/auth.store';
@@ -21,6 +22,10 @@ type WaAuthClientDeps = Readonly<{
21
22
  readonly onPairingRefresh?: (forceManual: boolean) => void;
22
23
  readonly onPaired?: (credentials: WaAuthCredentials) => void;
23
24
  readonly onError?: (error: Error) => void;
25
+ /** Server forced a passkey (Shortcake) prologue; `hasSigner` = a signer is configured. */
26
+ readonly onPasskeyRequired?: (hasSigner: boolean) => void;
27
+ /** External WebAuthn signer – enables handling forced passkey (Shortcake) prologues. */
28
+ readonly signPasskeyAssertion?: WaShortcakeAssertionSigner;
24
29
  };
25
30
  }>;
26
31
  /**
@@ -42,6 +47,8 @@ export declare class WaAuthClient {
42
47
  private readonly isConnected?;
43
48
  private readonly qrFlow;
44
49
  private readonly pairingFlow;
50
+ private readonly socket;
51
+ private readonly shortcakeFlow;
45
52
  private credentials;
46
53
  private versionOverride;
47
54
  constructor(options: WaAuthClientOptions, deps: WaAuthClientDeps);
@@ -139,6 +146,14 @@ export declare class WaAuthClient {
139
146
  handleIncomingIqSet(node: BinaryNode): Promise<boolean>;
140
147
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
141
148
  handleLinkCodeNotification(node: BinaryNode): Promise<boolean>;
149
+ /**
150
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
151
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
152
+ * and warn instead of silently stalling – which is what happens today when
153
+ * the server demands a passkey after a successful pairing-code
154
+ * `companion_finish`.
155
+ */
156
+ private handleShortcakeNotification;
142
157
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
143
158
  handleCompanionRegRefreshNotification(node: BinaryNode): Promise<boolean>;
144
159
  private patchCredentials;
@@ -4,7 +4,10 @@ exports.WaAuthClient = void 0;
4
4
  const credentials_flow_1 = require("./credentials-flow");
5
5
  const WaPairingFlow_1 = require("./pairing/WaPairingFlow");
6
6
  const WaQrFlow_1 = require("./pairing/WaQrFlow");
7
+ const WaShortcakeFlow_1 = require("./pairing/WaShortcakeFlow");
7
8
  const constants_1 = require("../protocol/constants");
9
+ const global_1 = require("../transport/node/builders/global");
10
+ const WaClientPayload_1 = require("../transport/noise/WaClientPayload");
8
11
  const bytes_1 = require("../util/bytes");
9
12
  const primitives_1 = require("../util/primitives");
10
13
  const runtime_1 = require("../util/runtime");
@@ -62,6 +65,23 @@ class WaAuthClient {
62
65
  },
63
66
  dangerous: options.dangerous
64
67
  });
68
+ this.socket = deps.socket;
69
+ const signAssertion = deps.callbacks?.signPasskeyAssertion;
70
+ this.shortcakeFlow = signAssertion
71
+ ? new WaShortcakeFlow_1.WaShortcakeFlow({
72
+ logger: this.logger,
73
+ socket: {
74
+ sendNode: deps.socket.sendNode,
75
+ query: (node, timeoutMs) => deps.socket.query(node, timeoutMs)
76
+ },
77
+ signAssertion,
78
+ auth: {
79
+ getCredentials: () => this.credentials,
80
+ updateCredentials: this.updateCredentials.bind(this)
81
+ },
82
+ deviceType: (0, WaClientPayload_1.resolveDevicePropsPlatformType)(deviceBrowser)
83
+ })
84
+ : null;
65
85
  }
66
86
  /**
67
87
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -134,6 +154,7 @@ class WaAuthClient {
134
154
  this.logger.trace('auth client clear transient state');
135
155
  this.qrFlow.clear();
136
156
  this.pairingFlow.clearSession();
157
+ this.shortcakeFlow?.clearSession();
137
158
  }
138
159
  /**
139
160
  * Wipes credentials from memory **and** from the auth store. Does
@@ -292,9 +313,35 @@ class WaAuthClient {
292
313
  }
293
314
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
294
315
  async handleLinkCodeNotification(node) {
316
+ const type = node.attrs.type;
317
+ if (type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST ||
318
+ type === constants_1.WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
319
+ return this.runHandled(() => this.handleShortcakeNotification(node));
320
+ }
295
321
  this.logger.trace('auth client handleLinkCodeNotification', { id: node.attrs.id });
296
322
  return this.runHandled(() => this.pairingFlow.handleLinkCodeNotification(node));
297
323
  }
324
+ /**
325
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
326
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
327
+ * and warn instead of silently stalling – which is what happens today when
328
+ * the server demands a passkey after a successful pairing-code
329
+ * `companion_finish`.
330
+ */
331
+ async handleShortcakeNotification(node) {
332
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
333
+ this.callbacks.onPasskeyRequired?.(this.shortcakeFlow !== null);
334
+ }
335
+ if (this.shortcakeFlow) {
336
+ return this.shortcakeFlow.handleIncomingNotification(node);
337
+ }
338
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
339
+ this.logger.warn('auth client: server requested passkey prologue but no signPasskeyAssertion configured', { id: node.attrs.id });
340
+ await this.socket.sendNode((0, global_1.buildAckNode)({ kind: 'notification', node }));
341
+ return true;
342
+ }
343
+ return false;
344
+ }
298
345
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
299
346
  async handleCompanionRegRefreshNotification(node) {
300
347
  this.logger.trace('auth client handleCompanionRegRefreshNotification', {
@@ -0,0 +1,93 @@
1
+ import type { WaAuthCredentials } from '../types';
2
+ import type { Logger } from '../../infra/log/types';
3
+ import { proto } from '../../proto';
4
+ import type { BinaryNode } from '../../transport/types';
5
+ /**
6
+ * Externally-provided WebAuthn assertion. The passkey/credential source lives
7
+ * OUTSIDE the protocol layer – the caller signs the server's request options
8
+ * however it likes (real authenticator, virtual authenticator, relay…) and
9
+ * hands back the raw assertion + credential id.
10
+ */
11
+ export type WaShortcakeAssertionSigner = (requestOptions: Uint8Array) => Promise<{
12
+ readonly credentialId: Uint8Array;
13
+ readonly webauthnAssertion: Uint8Array;
14
+ }>;
15
+ export interface WaShortcakeFlowOptions {
16
+ readonly logger: Logger;
17
+ readonly socket: {
18
+ readonly sendNode: (node: BinaryNode) => Promise<void>;
19
+ readonly query: (node: BinaryNode, timeoutMs: number) => Promise<BinaryNode>;
20
+ };
21
+ /** WebAuthn signer (passkey assertion) – external, keeps credentials out of the protocol. */
22
+ readonly signAssertion: WaShortcakeAssertionSigner;
23
+ /**
24
+ * Companion credentials access. The pairing-request payload (`PairingRequest`:
25
+ * noise static + identity pubkey + ADV secret) is built internally from
26
+ * these; the ADV secret is also rotated on a prologue request (matching the
27
+ * official clients), so an updater is required.
28
+ */
29
+ readonly auth: {
30
+ readonly getCredentials: () => WaAuthCredentials | null;
31
+ readonly updateCredentials: (credentials: WaAuthCredentials) => Promise<void>;
32
+ };
33
+ /** Companion platform reported in the ephemeral identity. */
34
+ readonly deviceType?: proto.DeviceProps.PlatformType;
35
+ /** Optional internal observability hooks. */
36
+ readonly callbacks?: {
37
+ /** Verification code derived after the primary replies. */
38
+ readonly emitVerificationCode?: (code: string) => void;
39
+ /** Prologue accepted by the server; now waiting for the primary. */
40
+ readonly emitPrologueSent?: () => void;
41
+ };
42
+ }
43
+ /**
44
+ * Drives the companion side of the WhatsApp "Shortcake" passkey-linking
45
+ * handshake (the `xmlns="md"` IQ exchange + commit/reveal ECDH). It owns the
46
+ * wire protocol and crypto only; the WebAuthn assertion and the registration
47
+ * payload are injected by the caller.
48
+ */
49
+ export declare class WaShortcakeFlow {
50
+ private readonly opts;
51
+ private session;
52
+ private handoffKey;
53
+ constructor(options: WaShortcakeFlowOptions);
54
+ hasSession(): boolean;
55
+ clearSession(): void;
56
+ /**
57
+ * Runs the prologue: fetch the request options, obtain the WebAuthn
58
+ * assertion (external), fetch the ref, build the companion ephemeral
59
+ * identity + commitment, and send the `passkey_prologue` IQ.
60
+ */
61
+ executePrologue(args?: {
62
+ readonly requestOptions?: Uint8Array;
63
+ readonly pairingHandoffProof?: Uint8Array;
64
+ }): Promise<void>;
65
+ /**
66
+ * Routes the two server-pushed Shortcake notifications. Returns `true` when
67
+ * the notification was a Shortcake one (and was consumed):
68
+ * - `passkey_prologue_request` kicks the flow off (server forces passkey,
69
+ * often right after a pairing-code `companion_finish`); the WebAuthn
70
+ * options are usually embedded.
71
+ * - `crsc_continuation` carries the primary's ephemeral identity.
72
+ */
73
+ handleIncomingNotification(node: BinaryNode): Promise<boolean>;
74
+ private handlePasskeyPrologueRequest;
75
+ /**
76
+ * Derives a pairing handoff HMAC key from the current ADV secret and rotates
77
+ * the ADV secret (matching the official clients). The derived key signs the
78
+ * next prologue's handoff proof; the rotated secret goes into the eventual
79
+ * `PairingRequest`.
80
+ */
81
+ private stashHandoffKeyAndRotateAdv;
82
+ private handlePrimaryEphemeralIdentity;
83
+ /** Current verification code, once derived. */
84
+ getVerificationCode(): string | null;
85
+ /**
86
+ * Confirms the verification code: builds + AES-GCM seals the pairing request
87
+ * and sends the `encrypted_pairing_request` IQ.
88
+ */
89
+ confirmVerificationCode(): Promise<void>;
90
+ private requestPasskeyRequestOptions;
91
+ private requestRef;
92
+ private ackNotification;
93
+ }
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WaShortcakeFlow = void 0;
4
+ const shortcake_crypto_1 = require("../pairing/shortcake-crypto");
5
+ const _crypto_1 = require("../../crypto");
6
+ const _proto_1 = require("../../proto");
7
+ const constants_1 = require("../../protocol/constants");
8
+ const global_1 = require("../../transport/node/builders/global");
9
+ const shortcake_1 = require("../../transport/node/builders/shortcake");
10
+ const helpers_1 = require("../../transport/node/helpers");
11
+ const query_1 = require("../../transport/node/query");
12
+ const bytes_1 = require("../../util/bytes");
13
+ /** HKDF info for the pairing handoff HMAC key (derived from the ADV secret). */
14
+ const HANDOFF_KEY_INFO = bytes_1.TEXT_ENCODER.encode('shortcake-passkey-handoff-v1');
15
+ const HANDOFF_KEY_TTL_MS = 5 * 60 * 1000;
16
+ const Stage = Object.freeze({
17
+ Idle: 'idle',
18
+ WaitingForPrimaryIdentity: 'waiting_for_primary_identity',
19
+ WaitingForConfirmation: 'waiting_for_confirmation',
20
+ WaitingForPairing: 'waiting_for_pairing'
21
+ });
22
+ /**
23
+ * Drives the companion side of the WhatsApp "Shortcake" passkey-linking
24
+ * handshake (the `xmlns="md"` IQ exchange + commit/reveal ECDH). It owns the
25
+ * wire protocol and crypto only; the WebAuthn assertion and the registration
26
+ * payload are injected by the caller.
27
+ */
28
+ class WaShortcakeFlow {
29
+ constructor(options) {
30
+ this.opts = options;
31
+ this.session = null;
32
+ this.handoffKey = null;
33
+ }
34
+ hasSession() {
35
+ return this.session !== null;
36
+ }
37
+ clearSession() {
38
+ this.session = null;
39
+ this.handoffKey = null;
40
+ }
41
+ /**
42
+ * Runs the prologue: fetch the request options, obtain the WebAuthn
43
+ * assertion (external), fetch the ref, build the companion ephemeral
44
+ * identity + commitment, and send the `passkey_prologue` IQ.
45
+ */
46
+ async executePrologue(args = {}) {
47
+ const deviceType = this.opts.deviceType ?? _proto_1.proto.DeviceProps.PlatformType.CHROME;
48
+ const requestOptions = args.requestOptions ?? (await this.requestPasskeyRequestOptions());
49
+ const assertion = await this.opts.signAssertion(requestOptions);
50
+ const ref = await this.requestRef();
51
+ const companion = await (0, shortcake_crypto_1.generateCompanionEphemeralIdentity)({ ref, deviceType });
52
+ let pairingHandoffProof = args.pairingHandoffProof;
53
+ const handoffKey = this.handoffKey;
54
+ this.handoffKey = null;
55
+ if (pairingHandoffProof === undefined &&
56
+ handoffKey !== null &&
57
+ Date.now() - handoffKey.ts < HANDOFF_KEY_TTL_MS) {
58
+ pairingHandoffProof = (0, _crypto_1.hmacSha256Sign)(handoffKey.hmac, companion.prologuePayloadBytes);
59
+ }
60
+ const skipHandoffUx = pairingHandoffProof !== undefined;
61
+ const response = await this.opts.socket.query((0, shortcake_1.buildSetPasskeyPrologueRequestNode)({
62
+ credentialId: assertion.credentialId,
63
+ webauthnAssertion: assertion.webauthnAssertion,
64
+ prologuePayload: companion.prologuePayloadBytes,
65
+ pairingHandoffProof
66
+ }), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
67
+ (0, query_1.assertIqResult)(response, 'shortcake set-passkey-prologue');
68
+ this.session = {
69
+ companion,
70
+ ref,
71
+ deviceType,
72
+ skipHandoffUx,
73
+ stage: Stage.WaitingForPrimaryIdentity,
74
+ encryptionKey: null,
75
+ verificationCode: null
76
+ };
77
+ this.opts.logger.debug('shortcake prologue sent', { ref, skipHandoffUx });
78
+ this.opts.callbacks?.emitPrologueSent?.();
79
+ }
80
+ /**
81
+ * Routes the two server-pushed Shortcake notifications. Returns `true` when
82
+ * the notification was a Shortcake one (and was consumed):
83
+ * - `passkey_prologue_request` kicks the flow off (server forces passkey,
84
+ * often right after a pairing-code `companion_finish`); the WebAuthn
85
+ * options are usually embedded.
86
+ * - `crsc_continuation` carries the primary's ephemeral identity.
87
+ */
88
+ async handleIncomingNotification(node) {
89
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
90
+ return this.handlePasskeyPrologueRequest(node);
91
+ }
92
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
93
+ return this.handlePrimaryEphemeralIdentity(node);
94
+ }
95
+ return false;
96
+ }
97
+ async handlePasskeyPrologueRequest(node) {
98
+ await this.ackNotification(node);
99
+ await this.stashHandoffKeyAndRotateAdv();
100
+ const optionsNode = (0, helpers_1.findNodeChild)(node, constants_1.WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
101
+ const requestOptions = optionsNode
102
+ ? (0, helpers_1.decodeNodeContentUtf8OrBytes)(optionsNode.content, 'shortcake.passkey_request_options')
103
+ : undefined;
104
+ this.opts.logger.debug('shortcake prologue requested by server', {
105
+ embeddedOptions: optionsNode !== undefined
106
+ });
107
+ await this.executePrologue({ requestOptions });
108
+ return true;
109
+ }
110
+ /**
111
+ * Derives a pairing handoff HMAC key from the current ADV secret and rotates
112
+ * the ADV secret (matching the official clients). The derived key signs the
113
+ * next prologue's handoff proof; the rotated secret goes into the eventual
114
+ * `PairingRequest`.
115
+ */
116
+ async stashHandoffKeyAndRotateAdv() {
117
+ const credentials = this.opts.auth.getCredentials();
118
+ if (!credentials) {
119
+ return;
120
+ }
121
+ this.handoffKey = {
122
+ hmac: (0, _crypto_1.hkdf)(credentials.advSecretKey, null, HANDOFF_KEY_INFO, 32),
123
+ ts: Date.now()
124
+ };
125
+ await this.opts.auth.updateCredentials({
126
+ ...credentials,
127
+ advSecretKey: await (0, _crypto_1.randomBytesAsync)(32)
128
+ });
129
+ }
130
+ async handlePrimaryEphemeralIdentity(node) {
131
+ const child = (0, helpers_1.findNodeChild)(node, constants_1.WA_NODE_TAGS.PRIMARY_EPHEMERAL_IDENTITY);
132
+ if (!child) {
133
+ return false;
134
+ }
135
+ const session = this.session;
136
+ if (!session || session.stage !== Stage.WaitingForPrimaryIdentity) {
137
+ this.opts.logger.warn('shortcake primary identity ignored: no active prologue');
138
+ await this.ackNotification(node);
139
+ return true;
140
+ }
141
+ await this.ackNotification(node);
142
+ const primary = (0, shortcake_crypto_1.decodePrimaryEphemeralIdentity)((0, helpers_1.decodeNodeContentUtf8OrBytes)(child.content, 'shortcake.primary_ephemeral_identity'));
143
+ const nonceResponse = await this.opts.socket.query((0, shortcake_1.buildSetCompanionNonceRequestNode)(session.companion.companionNonce), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
144
+ (0, query_1.assertIqResult)(nonceResponse, 'shortcake set-companion-nonce');
145
+ const verificationCode = (0, shortcake_crypto_1.deriveVerificationCode)(session.companion.companionNonce, primary);
146
+ const encryptionKey = await (0, shortcake_crypto_1.deriveEncryptionKey)({
147
+ companionPrivKey: session.companion.keyPair.privKey,
148
+ primaryPublicKey: primary.publicKey,
149
+ deviceType: session.deviceType,
150
+ ref: session.ref
151
+ });
152
+ session.encryptionKey = encryptionKey;
153
+ session.verificationCode = verificationCode;
154
+ session.stage = Stage.WaitingForConfirmation;
155
+ this.opts.logger.debug('shortcake verification code ready');
156
+ this.opts.callbacks?.emitVerificationCode?.(verificationCode);
157
+ await this.confirmVerificationCode();
158
+ return true;
159
+ }
160
+ /** Current verification code, once derived. */
161
+ getVerificationCode() {
162
+ return this.session?.verificationCode ?? null;
163
+ }
164
+ /**
165
+ * Confirms the verification code: builds + AES-GCM seals the pairing request
166
+ * and sends the `encrypted_pairing_request` IQ.
167
+ */
168
+ async confirmVerificationCode() {
169
+ const session = this.session;
170
+ if (!session || session.stage !== Stage.WaitingForConfirmation || !session.encryptionKey) {
171
+ throw new Error('shortcake: no verification code awaiting confirmation');
172
+ }
173
+ const credentials = this.opts.auth.getCredentials();
174
+ if (!credentials) {
175
+ throw new Error('shortcake: credentials are not initialized');
176
+ }
177
+ const plaintext = _proto_1.proto.PairingRequest.encode({
178
+ companionPublicKey: credentials.noiseKeyPair.pubKey,
179
+ companionIdentityKey: credentials.registrationInfo.identityKeyPair.pubKey,
180
+ advSecret: credentials.advSecretKey
181
+ }).finish();
182
+ const envelope = await (0, shortcake_crypto_1.encryptPairingRequest)(session.encryptionKey, plaintext);
183
+ const response = await this.opts.socket.query((0, shortcake_1.buildSetEncryptedPairingRequestRequestNode)(envelope), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
184
+ (0, query_1.assertIqResult)(response, 'shortcake set-encrypted-pairing-request');
185
+ session.stage = Stage.WaitingForPairing;
186
+ this.opts.logger.debug('shortcake encrypted pairing request sent');
187
+ }
188
+ async requestPasskeyRequestOptions() {
189
+ const response = await this.opts.socket.query((0, shortcake_1.buildGetPasskeyRequestOptionsRequestNode)(), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
190
+ (0, query_1.assertIqResult)(response, 'shortcake get-passkey-request-options');
191
+ const optionsNode = (0, helpers_1.findNodeChild)(response, constants_1.WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
192
+ if (!optionsNode) {
193
+ throw new Error('shortcake: get-passkey-request-options response missing options');
194
+ }
195
+ return (0, helpers_1.decodeNodeContentUtf8OrBytes)(optionsNode.content, 'shortcake.passkey_request_options');
196
+ }
197
+ async requestRef() {
198
+ const response = await this.opts.socket.query((0, shortcake_1.buildShortcakeGetRefRequestNode)(), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
199
+ (0, query_1.assertIqResult)(response, 'shortcake get-ref');
200
+ const refNode = (0, helpers_1.findNodeChild)(response, constants_1.WA_NODE_TAGS.REF);
201
+ if (!refNode) {
202
+ throw new Error('shortcake: get-ref response missing ref');
203
+ }
204
+ return bytes_1.TEXT_DECODER.decode((0, helpers_1.decodeNodeContentUtf8OrBytes)(refNode.content, 'shortcake.ref'));
205
+ }
206
+ async ackNotification(node) {
207
+ await this.opts.socket.sendNode((0, global_1.buildAckNode)({ kind: 'notification', node }));
208
+ }
209
+ }
210
+ exports.WaShortcakeFlow = WaShortcakeFlow;
@@ -1,5 +1,6 @@
1
1
  import type { SignalKeyPair } from '../../crypto/curves/types';
2
2
  export declare const PBKDF2_ITERATIONS: number;
3
+ export declare function encodeBytesToCrockford(bytes: Uint8Array): string;
3
4
  export declare function normalizeCustomPairingCode(input: string): string;
4
5
  export declare function createCompanionHello(options?: {
5
6
  readonly customCode?: string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PBKDF2_ITERATIONS = void 0;
4
+ exports.encodeBytesToCrockford = encodeBytesToCrockford;
4
5
  exports.normalizeCustomPairingCode = normalizeCustomPairingCode;
5
6
  exports.createCompanionHello = createCompanionHello;
6
7
  exports.completeCompanionFinish = completeCompanionFinish;
@@ -11,7 +12,7 @@ const bytes_1 = require("../../util/bytes");
11
12
  const CROCKFORD_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';
12
13
  exports.PBKDF2_ITERATIONS = 2 << 16;
13
14
  const PAIRING_AES_KEY_BYTES = 32;
14
- function bytesToCrockford(bytes) {
15
+ function encodeBytesToCrockford(bytes) {
15
16
  let bitCount = 0;
16
17
  let value = 0;
17
18
  let out = '';
@@ -48,7 +49,7 @@ async function createCompanionHello(options = {}) {
48
49
  (0, _crypto_1.randomBytesAsync)(16),
49
50
  normalizedCustomCode !== null ? Promise.resolve(null) : (0, _crypto_1.randomBytesAsync)(5)
50
51
  ]);
51
- const pairingCode = normalizedCustomCode ?? bytesToCrockford(codeBytes);
52
+ const pairingCode = normalizedCustomCode ?? encodeBytesToCrockford(codeBytes);
52
53
  const cipherKey = await (0, _crypto_1.pbkdf2Sha256)(bytes_1.TEXT_ENCODER.encode(pairingCode), salt, exports.PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
53
54
  const encrypted = (0, _crypto_1.aesCtrEncrypt)(cipherKey, counter, companionEphemeralKeyPair.pubKey);
54
55
  return {
@@ -0,0 +1,59 @@
1
+ import type { SignalKeyPair } from '../../crypto/curves/types';
2
+ import { proto } from '../../proto';
3
+ /**
4
+ * Decoded `PrimaryEphemeralIdentity` the primary device returns: its ephemeral
5
+ * X25519 public key (32B) and a 32B nonce used to derive the verification code.
6
+ */
7
+ export interface ShortcakePrimaryEphemeralIdentity {
8
+ readonly publicKey: Uint8Array;
9
+ readonly nonce: Uint8Array;
10
+ }
11
+ /**
12
+ * @sensitive Carries the companion's ephemeral X25519 private key
13
+ * (`keyPair.privKey`) and the pre-image `companionNonce`. Hold these in memory
14
+ * for the handshake only; never log or `JSON.stringify` an instance.
15
+ */
16
+ export interface ShortcakeCompanionEphemeralIdentity {
17
+ /** Companion ephemeral X25519 keypair (private half kept for the ECDH). */
18
+ readonly keyPair: SignalKeyPair;
19
+ /** 32 random bytes committed up-front, revealed only after the primary replies. */
20
+ readonly companionNonce: Uint8Array;
21
+ /** Encoded `CompanionEphemeralIdentity` proto (committed + sent in the prologue). */
22
+ readonly companionEphemeralIdentityBytes: Uint8Array;
23
+ /** `SHA-256(companionEphemeralIdentity ‖ companionNonce)` – the commitment hash. */
24
+ readonly commitmentHash: Uint8Array;
25
+ /** Encoded `ProloguePayload` proto ready for the `SetPasskeyPrologue` IQ. */
26
+ readonly prologuePayloadBytes: Uint8Array;
27
+ }
28
+ /**
29
+ * Generates the companion's ephemeral identity + commitment for a Shortcake
30
+ * prologue. The companion publishes a commitment to its nonce now and reveals
31
+ * the nonce later (after the primary's identity arrives) so the primary cannot
32
+ * grind the verification code.
33
+ */
34
+ export declare function generateCompanionEphemeralIdentity(args: {
35
+ readonly ref: string;
36
+ readonly deviceType: proto.DeviceProps.PlatformType;
37
+ }): Promise<ShortcakeCompanionEphemeralIdentity>;
38
+ /** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
39
+ export declare function decodePrimaryEphemeralIdentity(bytes: Uint8Array): ShortcakePrimaryEphemeralIdentity;
40
+ /**
41
+ * Derives the short verification code shown on both devices:
42
+ * `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
43
+ */
44
+ export declare function deriveVerificationCode(companionNonce: Uint8Array, primary: ShortcakePrimaryEphemeralIdentity): string;
45
+ /**
46
+ * Derives the AES-GCM key that protects the pairing request:
47
+ * `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
48
+ */
49
+ export declare function deriveEncryptionKey(args: {
50
+ readonly companionPrivKey: Uint8Array;
51
+ readonly primaryPublicKey: Uint8Array;
52
+ readonly deviceType: proto.DeviceProps.PlatformType;
53
+ readonly ref: string;
54
+ }): Promise<Uint8Array>;
55
+ /**
56
+ * Encrypts the pairing request plaintext under the derived key and returns the
57
+ * encoded `EncryptedPairingRequest` proto (random 12-byte IV).
58
+ */
59
+ export declare function encryptPairingRequest(encryptionKey: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateCompanionEphemeralIdentity = generateCompanionEphemeralIdentity;
4
+ exports.decodePrimaryEphemeralIdentity = decodePrimaryEphemeralIdentity;
5
+ exports.deriveVerificationCode = deriveVerificationCode;
6
+ exports.deriveEncryptionKey = deriveEncryptionKey;
7
+ exports.encryptPairingRequest = encryptPairingRequest;
8
+ const pairing_code_crypto_1 = require("../pairing/pairing-code-crypto");
9
+ const _crypto_1 = require("../../crypto");
10
+ const X25519_1 = require("../../crypto/curves/X25519");
11
+ const _proto_1 = require("../../proto");
12
+ const bytes_1 = require("../../util/bytes");
13
+ /**
14
+ * Cryptographic core of the WhatsApp "Shortcake" companion-linking protocol
15
+ * (the device-link flow the official clients call CRSC / Shortcake).
16
+ *
17
+ * This module implements ONLY the wire/crypto half: ephemeral X25519 key
18
+ * exchange, the commit-reveal nonce, the verification code, and the AES-GCM
19
+ * pairing envelope. The WebAuthn/passkey assertion that gates the prologue is
20
+ * NOT produced here – it is an opaque input supplied by the caller (so the
21
+ * credential source stays out of the protocol layer).
22
+ *
23
+ * Mirrors `WAWebShortcakeLinkingAlgorithm` from the web client.
24
+ */
25
+ const NONCE_BYTES = 32;
26
+ const VERIFICATION_CODE_BYTES = 5;
27
+ const GCM_IV_BYTES = 12;
28
+ const ENCRYPTION_KEY_BYTES = 32;
29
+ const EPHEMERAL_PUBLIC_KEY_BYTES = 32;
30
+ /** HKDF `info` for the pairing-request encryption key. */
31
+ const ENCRYPTION_KEY_INFO = bytes_1.TEXT_ENCODER.encode('Pairing Information Encryption Key');
32
+ /**
33
+ * Generates the companion's ephemeral identity + commitment for a Shortcake
34
+ * prologue. The companion publishes a commitment to its nonce now and reveals
35
+ * the nonce later (after the primary's identity arrives) so the primary cannot
36
+ * grind the verification code.
37
+ */
38
+ async function generateCompanionEphemeralIdentity(args) {
39
+ const [keyPair, companionNonce] = await Promise.all([
40
+ X25519_1.X25519.generateKeyPair(),
41
+ (0, _crypto_1.randomBytesAsync)(NONCE_BYTES)
42
+ ]);
43
+ const companionEphemeralIdentityBytes = _proto_1.proto.CompanionEphemeralIdentity.encode({
44
+ publicKey: keyPair.pubKey,
45
+ deviceType: args.deviceType,
46
+ ref: args.ref
47
+ }).finish();
48
+ const commitmentHash = (0, _crypto_1.sha256)((0, bytes_1.concatBytes)([companionEphemeralIdentityBytes, companionNonce]));
49
+ const prologuePayloadBytes = _proto_1.proto.ProloguePayload.encode({
50
+ companionEphemeralIdentity: companionEphemeralIdentityBytes,
51
+ commitment: { hash: commitmentHash }
52
+ }).finish();
53
+ return {
54
+ keyPair,
55
+ companionNonce,
56
+ companionEphemeralIdentityBytes,
57
+ commitmentHash,
58
+ prologuePayloadBytes
59
+ };
60
+ }
61
+ /** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
62
+ function decodePrimaryEphemeralIdentity(bytes) {
63
+ const decoded = _proto_1.proto.PrimaryEphemeralIdentity.decode(bytes);
64
+ const publicKey = decoded.publicKey;
65
+ const nonce = decoded.nonce;
66
+ if (!publicKey || publicKey.length !== EPHEMERAL_PUBLIC_KEY_BYTES) {
67
+ throw new Error('shortcake: PrimaryEphemeralIdentity.publicKey must be 32 bytes');
68
+ }
69
+ if (!nonce || nonce.length !== NONCE_BYTES) {
70
+ throw new Error('shortcake: PrimaryEphemeralIdentity.nonce must be 32 bytes');
71
+ }
72
+ return { publicKey, nonce };
73
+ }
74
+ /**
75
+ * Derives the short verification code shown on both devices:
76
+ * `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
77
+ */
78
+ function deriveVerificationCode(companionNonce, primary) {
79
+ const digest = (0, _crypto_1.sha256)((0, bytes_1.concatBytes)([companionNonce, primary.publicKey]));
80
+ const code = new Uint8Array(VERIFICATION_CODE_BYTES);
81
+ for (let i = 0; i < VERIFICATION_CODE_BYTES; i += 1) {
82
+ code[i] = primary.nonce[i] ^ digest[i];
83
+ }
84
+ return (0, pairing_code_crypto_1.encodeBytesToCrockford)(code);
85
+ }
86
+ /**
87
+ * Derives the AES-GCM key that protects the pairing request:
88
+ * `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
89
+ */
90
+ async function deriveEncryptionKey(args) {
91
+ const shared = await X25519_1.X25519.scalarMult(args.companionPrivKey, args.primaryPublicKey);
92
+ const salt = bytes_1.TEXT_ENCODER.encode(`Companion Pairing ${String(args.deviceType)} with ref ${args.ref}`);
93
+ return (0, _crypto_1.hkdf)(shared, salt, ENCRYPTION_KEY_INFO, ENCRYPTION_KEY_BYTES);
94
+ }
95
+ /**
96
+ * Encrypts the pairing request plaintext under the derived key and returns the
97
+ * encoded `EncryptedPairingRequest` proto (random 12-byte IV).
98
+ */
99
+ async function encryptPairingRequest(encryptionKey, plaintext) {
100
+ if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
101
+ throw new Error('shortcake: encryption key must be 32 bytes');
102
+ }
103
+ const iv = await (0, _crypto_1.randomBytesAsync)(GCM_IV_BYTES);
104
+ const encryptedPayload = (0, _crypto_1.aesGcmEncrypt)(encryptionKey, iv, plaintext);
105
+ return _proto_1.proto.EncryptedPairingRequest.encode({ encryptedPayload, iv }).finish();
106
+ }
@@ -352,7 +352,9 @@ function buildWaClientDependencies(input) {
352
352
  runtime.emitEvent('auth_paired', { credentials });
353
353
  scheduleReconnectAfterPairing();
354
354
  },
355
- onError: (error) => runtime.handleError(error)
355
+ onError: (error) => runtime.handleError(error),
356
+ onPasskeyRequired: (hasSigner) => runtime.emitEvent('auth_passkey_required', { hasSigner }),
357
+ signPasskeyAssertion: options.signPasskeyAssertion
356
358
  }
357
359
  });
358
360
  const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
@@ -64,6 +64,7 @@ class WaRetryCoordinator {
64
64
  signalProtocol: options.signalProtocol,
65
65
  sessionResolver: options.sessionResolver,
66
66
  getCurrentCredentials: options.getCurrentCredentials,
67
+ isMobilePrimary: options.isMobilePrimary,
67
68
  resolveUserIcdc: options.resolveUserIcdc,
68
69
  resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
69
70
  });