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.
- package/dist/appstate/types.d.ts +1 -2
- package/dist/auth/WaAuthClient.d.ts +25 -0
- package/dist/auth/WaAuthClient.js +63 -7
- package/dist/auth/credentials-flow.d.ts +8 -0
- package/dist/auth/credentials-flow.js +35 -2
- package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
- package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
- package/dist/auth/pairing/companion-host.d.ts +105 -0
- package/dist/auth/pairing/companion-host.js +148 -0
- package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
- package/dist/auth/pairing/pairing-code-crypto.js +3 -2
- package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
- package/dist/auth/pairing/shortcake-crypto.js +106 -0
- package/dist/auth/types.d.ts +20 -9
- package/dist/client/WaClient.d.ts +15 -5
- package/dist/client/WaClient.js +46 -12
- package/dist/client/WaClientFactory.d.ts +3 -0
- package/dist/client/WaClientFactory.js +27 -7
- package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
- package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
- package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/client/messaging/companion-host.d.ts +43 -0
- package/dist/client/messaging/companion-host.js +61 -0
- package/dist/client/messaging/key-protocol.js +25 -1
- package/dist/client/persistence/companion-host.d.ts +36 -0
- package/dist/client/persistence/companion-host.js +57 -0
- package/dist/client/plugins/install.d.ts +2 -1
- package/dist/client/plugins/install.js +44 -11
- package/dist/client/types.d.ts +90 -7
- package/dist/esm/auth/WaAuthClient.js +64 -8
- package/dist/esm/auth/credentials-flow.js +36 -3
- package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
- package/dist/esm/auth/pairing/companion-host.js +140 -0
- package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
- package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
- package/dist/esm/client/WaClient.js +46 -12
- package/dist/esm/client/WaClientFactory.js +28 -8
- package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
- package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/esm/client/messaging/companion-host.js +56 -0
- package/dist/esm/client/messaging/key-protocol.js +25 -1
- package/dist/esm/client/persistence/companion-host.js +54 -0
- package/dist/esm/client/plugins/install.js +44 -11
- package/dist/esm/index.js +5 -2
- package/dist/esm/message/WaMessageClient.js +3 -2
- package/dist/esm/message/context-info.js +4 -0
- package/dist/esm/protocol/constants.js +4 -3
- package/dist/esm/protocol/device-identity.js +18 -0
- package/dist/esm/protocol/group.js +6 -0
- package/dist/esm/protocol/message.js +12 -0
- package/dist/esm/protocol/nodes.js +17 -1
- package/dist/esm/protocol/notification.js +3 -1
- package/dist/esm/retry/replay.js +7 -15
- package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
- package/dist/esm/signal/attestation/constants.js +2 -0
- package/dist/esm/signal/index.js +1 -0
- package/dist/esm/transport/index.js +1 -1
- package/dist/esm/transport/node/builders/group.js +4 -3
- package/dist/esm/transport/node/builders/mobile.js +113 -0
- package/dist/esm/transport/node/builders/shortcake.js +50 -0
- package/dist/esm/transport/noise/WaClientPayload.js +17 -8
- package/dist/esm/transport/wa-version-fetcher.js +152 -0
- package/dist/esm/util/index.js +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +13 -4
- package/dist/message/WaMessageClient.js +2 -1
- package/dist/message/context-info.d.ts +2 -0
- package/dist/message/context-info.js +5 -0
- package/dist/protocol/constants.d.ts +6 -4
- package/dist/protocol/constants.js +8 -2
- package/dist/protocol/device-identity.d.ts +25 -0
- package/dist/protocol/device-identity.js +21 -0
- package/dist/protocol/group.d.ts +6 -0
- package/dist/protocol/group.js +7 -1
- package/dist/protocol/message.d.ts +14 -0
- package/dist/protocol/message.js +13 -1
- package/dist/protocol/nodes.d.ts +16 -0
- package/dist/protocol/nodes.js +17 -1
- package/dist/protocol/notification.d.ts +2 -0
- package/dist/protocol/notification.js +3 -1
- package/dist/retry/replay.d.ts +1 -0
- package/dist/retry/replay.js +6 -14
- package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
- package/dist/signal/attestation/WaAdvSignature.js +71 -1
- package/dist/signal/attestation/constants.d.ts +2 -0
- package/dist/signal/attestation/constants.js +3 -1
- package/dist/signal/index.d.ts +1 -0
- package/dist/signal/index.js +13 -1
- package/dist/transport/index.d.ts +2 -2
- package/dist/transport/index.js +4 -3
- package/dist/transport/node/builders/group.js +4 -3
- package/dist/transport/node/builders/mobile.d.ts +68 -0
- package/dist/transport/node/builders/mobile.js +120 -0
- package/dist/transport/node/builders/shortcake.d.ts +19 -0
- package/dist/transport/node/builders/shortcake.js +57 -0
- package/dist/transport/noise/WaClientPayload.d.ts +2 -0
- package/dist/transport/noise/WaClientPayload.js +17 -7
- package/dist/transport/wa-version-fetcher.d.ts +92 -0
- package/dist/transport/wa-version-fetcher.js +156 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +3 -1
- package/package.json +1 -1
- package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
- package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
- package/dist/transport/wa-web-version-fetcher.js +0 -94
package/dist/appstate/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
const device =
|
|
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
|
|
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 =
|
|
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;
|