zapo-js 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +25 -0
  3. package/dist/auth/WaAuthClient.js +63 -7
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
  7. package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
  8. package/dist/auth/pairing/companion-host.d.ts +105 -0
  9. package/dist/auth/pairing/companion-host.js +148 -0
  10. package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
  11. package/dist/auth/pairing/pairing-code-crypto.js +3 -2
  12. package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
  13. package/dist/auth/pairing/shortcake-crypto.js +106 -0
  14. package/dist/auth/types.d.ts +20 -9
  15. package/dist/client/WaClient.d.ts +15 -5
  16. package/dist/client/WaClient.js +46 -12
  17. package/dist/client/WaClientFactory.d.ts +3 -0
  18. package/dist/client/WaClientFactory.js +27 -7
  19. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  20. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  21. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  22. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  23. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  24. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  25. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  26. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  27. package/dist/client/messaging/companion-host.d.ts +43 -0
  28. package/dist/client/messaging/companion-host.js +61 -0
  29. package/dist/client/messaging/key-protocol.js +25 -1
  30. package/dist/client/persistence/companion-host.d.ts +36 -0
  31. package/dist/client/persistence/companion-host.js +57 -0
  32. package/dist/client/plugins/install.d.ts +2 -1
  33. package/dist/client/plugins/install.js +44 -11
  34. package/dist/client/types.d.ts +90 -7
  35. package/dist/esm/auth/WaAuthClient.js +64 -8
  36. package/dist/esm/auth/credentials-flow.js +36 -3
  37. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  38. package/dist/esm/auth/pairing/companion-host.js +140 -0
  39. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  40. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  41. package/dist/esm/client/WaClient.js +46 -12
  42. package/dist/esm/client/WaClientFactory.js +28 -8
  43. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  44. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  45. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  46. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  47. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  48. package/dist/esm/client/messaging/companion-host.js +56 -0
  49. package/dist/esm/client/messaging/key-protocol.js +25 -1
  50. package/dist/esm/client/persistence/companion-host.js +54 -0
  51. package/dist/esm/client/plugins/install.js +44 -11
  52. package/dist/esm/index.js +5 -2
  53. package/dist/esm/message/WaMessageClient.js +3 -2
  54. package/dist/esm/message/context-info.js +4 -0
  55. package/dist/esm/protocol/constants.js +4 -3
  56. package/dist/esm/protocol/device-identity.js +18 -0
  57. package/dist/esm/protocol/group.js +6 -0
  58. package/dist/esm/protocol/message.js +12 -0
  59. package/dist/esm/protocol/nodes.js +17 -1
  60. package/dist/esm/protocol/notification.js +3 -1
  61. package/dist/esm/retry/replay.js +7 -15
  62. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  63. package/dist/esm/signal/attestation/constants.js +2 -0
  64. package/dist/esm/signal/index.js +1 -0
  65. package/dist/esm/transport/index.js +1 -1
  66. package/dist/esm/transport/node/builders/group.js +4 -3
  67. package/dist/esm/transport/node/builders/mobile.js +113 -0
  68. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  69. package/dist/esm/transport/noise/WaClientPayload.js +17 -8
  70. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  71. package/dist/esm/util/index.js +1 -0
  72. package/dist/index.d.ts +11 -4
  73. package/dist/index.js +13 -4
  74. package/dist/message/WaMessageClient.js +2 -1
  75. package/dist/message/context-info.d.ts +2 -0
  76. package/dist/message/context-info.js +5 -0
  77. package/dist/protocol/constants.d.ts +6 -4
  78. package/dist/protocol/constants.js +8 -2
  79. package/dist/protocol/device-identity.d.ts +25 -0
  80. package/dist/protocol/device-identity.js +21 -0
  81. package/dist/protocol/group.d.ts +6 -0
  82. package/dist/protocol/group.js +7 -1
  83. package/dist/protocol/message.d.ts +14 -0
  84. package/dist/protocol/message.js +13 -1
  85. package/dist/protocol/nodes.d.ts +16 -0
  86. package/dist/protocol/nodes.js +17 -1
  87. package/dist/protocol/notification.d.ts +2 -0
  88. package/dist/protocol/notification.js +3 -1
  89. package/dist/retry/replay.d.ts +1 -0
  90. package/dist/retry/replay.js +6 -14
  91. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  92. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  93. package/dist/signal/attestation/constants.d.ts +2 -0
  94. package/dist/signal/attestation/constants.js +3 -1
  95. package/dist/signal/index.d.ts +1 -0
  96. package/dist/signal/index.js +13 -1
  97. package/dist/transport/index.d.ts +2 -2
  98. package/dist/transport/index.js +4 -3
  99. package/dist/transport/node/builders/group.js +4 -3
  100. package/dist/transport/node/builders/mobile.d.ts +68 -0
  101. package/dist/transport/node/builders/mobile.js +120 -0
  102. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  103. package/dist/transport/node/builders/shortcake.js +57 -0
  104. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  105. package/dist/transport/noise/WaClientPayload.js +17 -7
  106. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  107. package/dist/transport/wa-version-fetcher.js +156 -0
  108. package/dist/util/index.d.ts +1 -0
  109. package/dist/util/index.js +3 -1
  110. package/package.json +1 -1
  111. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  112. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  113. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -28,14 +28,13 @@ export type WaAppStateMutationInput = {
28
28
  readonly collection: AppStateCollectionName;
29
29
  readonly operation: 'remove';
30
30
  readonly index: string;
31
- readonly previousValue: Proto.ISyncActionValue;
32
31
  readonly version: number;
33
32
  readonly timestamp: number;
34
33
  };
