zapo-js 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/WaAuthClient.d.ts +15 -0
- package/dist/auth/WaAuthClient.js +47 -0
- package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
- package/dist/auth/pairing/WaShortcakeFlow.js +210 -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/client/WaClient.d.ts +84 -49
- package/dist/client/WaClient.js +70 -50
- package/dist/client/WaClientFactory.d.ts +7 -0
- package/dist/client/WaClientFactory.js +4 -1
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/client/plugins/define.d.ts +23 -0
- package/dist/client/plugins/define.js +22 -0
- package/dist/client/plugins/index.d.ts +4 -0
- package/dist/client/plugins/index.js +9 -0
- package/dist/client/plugins/install.d.ts +18 -0
- package/dist/client/plugins/install.js +83 -0
- package/dist/client/plugins/types.d.ts +76 -0
- package/dist/client/plugins/types.js +6 -0
- package/dist/client/types.d.ts +25 -0
- package/dist/esm/auth/WaAuthClient.js +48 -1
- package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -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 +70 -49
- package/dist/esm/client/WaClientFactory.js +4 -1
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/esm/client/plugins/define.js +19 -0
- package/dist/esm/client/plugins/index.js +3 -0
- package/dist/esm/client/plugins/install.js +80 -0
- package/dist/esm/client/plugins/types.js +3 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/infra/log/ConsoleLogger.js +3 -9
- package/dist/esm/infra/log/PinoLogger.js +24 -12
- package/dist/esm/infra/log/types.js +8 -1
- package/dist/esm/protocol/nodes.js +2 -0
- package/dist/esm/protocol/notification.js +3 -1
- package/dist/esm/retry/replay.js +4 -12
- package/dist/esm/transport/index.js +2 -0
- package/dist/esm/transport/node/builders/shortcake.js +50 -0
- package/dist/esm/transport/noise/WaClientPayload.js +1 -1
- package/dist/esm/util/index.js +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +7 -2
- package/dist/infra/log/ConsoleLogger.d.ts +4 -2
- package/dist/infra/log/ConsoleLogger.js +5 -11
- package/dist/infra/log/PinoLogger.d.ts +4 -2
- package/dist/infra/log/PinoLogger.js +24 -12
- package/dist/infra/log/types.d.ts +8 -1
- package/dist/infra/log/types.js +9 -1
- package/dist/protocol/nodes.d.ts +2 -0
- package/dist/protocol/nodes.js +2 -0
- 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 +4 -12
- package/dist/transport/index.d.ts +3 -0
- package/dist/transport/index.js +11 -1
- 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 +1 -0
- package/dist/util/index.d.ts +1 -1
- package/dist/util/index.js +4 -1
- package/package.json +1 -1
package/dist/client/types.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { AppStateCollectionName } from '../appstate/types';
|
|
2
2
|
import type { DataForKey, WaAppstateActionKey, WaAppstateIndexArgs } from '../appstate-spec';
|
|
3
|
+
import type { WaShortcakeAssertionSigner } from '../auth/pairing/WaShortcakeFlow';
|
|
3
4
|
import type { WaAuthClientOptions, WaAuthCredentials, WaAuthDangerousOptions, WaAuthSocketOptions } from '../auth/types';
|
|
4
5
|
import type { WaCallGroupParticipant, WaCallType } from './events/call';
|
|
5
6
|
import type { IncomingPresenceType, PresenceLastSeen } from './events/presence';
|
|
7
|
+
import type { WaClientPluginDefinition } from './plugins/types';
|
|
6
8
|
import type { WaMediaProcessor } from '../media/processor';
|
|
7
9
|
import type { WaLinkPreviewOptions } from '../message/addons/link-preview/types';
|
|
8
10
|
import type { WaQuoteRef, WaSendContextInfo } from '../message/context-info';
|
|
@@ -173,6 +175,11 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
|
|
|
173
175
|
* default auto-fetch globally.
|
|
174
176
|
*/
|
|
175
177
|
readonly linkPreview?: WaLinkPreviewOptions;
|
|
178
|
+
/**
|
|
179
|
+
* Optional client plugins – behavior hooks and/or coordinators exposed at
|
|
180
|
+
* `client[exposeAs]`. See {@link defineWaClientPlugin}.
|
|
181
|
+
*/
|
|
182
|
+
readonly plugins?: readonly WaClientPluginDefinition[];
|
|
176
183
|
/**
|
|
177
184
|
* Test-only overrides intended for running against a fake server.
|
|
178
185
|
*
|
|
@@ -187,6 +194,14 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
|
|
|
187
194
|
* disables a security check the production code path enforces.
|
|
188
195
|
*/
|
|
189
196
|
readonly dangerous?: WaClientDangerousOptions;
|
|
197
|
+
/**
|
|
198
|
+
* External WebAuthn signer. Configuring it enables handling a server-forced
|
|
199
|
+
* passkey ("Shortcake") prologue – e.g. when the server demands a passkey
|
|
200
|
+
* right after a successful pairing-code `companion_finish`. Called with the
|
|
201
|
+
* server's request options; returns the assertion + credential id. The
|
|
202
|
+
* credential source (real/virtual authenticator) stays outside the library.
|
|
203
|
+
*/
|
|
204
|
+
readonly signPasskeyAssertion?: WaShortcakeAssertionSigner;
|
|
190
205
|
}
|
|
191
206
|
export interface WaClientDangerousOptions extends WaAuthDangerousOptions {
|
|
192
207
|
/**
|
|
@@ -1065,6 +1080,16 @@ export interface WaClientEventMap {
|
|
|
1065
1080
|
readonly auth_paired: (event: {
|
|
1066
1081
|
readonly credentials: WaAuthCredentials;
|
|
1067
1082
|
}) => void;
|
|
1083
|
+
/**
|
|
1084
|
+
* The server is forcing a passkey (Shortcake) to link this device – emitted
|
|
1085
|
+
* when the server pushes the prologue request. `hasSigner` is `true` when a
|
|
1086
|
+
* `signPasskeyAssertion` is configured (the handshake proceeds) and `false`
|
|
1087
|
+
* when none is set (the request is acked but the link cannot complete
|
|
1088
|
+
* headless, so the user should be told a passkey is required).
|
|
1089
|
+
*/
|
|
1090
|
+
readonly auth_passkey_required: (event: {
|
|
1091
|
+
readonly hasSigner: boolean;
|
|
1092
|
+
}) => void;
|
|
1068
1093
|
/**
|
|
1069
1094
|
* Connection-state transitions: `'open'` (handshake + auth complete),
|
|
1070
1095
|
* `'connecting'`, or `'close'` (with a `reason` and optional `code`). The
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { buildCommsConfig, loadOrCreateCredentials, persistCredentials } from './credentials-flow.js';
|
|
2
2
|
import { WaPairingFlow } from './pairing/WaPairingFlow.js';
|
|
3
3
|
import { WaQrFlow } from './pairing/WaQrFlow.js';
|
|
4
|
-
import {
|
|
4
|
+
import { WaShortcakeFlow } from './pairing/WaShortcakeFlow.js';
|
|
5
|
+
import { getWaCompanionPlatformId, WA_DEFAULTS, WA_NOTIFICATION_TYPES } from '../protocol/constants.js';
|
|
6
|
+
import { buildAckNode } from '../transport/node/builders/global.js';
|
|
7
|
+
import { resolveDevicePropsPlatformType } from '../transport/noise/WaClientPayload.js';
|
|
5
8
|
import { uint8Equal } from '../util/bytes.js';
|
|
6
9
|
import { toError } from '../util/primitives.js';
|
|
7
10
|
import { getRuntimeOsDisplayName } from '../util/runtime.js';
|
|
@@ -59,6 +62,23 @@ export class WaAuthClient {
|
|
|
59
62
|
},
|
|
60
63
|
dangerous: options.dangerous
|
|
61
64
|
});
|
|
65
|
+
this.socket = deps.socket;
|
|
66
|
+
const signAssertion = deps.callbacks?.signPasskeyAssertion;
|
|
67
|
+
this.shortcakeFlow = signAssertion
|
|
68
|
+
? new WaShortcakeFlow({
|
|
69
|
+
logger: this.logger,
|
|
70
|
+
socket: {
|
|
71
|
+
sendNode: deps.socket.sendNode,
|
|
72
|
+
query: (node, timeoutMs) => deps.socket.query(node, timeoutMs)
|
|
73
|
+
},
|
|
74
|
+
signAssertion,
|
|
75
|
+
auth: {
|
|
76
|
+
getCredentials: () => this.credentials,
|
|
77
|
+
updateCredentials: this.updateCredentials.bind(this)
|
|
78
|
+
},
|
|
79
|
+
deviceType: resolveDevicePropsPlatformType(deviceBrowser)
|
|
80
|
+
})
|
|
81
|
+
: null;
|
|
62
82
|
}
|
|
63
83
|
/**
|
|
64
84
|
* Returns a snapshot of auth readiness flags (connection, registration,
|
|
@@ -131,6 +151,7 @@ export class WaAuthClient {
|
|
|
131
151
|
this.logger.trace('auth client clear transient state');
|
|
132
152
|
this.qrFlow.clear();
|
|
133
153
|
this.pairingFlow.clearSession();
|
|
154
|
+
this.shortcakeFlow?.clearSession();
|
|
134
155
|
}
|
|
135
156
|
/**
|
|
136
157
|
* Wipes credentials from memory **and** from the auth store. Does
|
|
@@ -289,9 +310,35 @@ export class WaAuthClient {
|
|
|
289
310
|
}
|
|
290
311
|
/** Dispatcher: returns `true` when `node` is a link-code companion notification. */
|
|
291
312
|
async handleLinkCodeNotification(node) {
|
|
313
|
+
const type = node.attrs.type;
|
|
314
|
+
if (type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST ||
|
|
315
|
+
type === WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
|
|
316
|
+
return this.runHandled(() => this.handleShortcakeNotification(node));
|
|
317
|
+
}
|
|
292
318
|
this.logger.trace('auth client handleLinkCodeNotification', { id: node.attrs.id });
|
|
293
319
|
return this.runHandled(() => this.pairingFlow.handleLinkCodeNotification(node));
|
|
294
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Handles the server-forced passkey (Shortcake) prologue. With a configured
|
|
323
|
+
* `signPasskeyAssertion` the full handshake runs; without one we just ack
|
|
324
|
+
* and warn instead of silently stalling – which is what happens today when
|
|
325
|
+
* the server demands a passkey after a successful pairing-code
|
|
326
|
+
* `companion_finish`.
|
|
327
|
+
*/
|
|
328
|
+
async handleShortcakeNotification(node) {
|
|
329
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
|
|
330
|
+
this.callbacks.onPasskeyRequired?.(this.shortcakeFlow !== null);
|
|
331
|
+
}
|
|
332
|
+
if (this.shortcakeFlow) {
|
|
333
|
+
return this.shortcakeFlow.handleIncomingNotification(node);
|
|
334
|
+
}
|
|
335
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
|
|
336
|
+
this.logger.warn('auth client: server requested passkey prologue but no signPasskeyAssertion configured', { id: node.attrs.id });
|
|
337
|
+
await this.socket.sendNode(buildAckNode({ kind: 'notification', node }));
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
295
342
|
/** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
|
|
296
343
|
async handleCompanionRegRefreshNotification(node) {
|
|
297
344
|
this.logger.trace('auth client handleCompanionRegRefreshNotification', {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { decodePrimaryEphemeralIdentity, deriveEncryptionKey, deriveVerificationCode, encryptPairingRequest, generateCompanionEphemeralIdentity } from '../pairing/shortcake-crypto.js';
|
|
2
|
+
import { hkdf, hmacSha256Sign, randomBytesAsync } from '../../crypto/index.js';
|
|
3
|
+
import { proto } from '../../proto.js';
|
|
4
|
+
import { WA_DEFAULTS, WA_NODE_TAGS, WA_NOTIFICATION_TYPES } from '../../protocol/constants.js';
|
|
5
|
+
import { buildAckNode } from '../../transport/node/builders/global.js';
|
|
6
|
+
import { buildGetPasskeyRequestOptionsRequestNode, buildSetCompanionNonceRequestNode, buildSetEncryptedPairingRequestRequestNode, buildSetPasskeyPrologueRequestNode, buildShortcakeGetRefRequestNode } from '../../transport/node/builders/shortcake.js';
|
|
7
|
+
import { decodeNodeContentUtf8OrBytes, findNodeChild } from '../../transport/node/helpers.js';
|
|
8
|
+
import { assertIqResult } from '../../transport/node/query.js';
|
|
9
|
+
import { TEXT_DECODER, TEXT_ENCODER } from '../../util/bytes.js';
|
|
10
|
+
/** HKDF info for the pairing handoff HMAC key (derived from the ADV secret). */
|
|
11
|
+
const HANDOFF_KEY_INFO = TEXT_ENCODER.encode('shortcake-passkey-handoff-v1');
|
|
12
|
+
const HANDOFF_KEY_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
const Stage = Object.freeze({
|
|
14
|
+
Idle: 'idle',
|
|
15
|
+
WaitingForPrimaryIdentity: 'waiting_for_primary_identity',
|
|
16
|
+
WaitingForConfirmation: 'waiting_for_confirmation',
|
|
17
|
+
WaitingForPairing: 'waiting_for_pairing'
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Drives the companion side of the WhatsApp "Shortcake" passkey-linking
|
|
21
|
+
* handshake (the `xmlns="md"` IQ exchange + commit/reveal ECDH). It owns the
|
|
22
|
+
* wire protocol and crypto only; the WebAuthn assertion and the registration
|
|
23
|
+
* payload are injected by the caller.
|
|
24
|
+
*/
|
|
25
|
+
export class WaShortcakeFlow {
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.opts = options;
|
|
28
|
+
this.session = null;
|
|
29
|
+
this.handoffKey = null;
|
|
30
|
+
}
|
|
31
|
+
hasSession() {
|
|
32
|
+
return this.session !== null;
|
|
33
|
+
}
|
|
34
|
+
clearSession() {
|
|
35
|
+
this.session = null;
|
|
36
|
+
this.handoffKey = null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Runs the prologue: fetch the request options, obtain the WebAuthn
|
|
40
|
+
* assertion (external), fetch the ref, build the companion ephemeral
|
|
41
|
+
* identity + commitment, and send the `passkey_prologue` IQ.
|
|
42
|
+
*/
|
|
43
|
+
async executePrologue(args = {}) {
|
|
44
|
+
const deviceType = this.opts.deviceType ?? proto.DeviceProps.PlatformType.CHROME;
|
|
45
|
+
const requestOptions = args.requestOptions ?? (await this.requestPasskeyRequestOptions());
|
|
46
|
+
const assertion = await this.opts.signAssertion(requestOptions);
|
|
47
|
+
const ref = await this.requestRef();
|
|
48
|
+
const companion = await generateCompanionEphemeralIdentity({ ref, deviceType });
|
|
49
|
+
let pairingHandoffProof = args.pairingHandoffProof;
|
|
50
|
+
const handoffKey = this.handoffKey;
|
|
51
|
+
this.handoffKey = null;
|
|
52
|
+
if (pairingHandoffProof === undefined &&
|
|
53
|
+
handoffKey !== null &&
|
|
54
|
+
Date.now() - handoffKey.ts < HANDOFF_KEY_TTL_MS) {
|
|
55
|
+
pairingHandoffProof = hmacSha256Sign(handoffKey.hmac, companion.prologuePayloadBytes);
|
|
56
|
+
}
|
|
57
|
+
const skipHandoffUx = pairingHandoffProof !== undefined;
|
|
58
|
+
const response = await this.opts.socket.query(buildSetPasskeyPrologueRequestNode({
|
|
59
|
+
credentialId: assertion.credentialId,
|
|
60
|
+
webauthnAssertion: assertion.webauthnAssertion,
|
|
61
|
+
prologuePayload: companion.prologuePayloadBytes,
|
|
62
|
+
pairingHandoffProof
|
|
63
|
+
}), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
64
|
+
assertIqResult(response, 'shortcake set-passkey-prologue');
|
|
65
|
+
this.session = {
|
|
66
|
+
companion,
|
|
67
|
+
ref,
|
|
68
|
+
deviceType,
|
|
69
|
+
skipHandoffUx,
|
|
70
|
+
stage: Stage.WaitingForPrimaryIdentity,
|
|
71
|
+
encryptionKey: null,
|
|
72
|
+
verificationCode: null
|
|
73
|
+
};
|
|
74
|
+
this.opts.logger.debug('shortcake prologue sent', { ref, skipHandoffUx });
|
|
75
|
+
this.opts.callbacks?.emitPrologueSent?.();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Routes the two server-pushed Shortcake notifications. Returns `true` when
|
|
79
|
+
* the notification was a Shortcake one (and was consumed):
|
|
80
|
+
* - `passkey_prologue_request` kicks the flow off (server forces passkey,
|
|
81
|
+
* often right after a pairing-code `companion_finish`); the WebAuthn
|
|
82
|
+
* options are usually embedded.
|
|
83
|
+
* - `crsc_continuation` carries the primary's ephemeral identity.
|
|
84
|
+
*/
|
|
85
|
+
async handleIncomingNotification(node) {
|
|
86
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
|
|
87
|
+
return this.handlePasskeyPrologueRequest(node);
|
|
88
|
+
}
|
|
89
|
+
if (node.attrs.type === WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
|
|
90
|
+
return this.handlePrimaryEphemeralIdentity(node);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
async handlePasskeyPrologueRequest(node) {
|
|
95
|
+
await this.ackNotification(node);
|
|
96
|
+
await this.stashHandoffKeyAndRotateAdv();
|
|
97
|
+
const optionsNode = findNodeChild(node, WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
|
|
98
|
+
const requestOptions = optionsNode
|
|
99
|
+
? decodeNodeContentUtf8OrBytes(optionsNode.content, 'shortcake.passkey_request_options')
|
|
100
|
+
: undefined;
|
|
101
|
+
this.opts.logger.debug('shortcake prologue requested by server', {
|
|
102
|
+
embeddedOptions: optionsNode !== undefined
|
|
103
|
+
});
|
|
104
|
+
await this.executePrologue({ requestOptions });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Derives a pairing handoff HMAC key from the current ADV secret and rotates
|
|
109
|
+
* the ADV secret (matching the official clients). The derived key signs the
|
|
110
|
+
* next prologue's handoff proof; the rotated secret goes into the eventual
|
|
111
|
+
* `PairingRequest`.
|
|
112
|
+
*/
|
|
113
|
+
async stashHandoffKeyAndRotateAdv() {
|
|
114
|
+
const credentials = this.opts.auth.getCredentials();
|
|
115
|
+
if (!credentials) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.handoffKey = {
|
|
119
|
+
hmac: hkdf(credentials.advSecretKey, null, HANDOFF_KEY_INFO, 32),
|
|
120
|
+
ts: Date.now()
|
|
121
|
+
};
|
|
122
|
+
await this.opts.auth.updateCredentials({
|
|
123
|
+
...credentials,
|
|
124
|
+
advSecretKey: await randomBytesAsync(32)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async handlePrimaryEphemeralIdentity(node) {
|
|
128
|
+
const child = findNodeChild(node, WA_NODE_TAGS.PRIMARY_EPHEMERAL_IDENTITY);
|
|
129
|
+
if (!child) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const session = this.session;
|
|
133
|
+
if (!session || session.stage !== Stage.WaitingForPrimaryIdentity) {
|
|
134
|
+
this.opts.logger.warn('shortcake primary identity ignored: no active prologue');
|
|
135
|
+
await this.ackNotification(node);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
await this.ackNotification(node);
|
|
139
|
+
const primary = decodePrimaryEphemeralIdentity(decodeNodeContentUtf8OrBytes(child.content, 'shortcake.primary_ephemeral_identity'));
|
|
140
|
+
const nonceResponse = await this.opts.socket.query(buildSetCompanionNonceRequestNode(session.companion.companionNonce), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
141
|
+
assertIqResult(nonceResponse, 'shortcake set-companion-nonce');
|
|
142
|
+
const verificationCode = deriveVerificationCode(session.companion.companionNonce, primary);
|
|
143
|
+
const encryptionKey = await deriveEncryptionKey({
|
|
144
|
+
companionPrivKey: session.companion.keyPair.privKey,
|
|
145
|
+
primaryPublicKey: primary.publicKey,
|
|
146
|
+
deviceType: session.deviceType,
|
|
147
|
+
ref: session.ref
|
|
148
|
+
});
|
|
149
|
+
session.encryptionKey = encryptionKey;
|
|
150
|
+
session.verificationCode = verificationCode;
|
|
151
|
+
session.stage = Stage.WaitingForConfirmation;
|
|
152
|
+
this.opts.logger.debug('shortcake verification code ready');
|
|
153
|
+
this.opts.callbacks?.emitVerificationCode?.(verificationCode);
|
|
154
|
+
await this.confirmVerificationCode();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/** Current verification code, once derived. */
|
|
158
|
+
getVerificationCode() {
|
|
159
|
+
return this.session?.verificationCode ?? null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Confirms the verification code: builds + AES-GCM seals the pairing request
|
|
163
|
+
* and sends the `encrypted_pairing_request` IQ.
|
|
164
|
+
*/
|
|
165
|
+
async confirmVerificationCode() {
|
|
166
|
+
const session = this.session;
|
|
167
|
+
if (!session || session.stage !== Stage.WaitingForConfirmation || !session.encryptionKey) {
|
|
168
|
+
throw new Error('shortcake: no verification code awaiting confirmation');
|
|
169
|
+
}
|
|
170
|
+
const credentials = this.opts.auth.getCredentials();
|
|
171
|
+
if (!credentials) {
|
|
172
|
+
throw new Error('shortcake: credentials are not initialized');
|
|
173
|
+
}
|
|
174
|
+
const plaintext = proto.PairingRequest.encode({
|
|
175
|
+
companionPublicKey: credentials.noiseKeyPair.pubKey,
|
|
176
|
+
companionIdentityKey: credentials.registrationInfo.identityKeyPair.pubKey,
|
|
177
|
+
advSecret: credentials.advSecretKey
|
|
178
|
+
}).finish();
|
|
179
|
+
const envelope = await encryptPairingRequest(session.encryptionKey, plaintext);
|
|
180
|
+
const response = await this.opts.socket.query(buildSetEncryptedPairingRequestRequestNode(envelope), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
181
|
+
assertIqResult(response, 'shortcake set-encrypted-pairing-request');
|
|
182
|
+
session.stage = Stage.WaitingForPairing;
|
|
183
|
+
this.opts.logger.debug('shortcake encrypted pairing request sent');
|
|
184
|
+
}
|
|
185
|
+
async requestPasskeyRequestOptions() {
|
|
186
|
+
const response = await this.opts.socket.query(buildGetPasskeyRequestOptionsRequestNode(), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
187
|
+
assertIqResult(response, 'shortcake get-passkey-request-options');
|
|
188
|
+
const optionsNode = findNodeChild(response, WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS);
|
|
189
|
+
if (!optionsNode) {
|
|
190
|
+
throw new Error('shortcake: get-passkey-request-options response missing options');
|
|
191
|
+
}
|
|
192
|
+
return decodeNodeContentUtf8OrBytes(optionsNode.content, 'shortcake.passkey_request_options');
|
|
193
|
+
}
|
|
194
|
+
async requestRef() {
|
|
195
|
+
const response = await this.opts.socket.query(buildShortcakeGetRefRequestNode(), WA_DEFAULTS.IQ_TIMEOUT_MS);
|
|
196
|
+
assertIqResult(response, 'shortcake get-ref');
|
|
197
|
+
const refNode = findNodeChild(response, WA_NODE_TAGS.REF);
|
|
198
|
+
if (!refNode) {
|
|
199
|
+
throw new Error('shortcake: get-ref response missing ref');
|
|
200
|
+
}
|
|
201
|
+
return TEXT_DECODER.decode(decodeNodeContentUtf8OrBytes(refNode.content, 'shortcake.ref'));
|
|
202
|
+
}
|
|
203
|
+
async ackNotification(node) {
|
|
204
|
+
await this.opts.socket.sendNode(buildAckNode({ kind: 'notification', node }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -5,7 +5,7 @@ import { concatBytes, TEXT_ENCODER } from '../../util/bytes.js';
|
|
|
5
5
|
const CROCKFORD_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';
|
|
6
6
|
export const PBKDF2_ITERATIONS = 2 << 16;
|
|
7
7
|
const PAIRING_AES_KEY_BYTES = 32;
|
|
8
|
-
function
|
|
8
|
+
export function encodeBytesToCrockford(bytes) {
|
|
9
9
|
let bitCount = 0;
|
|
10
10
|
let value = 0;
|
|
11
11
|
let out = '';
|
|
@@ -42,7 +42,7 @@ export async function createCompanionHello(options = {}) {
|
|
|
42
42
|
randomBytesAsync(16),
|
|
43
43
|
normalizedCustomCode !== null ? Promise.resolve(null) : randomBytesAsync(5)
|
|
44
44
|
]);
|
|
45
|
-
const pairingCode = normalizedCustomCode ??
|
|
45
|
+
const pairingCode = normalizedCustomCode ?? encodeBytesToCrockford(codeBytes);
|
|
46
46
|
const cipherKey = await pbkdf2Sha256(TEXT_ENCODER.encode(pairingCode), salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
47
47
|
const encrypted = aesCtrEncrypt(cipherKey, counter, companionEphemeralKeyPair.pubKey);
|
|
48
48
|
return {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { encodeBytesToCrockford } from '../pairing/pairing-code-crypto.js';
|
|
2
|
+
import { aesGcmEncrypt, hkdf, randomBytesAsync, sha256 } from '../../crypto/index.js';
|
|
3
|
+
import { X25519 } from '../../crypto/curves/X25519.js';
|
|
4
|
+
import { proto } from '../../proto.js';
|
|
5
|
+
import { concatBytes, TEXT_ENCODER } from '../../util/bytes.js';
|
|
6
|
+
/**
|
|
7
|
+
* Cryptographic core of the WhatsApp "Shortcake" companion-linking protocol
|
|
8
|
+
* (the device-link flow the official clients call CRSC / Shortcake).
|
|
9
|
+
*
|
|
10
|
+
* This module implements ONLY the wire/crypto half: ephemeral X25519 key
|
|
11
|
+
* exchange, the commit-reveal nonce, the verification code, and the AES-GCM
|
|
12
|
+
* pairing envelope. The WebAuthn/passkey assertion that gates the prologue is
|
|
13
|
+
* NOT produced here – it is an opaque input supplied by the caller (so the
|
|
14
|
+
* credential source stays out of the protocol layer).
|
|
15
|
+
*
|
|
16
|
+
* Mirrors `WAWebShortcakeLinkingAlgorithm` from the web client.
|
|
17
|
+
*/
|
|
18
|
+
const NONCE_BYTES = 32;
|
|
19
|
+
const VERIFICATION_CODE_BYTES = 5;
|
|
20
|
+
const GCM_IV_BYTES = 12;
|
|
21
|
+
const ENCRYPTION_KEY_BYTES = 32;
|
|
22
|
+
const EPHEMERAL_PUBLIC_KEY_BYTES = 32;
|
|
23
|
+
/** HKDF `info` for the pairing-request encryption key. */
|
|
24
|
+
const ENCRYPTION_KEY_INFO = TEXT_ENCODER.encode('Pairing Information Encryption Key');
|
|
25
|
+
/**
|
|
26
|
+
* Generates the companion's ephemeral identity + commitment for a Shortcake
|
|
27
|
+
* prologue. The companion publishes a commitment to its nonce now and reveals
|
|
28
|
+
* the nonce later (after the primary's identity arrives) so the primary cannot
|
|
29
|
+
* grind the verification code.
|
|
30
|
+
*/
|
|
31
|
+
export async function generateCompanionEphemeralIdentity(args) {
|
|
32
|
+
const [keyPair, companionNonce] = await Promise.all([
|
|
33
|
+
X25519.generateKeyPair(),
|
|
34
|
+
randomBytesAsync(NONCE_BYTES)
|
|
35
|
+
]);
|
|
36
|
+
const companionEphemeralIdentityBytes = proto.CompanionEphemeralIdentity.encode({
|
|
37
|
+
publicKey: keyPair.pubKey,
|
|
38
|
+
deviceType: args.deviceType,
|
|
39
|
+
ref: args.ref
|
|
40
|
+
}).finish();
|
|
41
|
+
const commitmentHash = sha256(concatBytes([companionEphemeralIdentityBytes, companionNonce]));
|
|
42
|
+
const prologuePayloadBytes = proto.ProloguePayload.encode({
|
|
43
|
+
companionEphemeralIdentity: companionEphemeralIdentityBytes,
|
|
44
|
+
commitment: { hash: commitmentHash }
|
|
45
|
+
}).finish();
|
|
46
|
+
return {
|
|
47
|
+
keyPair,
|
|
48
|
+
companionNonce,
|
|
49
|
+
companionEphemeralIdentityBytes,
|
|
50
|
+
commitmentHash,
|
|
51
|
+
prologuePayloadBytes
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
|
|
55
|
+
export function decodePrimaryEphemeralIdentity(bytes) {
|
|
56
|
+
const decoded = proto.PrimaryEphemeralIdentity.decode(bytes);
|
|
57
|
+
const publicKey = decoded.publicKey;
|
|
58
|
+
const nonce = decoded.nonce;
|
|
59
|
+
if (!publicKey || publicKey.length !== EPHEMERAL_PUBLIC_KEY_BYTES) {
|
|
60
|
+
throw new Error('shortcake: PrimaryEphemeralIdentity.publicKey must be 32 bytes');
|
|
61
|
+
}
|
|
62
|
+
if (!nonce || nonce.length !== NONCE_BYTES) {
|
|
63
|
+
throw new Error('shortcake: PrimaryEphemeralIdentity.nonce must be 32 bytes');
|
|
64
|
+
}
|
|
65
|
+
return { publicKey, nonce };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Derives the short verification code shown on both devices:
|
|
69
|
+
* `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
|
|
70
|
+
*/
|
|
71
|
+
export function deriveVerificationCode(companionNonce, primary) {
|
|
72
|
+
const digest = sha256(concatBytes([companionNonce, primary.publicKey]));
|
|
73
|
+
const code = new Uint8Array(VERIFICATION_CODE_BYTES);
|
|
74
|
+
for (let i = 0; i < VERIFICATION_CODE_BYTES; i += 1) {
|
|
75
|
+
code[i] = primary.nonce[i] ^ digest[i];
|
|
76
|
+
}
|
|
77
|
+
return encodeBytesToCrockford(code);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Derives the AES-GCM key that protects the pairing request:
|
|
81
|
+
* `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
|
|
82
|
+
*/
|
|
83
|
+
export async function deriveEncryptionKey(args) {
|
|
84
|
+
const shared = await X25519.scalarMult(args.companionPrivKey, args.primaryPublicKey);
|
|
85
|
+
const salt = TEXT_ENCODER.encode(`Companion Pairing ${String(args.deviceType)} with ref ${args.ref}`);
|
|
86
|
+
return hkdf(shared, salt, ENCRYPTION_KEY_INFO, ENCRYPTION_KEY_BYTES);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Encrypts the pairing request plaintext under the derived key and returns the
|
|
90
|
+
* encoded `EncryptedPairingRequest` proto (random 12-byte IV).
|
|
91
|
+
*/
|
|
92
|
+
export async function encryptPairingRequest(encryptionKey, plaintext) {
|
|
93
|
+
if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
|
|
94
|
+
throw new Error('shortcake: encryption key must be 32 bytes');
|
|
95
|
+
}
|
|
96
|
+
const iv = await randomBytesAsync(GCM_IV_BYTES);
|
|
97
|
+
const encryptedPayload = aesGcmEncrypt(encryptionKey, iv, plaintext);
|
|
98
|
+
return proto.EncryptedPairingRequest.encode({ encryptedPayload, iv }).finish();
|
|
99
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createIgnoreKeyFilter, validateIgnoreKey } from './messaging/ignore-key
|
|
|
3
3
|
import { runHistorySyncNotification } from './persistence/history-sync.js';
|
|
4
4
|
import { persistIncomingMailboxEntities } from './persistence/mailbox.js';
|
|
5
5
|
import { WriteBehindPersistence } from './persistence/WriteBehindPersistence.js';
|
|
6
|
+
import { installWaClientPlugins } from './plugins/install.js';
|
|
6
7
|
import { buildWaClientDependencies, resolveWaClientBase } from './WaClientFactory.js';
|
|
7
8
|
import { ConsoleLogger } from '../infra/log/ConsoleLogger.js';
|
|
8
9
|
import { proto } from '../proto.js';
|
|
@@ -19,55 +20,8 @@ const SYNC_RELATED_PROTOCOL_TYPES = new Set([
|
|
|
19
20
|
proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_MESSAGE,
|
|
20
21
|
proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE
|
|
21
22
|
]);
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
* coordinators (accessible via getters such as {@link message}, {@link group},
|
|
25
|
-
* {@link newsletter}, etc.) and re-emits every {@link WaClientEventMap} event.
|
|
26
|
-
*
|
|
27
|
-
* Lifecycle: construct with {@link WaClientOptions}, call {@link connect} to
|
|
28
|
-
* open the socket, react to `connection`/`auth_qr`/`auth_pairing_code` events,
|
|
29
|
-
* then use the coordinator getters to drive the session. Call {@link disconnect}
|
|
30
|
-
* to shut down cleanly or {@link logout} to remove the companion device.
|
|
31
|
-
*
|
|
32
|
-
* @example
|
|
33
|
-
* ```ts
|
|
34
|
-
* import { createPinoLogger, createStore, WaClient } from '..'
|
|
35
|
-
* import { createSqliteStore } from '@zapo-js/store-sqlite'
|
|
36
|
-
*
|
|
37
|
-
* const store = createStore({
|
|
38
|
-
* backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite' }) },
|
|
39
|
-
* providers: {
|
|
40
|
-
* auth: 'sqlite',
|
|
41
|
-
* signal: 'sqlite',
|
|
42
|
-
* preKey: 'sqlite',
|
|
43
|
-
* session: 'sqlite',
|
|
44
|
-
* identity: 'sqlite',
|
|
45
|
-
* senderKey: 'sqlite',
|
|
46
|
-
* appState: 'sqlite',
|
|
47
|
-
* privacyToken: 'sqlite',
|
|
48
|
-
* messages: 'sqlite',
|
|
49
|
-
* threads: 'sqlite',
|
|
50
|
-
* contacts: 'sqlite'
|
|
51
|
-
* }
|
|
52
|
-
* })
|
|
53
|
-
*
|
|
54
|
-
* const client = new WaClient(
|
|
55
|
-
* { store, sessionId: 'default' },
|
|
56
|
-
* await createPinoLogger({ level: 'info', pretty: true })
|
|
57
|
-
* )
|
|
58
|
-
*
|
|
59
|
-
* client.on('auth_qr', ({ qr, ttlMs }) => console.log('scan:', qr, ttlMs))
|
|
60
|
-
* client.on('connection', (event) => console.log('connection', event))
|
|
61
|
-
* client.on('message', async (event) => {
|
|
62
|
-
* if (event.message?.conversation === 'ping') {
|
|
63
|
-
* await client.message.send(event.chatJid!, 'pong')
|
|
64
|
-
* }
|
|
65
|
-
* })
|
|
66
|
-
*
|
|
67
|
-
* await client.connect()
|
|
68
|
-
* ```
|
|
69
|
-
*/
|
|
70
|
-
export class WaClient extends EventEmitter {
|
|
23
|
+
/** @internal Implementation backing the exported {@link WaClient}. */
|
|
24
|
+
class WaClientImpl extends EventEmitter {
|
|
71
25
|
/**
|
|
72
26
|
* @param options Client configuration (store, transport, addons, history...).
|
|
73
27
|
* @param logger Optional structured logger. Defaults to a `ConsoleLogger('info')`.
|
|
@@ -78,6 +32,7 @@ export class WaClient extends EventEmitter {
|
|
|
78
32
|
this.acceptingIncomingEvents = true;
|
|
79
33
|
this.activeIncomingHandlers = 0;
|
|
80
34
|
this.incomingHandlersDrainedWaiters = [];
|
|
35
|
+
this.disposePlugins = null;
|
|
81
36
|
const base = resolveWaClientBase(options, logger);
|
|
82
37
|
this.options = base.options;
|
|
83
38
|
this.logger = base.logger;
|
|
@@ -115,6 +70,13 @@ export class WaClient extends EventEmitter {
|
|
|
115
70
|
this.deps = dependencies;
|
|
116
71
|
this.appStateSync = dependencies.appStateSync;
|
|
117
72
|
this.mediaTransfer = dependencies.mediaTransfer;
|
|
73
|
+
this.disposePlugins = installWaClientPlugins(this, {
|
|
74
|
+
options: this.options,
|
|
75
|
+
logger: this.logger,
|
|
76
|
+
stores: this.stores,
|
|
77
|
+
deps: this.deps,
|
|
78
|
+
queryWithContext: this.queryWithContext.bind(this)
|
|
79
|
+
}, this.options.plugins ?? []);
|
|
118
80
|
this.bindNodeTransportEvents();
|
|
119
81
|
this.on('connection', (event) => {
|
|
120
82
|
if (event.status !== 'close')
|
|
@@ -381,6 +343,11 @@ export class WaClient extends EventEmitter {
|
|
|
381
343
|
remaining: writeBehindFlush.remaining
|
|
382
344
|
});
|
|
383
345
|
}
|
|
346
|
+
if (this.disposePlugins) {
|
|
347
|
+
const dispose = this.disposePlugins;
|
|
348
|
+
this.disposePlugins = null;
|
|
349
|
+
await dispose();
|
|
350
|
+
}
|
|
384
351
|
await this.deps.connectionManager.disconnect();
|
|
385
352
|
this.emit('connection', {
|
|
386
353
|
status: 'close',
|
|
@@ -603,3 +570,57 @@ export class WaClient extends EventEmitter {
|
|
|
603
570
|
this.emit('debug_client_error', { error });
|
|
604
571
|
}
|
|
605
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Top-level WhatsApp client. Owns the transport, auth, signal, and per-feature
|
|
575
|
+
* coordinators (accessible via getters such as {@link message}, {@link group},
|
|
576
|
+
* {@link newsletter}, etc.) and re-emits every {@link WaClientEventMap} event.
|
|
577
|
+
*
|
|
578
|
+
* Lifecycle: construct with {@link WaClientOptions}, call {@link connect} to
|
|
579
|
+
* open the socket, react to `connection`/`auth_qr`/`auth_pairing_code` events,
|
|
580
|
+
* then use the coordinator getters to drive the session. Call {@link disconnect}
|
|
581
|
+
* to shut down cleanly or {@link logout} to remove the companion device.
|
|
582
|
+
*
|
|
583
|
+
* Pass `plugins` to expose plugin getters and events on the returned client,
|
|
584
|
+
* typed from the values you pass: e.g. `plugins: [voipPlugin()]` adds
|
|
585
|
+
* `client.voip` and the `voip_*` events, and only then. See
|
|
586
|
+
* {@link WaClientConstructor}.
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* ```ts
|
|
590
|
+
* import { createPinoLogger, createStore, WaClient } from '..'
|
|
591
|
+
* import { createSqliteStore } from '@zapo-js/store-sqlite'
|
|
592
|
+
*
|
|
593
|
+
* const store = createStore({
|
|
594
|
+
* backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite' }) },
|
|
595
|
+
* providers: {
|
|
596
|
+
* auth: 'sqlite',
|
|
597
|
+
* signal: 'sqlite',
|
|
598
|
+
* preKey: 'sqlite',
|
|
599
|
+
* session: 'sqlite',
|
|
600
|
+
* identity: 'sqlite',
|
|
601
|
+
* senderKey: 'sqlite',
|
|
602
|
+
* appState: 'sqlite',
|
|
603
|
+
* privacyToken: 'sqlite',
|
|
604
|
+
* messages: 'sqlite',
|
|
605
|
+
* threads: 'sqlite',
|
|
606
|
+
* contacts: 'sqlite'
|
|
607
|
+
* }
|
|
608
|
+
* })
|
|
609
|
+
*
|
|
610
|
+
* const client = new WaClient(
|
|
611
|
+
* { store, sessionId: 'default' },
|
|
612
|
+
* await createPinoLogger({ level: 'info', pretty: true })
|
|
613
|
+
* )
|
|
614
|
+
*
|
|
615
|
+
* client.on('auth_qr', ({ qr, ttlMs }) => console.log('scan:', qr, ttlMs))
|
|
616
|
+
* client.on('connection', (event) => console.log('connection', event))
|
|
617
|
+
* client.on('message', async (event) => {
|
|
618
|
+
* if (event.message?.conversation === 'ping') {
|
|
619
|
+
* await client.message.send(event.chatJid!, 'pong')
|
|
620
|
+
* }
|
|
621
|
+
* })
|
|
622
|
+
*
|
|
623
|
+
* await client.connect()
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
export const WaClient = WaClientImpl;
|
|
@@ -348,7 +348,9 @@ export function buildWaClientDependencies(input) {
|
|
|
348
348
|
runtime.emitEvent('auth_paired', { credentials });
|
|
349
349
|
scheduleReconnectAfterPairing();
|
|
350
350
|
},
|
|
351
|
-
onError: (error) => runtime.handleError(error)
|
|
351
|
+
onError: (error) => runtime.handleError(error),
|
|
352
|
+
onPasskeyRequired: (hasSigner) => runtime.emitEvent('auth_passkey_required', { hasSigner }),
|
|
353
|
+
signPasskeyAssertion: options.signPasskeyAssertion
|
|
352
354
|
}
|
|
353
355
|
});
|
|
354
356
|
const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
|
|
@@ -1026,6 +1028,7 @@ export function buildWaClientDependencies(input) {
|
|
|
1026
1028
|
signalMissingPreKeysSync,
|
|
1027
1029
|
signalRotateKey,
|
|
1028
1030
|
signalSessionSync,
|
|
1031
|
+
sessionResolver,
|
|
1029
1032
|
authClient,
|
|
1030
1033
|
messageDispatch,
|
|
1031
1034
|
messageCoordinator,
|
|
@@ -61,6 +61,7 @@ export class WaRetryCoordinator {
|
|
|
61
61
|
signalProtocol: options.signalProtocol,
|
|
62
62
|
sessionResolver: options.sessionResolver,
|
|
63
63
|
getCurrentCredentials: options.getCurrentCredentials,
|
|
64
|
+
isMobilePrimary: options.isMobilePrimary,
|
|
64
65
|
resolveUserIcdc: options.resolveUserIcdc,
|
|
65
66
|
resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
|
|
66
67
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function defineWaClientPlugin(input) {
|
|
2
|
+
if ('exposeAs' in input && input.exposeAs !== undefined) {
|
|
3
|
+
const exposeInput = input;
|
|
4
|
+
return {
|
|
5
|
+
id: exposeInput.id,
|
|
6
|
+
exposeAs: exposeInput.exposeAs,
|
|
7
|
+
setup: exposeInput.setup,
|
|
8
|
+
dispose: exposeInput.dispose
|
|
9
|
+
? (instance, ctx) => exposeInput.dispose(instance, ctx)
|
|
10
|
+
: undefined
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const behaviorInput = input;
|
|
14
|
+
return {
|
|
15
|
+
id: behaviorInput.id,
|
|
16
|
+
setup: behaviorInput.setup,
|
|
17
|
+
dispose: behaviorInput.dispose ? (_instance, ctx) => behaviorInput.dispose(ctx) : undefined
|
|
18
|
+
};
|
|
19
|
+
}
|