35
34
  export interface WaAppStateMutation {
36
35
  readonly collection: AppStateCollectionName;
37
36
  readonly operation: 'set' | 'remove';
38
- readonly source: 'snapshot' | 'patch';
37
+ readonly source: 'snapshot' | 'patch' | 'local';
39
38
  readonly index: string;
40
39
  readonly value: Proto.ISyncActionValue | null;
41
40
  readonly version: number;
@@ -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,8 +47,11 @@ 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;
54
+ private mobileAppVersionOverride;
47
55
  constructor(options: WaAuthClientOptions, deps: WaAuthClientDeps);
48
56
  /**
49
57
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -78,6 +86,15 @@ export declare class WaAuthClient {
78
86
  * options.
79
87
  */
80
88
  setNextConnectVersion(version: string): void;
89
+ /**
90
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
91
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
92
+ * its version from `deviceInfo.appVersion` (the login payload), not the
93
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
94
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
95
+ * inject a fresh Android app version without mutating the user's options.
96
+ */
97
+ setNextConnectMobileAppVersion(appVersion: string): void;
81
98
  /** Clears the in-memory QR and pairing sessions without touching storage. */
82
99
  clearTransientState(): Promise<void>;
83
100
  /**
@@ -139,6 +156,14 @@ export declare class WaAuthClient {
139
156
  handleIncomingIqSet(node: BinaryNode): Promise<boolean>;
140
157
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
141
158
  handleLinkCodeNotification(node: BinaryNode): Promise<boolean>;
159
+ /**
160
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
161
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
162
+ * and warn instead of silently stalling – which is what happens today when
163
+ * the server demands a passkey after a successful pairing-code
164
+ * `companion_finish`.
165
+ */
166
+ private handleShortcakeNotification;
142
167
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
143
168
  handleCompanionRegRefreshNotification(node: BinaryNode): Promise<boolean>;
144
169
  private patchCredentials;
@@ -4,10 +4,12 @@ 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
- const runtime_1 = require("../util/runtime");
11
13
  /**
12
14
  * Owns the auth/pairing lifecycle and persistence of {@link WaAuthCredentials}.
13
15
  * Exposed as `client.auth` on a {@link WaClient}.
@@ -20,12 +22,8 @@ const runtime_1 = require("../util/runtime");
20
22
  class WaAuthClient {
21
23
  constructor(options, deps) {
22
24
  this.versionOverride = null;
23
- const deviceBrowser = options.deviceBrowser ?? constants_1.WA_DEFAULTS.DEVICE_BROWSER;
24
- const device = Object.freeze({
25
- browser: deviceBrowser,
26
- osDisplayName: options.deviceOsDisplayName ?? (0, runtime_1.getRuntimeOsDisplayName)(),
27
- platform: options.devicePlatform ?? (0, constants_1.getWaCompanionPlatformId)(deviceBrowser)
28
- });
25
+ this.mobileAppVersionOverride = null;
26
+ const device = (0, constants_1.resolveWaDeviceIdentity)(options);
29
27
  this.options = Object.freeze({
30
28
  ...options,
31
29
  deviceBrowser: device.browser,
@@ -62,6 +60,23 @@ class WaAuthClient {
62
60
  },
63
61
  dangerous: options.dangerous
64
62
  });
63
+ this.socket = deps.socket;
64
+ const signAssertion = deps.callbacks?.signPasskeyAssertion;
65
+ this.shortcakeFlow = signAssertion
66
+ ? new WaShortcakeFlow_1.WaShortcakeFlow({
67
+ logger: this.logger,
68
+ socket: {
69
+ sendNode: deps.socket.sendNode,
70
+ query: (node, timeoutMs) => deps.socket.query(node, timeoutMs)
71
+ },
72
+ signAssertion,
73
+ auth: {
74
+ getCredentials: () => this.credentials,
75
+ updateCredentials: this.updateCredentials.bind(this)
76
+ },
77
+ deviceType: (0, WaClientPayload_1.resolveDevicePropsPlatformType)(device.browser)
78
+ })
79
+ : null;
65
80
  }
66
81
  /**
67
82
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -107,12 +122,15 @@ class WaAuthClient {
107
122
  this.logger.trace('auth client building comms config');
108
123
  const override = this.versionOverride;
109
124
  this.versionOverride = null;
125
+ const mobileAppVersionOverride = this.mobileAppVersionOverride;
126
+ this.mobileAppVersionOverride = null;
110
127
  return (0, credentials_flow_1.buildCommsConfig)(this.logger, this.requireCredentials(), socketOptions, {
111
128
  deviceBrowser: this.options.deviceBrowser,
112
129
  deviceOsDisplayName: this.options.deviceOsDisplayName,
113
130
  requireFullSync: this.options.requireFullSync,
114
131
  version: override ?? this.options.version,
115
132
  mobileTransport: this.options.mobileTransport,
133
+ mobileAppVersionOverride: mobileAppVersionOverride ?? undefined,
116
134
  noiseTrustedRootCa: overrides.noiseTrustedRootCa,
117
135
  disableNoiseCertificateChainVerification: overrides.disableNoiseCertificateChainVerification ??
118
136
  this.options.dangerous?.disableNoiseCertificateChainVerification
@@ -128,12 +146,24 @@ class WaAuthClient {
128
146
  setNextConnectVersion(version) {
129
147
  this.versionOverride = version;
130
148
  }
149
+ /**
150
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
151
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
152
+ * its version from `deviceInfo.appVersion` (the login payload), not the
153
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
154
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
155
+ * inject a fresh Android app version without mutating the user's options.
156
+ */
157
+ setNextConnectMobileAppVersion(appVersion) {
158
+ this.mobileAppVersionOverride = appVersion;
159
+ }
131
160
  /** Clears the in-memory QR and pairing sessions without touching storage. */
132
161
  // eslint-disable-next-line @typescript-eslint/require-await
133
162
  async clearTransientState() {
134
163
  this.logger.trace('auth client clear transient state');
135
164
  this.qrFlow.clear();
136
165
  this.pairingFlow.clearSession();
166
+ this.shortcakeFlow?.clearSession();
137
167
  }
138
168
  /**
139
169
  * Wipes credentials from memory **and** from the auth store. Does
@@ -292,9 +322,35 @@ class WaAuthClient {
292
322
  }
293
323
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
294
324
  async handleLinkCodeNotification(node) {
325
+ const type = node.attrs.type;
326
+ if (type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST ||
327
+ type === constants_1.WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
328
+ return this.runHandled(() => this.handleShortcakeNotification(node));
329
+ }
295
330
  this.logger.trace('auth client handleLinkCodeNotification', { id: node.attrs.id });
296
331
  return this.runHandled(() => this.pairingFlow.handleLinkCodeNotification(node));
297
332
  }
333
+ /**
334
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
335
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
336
+ * and warn instead of silently stalling – which is what happens today when
337
+ * the server demands a passkey after a successful pairing-code
338
+ * `companion_finish`.
339
+ */
340
+ async handleShortcakeNotification(node) {
341
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
342
+ this.callbacks.onPasskeyRequired?.(this.shortcakeFlow !== null);
343
+ }
344
+ if (this.shortcakeFlow) {
345
+ return this.shortcakeFlow.handleIncomingNotification(node);
346
+ }
347
+ if (node.attrs.type === constants_1.WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
348
+ this.logger.warn('auth client: server requested passkey prologue but no signPasskeyAssertion configured', { id: node.attrs.id });
349
+ await this.socket.sendNode((0, global_1.buildAckNode)({ kind: 'notification', node }));
350
+ return true;
351
+ }
352
+ return false;
353
+ }
298
354
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
299
355
  async handleCompanionRegRefreshNotification(node) {
300
356
  this.logger.trace('auth client handleCompanionRegRefreshNotification', {
@@ -17,5 +17,13 @@ export declare function persistCredentials(args: WaAuthCredentialsFlowArgs, cred
17
17
  export declare function buildCommsConfig(logger: Logger, credentials: WaAuthCredentials, socketOptions: WaAuthSocketOptions, clientOptions: Pick<WaAuthClientOptions, 'deviceBrowser' | 'deviceOsDisplayName' | 'requireFullSync' | 'version' | 'mobileTransport'> & {
18
18
  readonly noiseTrustedRootCa?: WaNoiseRootCa;
19
19
  readonly disableNoiseCertificateChainVerification?: boolean;
20
+ /**
21
+ * One-shot override for the mobile `deviceInfo.appVersion` on this
22
+ * connect, applied on top of the resolved mobile transport. Used by
23
+ * the `recoverFromClientTooOld` auto-retry to inject a fresh Android
24
+ * app version fetched from the public listing. Ignored for web
25
+ * sessions.
26
+ */
27
+ readonly mobileAppVersionOverride?: string;
20
28
  }): Promise<WaCommsConfig>;
21
29
  export {};
@@ -66,6 +66,26 @@ async function resolveVersion(version) {
66
66
  }
67
67
  return resolved;
68
68
  }
69
+ /**
70
+ * Validates the user-supplied `version` string against the transport it will
71
+ * feed. A mobile session sends the version as the four-part Android app
72
+ * version (`2.YY.WW.RR`) in the login payload, so it must have exactly four
73
+ * numeric parts. A web session advertises up to five parts (`2.3000.x[.y.z]`),
74
+ * so three to five numeric parts are accepted.
75
+ */
76
+ function assertValidVersion(version, mobile) {
77
+ const parts = version.split('.');
78
+ const allNumeric = parts.every((part) => (0, primitives_1.parseOptionalInt)(part) !== undefined);
79
+ if (mobile) {
80
+ if (parts.length !== 4 || !allNumeric) {
81
+ throw new Error(`mobile session requires a 4-part numeric version (e.g. 2.26.27.70), got: ${version}`);
82
+ }
83
+ return;
84
+ }
85
+ if (parts.length < 3 || parts.length > 5 || !allNumeric) {
86
+ throw new Error(`web session requires a 3- to 5-part numeric version (e.g. 2.3000.1040229458), got: ${version}`);
87
+ }
88
+ }
69
89
  async function buildCommsConfig(logger, credentials, socketOptions, clientOptions) {
70
90
  const meJid = credentials.meJid;
71
91
  const registered = meJid !== null && meJid !== undefined;
@@ -86,6 +106,12 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
86
106
  ? 'credentials'
87
107
  : 'none'
88
108
  });
109
+ const resolvedVersion = effectiveMobileTransport && clientOptions.mobileAppVersionOverride !== undefined
110
+ ? undefined
111
+ : await resolveVersion(clientOptions.version);
112
+ if (resolvedVersion !== undefined) {
113
+ assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
114
+ }
89
115
  if (effectiveMobileTransport) {
90
116
  if (wsProxy) {
91
117
  throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
@@ -93,11 +119,18 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
93
119
  if (!loginIdentity) {
94
120
  throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
95
121
  }
122
+ if (clientOptions.mobileAppVersionOverride !== undefined) {
123
+ assertValidVersion(clientOptions.mobileAppVersionOverride, true);
124
+ }
125
+ const appVersionOverride = clientOptions.mobileAppVersionOverride ?? resolvedVersion;
126
+ const deviceInfo = appVersionOverride
127
+ ? { ...effectiveMobileTransport.deviceInfo, appVersion: appVersionOverride }
128
+ : effectiveMobileTransport.deviceInfo;
96
129
  const loginPayload = (0, WaMobileClientPayload_1.buildMobileLoginPayload)({
97
130
  username: loginIdentity.username,
98
131
  device: loginIdentity.device,
99
132
  passive: effectiveMobileTransport.passive ?? false,
100
- deviceInfo: effectiveMobileTransport.deviceInfo,
133
+ deviceInfo,
101
134
  pushName: effectiveMobileTransport.pushName,
102
135
  yearClass: effectiveMobileTransport.yearClass,
103
136
  memClass: effectiveMobileTransport.memClass
@@ -122,7 +155,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
122
155
  }
123
156
  };
124
157
  }
125
- const versionBase = await resolveVersion(clientOptions.version);
158
+ const versionBase = resolvedVersion;
126
159
  return {
127
160
  url: socketOptions.url,
128
161
  urls: socketOptions.urls,
@@ -0,0 +1,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;