zapo-js 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/WaClientFactory.js +3 -1
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/client/types.d.ts +19 -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/WaClientFactory.js +3 -1
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
- 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/node/builders/shortcake.js +50 -0
- package/dist/esm/transport/noise/WaClientPayload.js +1 -1
- package/dist/index.d.ts +1 -0
- 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/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/package.json +1 -1
package/dist/client/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppStateCollectionName } from '../appstate/types';
|
|
2
2
|
import type { DataForKey, WaAppstateActionKey, WaAppstateIndexArgs } from '../appstate-spec';
|
|
3
|
+
import type { WaShortcakeAssertionSigner } from '../auth/pairing/WaShortcakeFlow';
|
|
3
4
|
import type { WaAuthClientOptions, WaAuthCredentials, WaAuthDangerousOptions, WaAuthSocketOptions } from '../auth/types';
|
|
4
5
|
import type { WaCallGroupParticipant, WaCallType } from './events/call';
|
|
5
6
|
import type { IncomingPresenceType, PresenceLastSeen } from './events/presence';
|
|
@@ -193,6 +194,14 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
|
|
|
193
194
|
* disables a security check the production code path enforces.
|
|
194
195
|
*/
|
|
195
196
|
readonly dangerous?: WaClientDangerousOptions;
|
|
197
|
+
/**
|
|
198
|
+
* External WebAuthn signer. Configuring it enables handling a server-forced
|
|
199
|
+
* passkey ("Shortcake") prologue – e.g. when the server demands a passkey
|
|
200
|
+
* right after a successful pairing-code `companion_finish`. Called with the
|
|
201
|
+
* server's request options; returns the assertion + credential id. The
|
|
202
|
+
* credential source (real/virtual authenticator) stays outside the library.
|
|
203
|
+
*/
|
|
204
|
+
readonly signPasskeyAssertion?: WaShortcakeAssertionSigner;
|
|
196
205
|
}
|
|
197
206
|
export interface WaClientDangerousOptions extends WaAuthDangerousOptions {
|
|
198
207
|
/**
|
|
@@ -1071,6 +1080,16 @@ export interface WaClientEventMap {
|
|
|
1071
1080
|
readonly auth_paired: (event: {
|
|
1072
1081
|
readonly credentials: WaAuthCredentials;
|
|
1073
1082
|
}) => void;
|
|
1083
|
+
/**
|
|
1084
|
+
* The server is forcing a passkey (Shortcake) to link this device – emitted
|
|
1085
|
+
* when the server pushes the prologue request. `hasSigner` is `true` when a
|
|
1086
|
+
* `signPasskeyAssertion` is configured (the handshake proceeds) and `false`
|
|
1087
|
+
* when none is set (the request is acked but the link cannot complete
|
|
1088
|
+
* headless, so the user should be told a passkey is required).
|
|
1089
|
+
*/
|
|
1090
|
+
readonly auth_passkey_required: (event: {
|
|
1091
|
+
readonly hasSigner: boolean;
|
|
1092
|
+
}) => void;
|
|
1074
1093
|
/**
|
|
1075
1094
|
* Connection-state transitions: `'open'` (handshake + auth complete),
|
|
1076
1095
|
* `'connecting'`, or `'close'` (with a `reason` and optional `code`). The
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { buildCommsConfig, loadOrCreateCredentials, persistCredentials } from './credentials-flow.js';
|
|
2
2
|
import { WaPairingFlow } from './pairing/WaPairingFlow.js';
|
|
3
3
|
import { WaQrFlow } from './pairing/WaQrFlow.js';
|
|
4
|
-
import {
|
|
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
|
+
}
|
|
@@ -348,7 +348,9 @@ export function buildWaClientDependencies(input) {
|
|
|
348
348
|
runtime.emitEvent('auth_paired', { credentials });
|
|
349
349
|
scheduleReconnectAfterPairing();
|
|
350
350
|
},
|
|
351
|
-
onError: (error) => runtime.handleError(error)
|
|
351
|
+
onError: (error) => runtime.handleError(error),
|
|
352
|
+
onPasskeyRequired: (hasSigner) => runtime.emitEvent('auth_passkey_required', { hasSigner }),
|
|
353
|
+
signPasskeyAssertion: options.signPasskeyAssertion
|
|
352
354
|
}
|
|
353
355
|
});
|
|
354
356
|
const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
|
|
@@ -61,6 +61,7 @@ export class WaRetryCoordinator {
|
|
|
61
61
|
signalProtocol: options.signalProtocol,
|
|
62
62
|
sessionResolver: options.sessionResolver,
|
|
63
63
|
getCurrentCredentials: options.getCurrentCredentials,
|
|
64
|
+
isMobilePrimary: options.isMobilePrimary,
|
|
64
65
|
resolveUserIcdc: options.resolveUserIcdc,
|
|
65
66
|
resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
|
|
66
67
|
});
|
|
@@ -50,6 +50,8 @@ export const WA_NODE_TAGS = Object.freeze({
|
|
|
50
50
|
COUNTRY_CODE: 'country_code',
|
|
51
51
|
DEVICE_IDENTITY: 'device-identity',
|
|
52
52
|
PLATFORM: 'platform',
|
|
53
|
+
PASSKEY_REQUEST_OPTIONS: 'passkey_request_options',
|
|
54
|
+
PRIMARY_EPHEMERAL_IDENTITY: 'primary_ephemeral_identity',
|
|
53
55
|
SUBJECT: 'subject',
|
|
54
56
|
BODY: 'body',
|
|
55
57
|
PARTICIPANT: 'participant',
|
|
@@ -6,7 +6,9 @@ export const WA_NOTIFICATION_TYPES = Object.freeze({
|
|
|
6
6
|
REGISTRATION: 'registration',
|
|
7
7
|
NEWSLETTER: 'newsletter',
|
|
8
8
|
BUSINESS: 'business',
|
|
9
|
-
PICTURE: 'picture'
|
|
9
|
+
PICTURE: 'picture',
|
|
10
|
+
PASSKEY_PROLOGUE_REQUEST: 'passkey_prologue_request',
|
|
11
|
+
CRSC_CONTINUATION: 'crsc_continuation'
|
|
10
12
|
});
|
|
11
13
|
export const WA_BUSINESS_NOTIFICATION_TAGS = Object.freeze({
|
|
12
14
|
VERIFIED_NAME: 'verified_name',
|
package/dist/esm/retry/replay.js
CHANGED
|
@@ -85,9 +85,9 @@ export class WaRetryReplayService {
|
|
|
85
85
|
resolveSignedDeviceIdentity(context) {
|
|
86
86
|
const signedIdentity = this.options.getCurrentCredentials()?.signedIdentity;
|
|
87
87
|
if (!signedIdentity) {
|
|
88
|
-
this.options.
|
|
89
|
-
context
|
|
90
|
-
}
|
|
88
|
+
if (!this.options.isMobilePrimary?.()) {
|
|
89
|
+
this.options.logger.warn('retry request missing signed identity for pkmsg envelope', { context });
|
|
90
|
+
}
|
|
91
91
|
return undefined;
|
|
92
92
|
}
|
|
93
93
|
return proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
|
|
@@ -179,15 +179,7 @@ export class WaRetryReplayService {
|
|
|
179
179
|
logContext: 'group'
|
|
180
180
|
})) ?? payload.plaintext;
|
|
181
181
|
const encrypted = await this.options.signalProtocol.encryptMessage(requesterAddress, plaintext);
|
|
182
|
-
|
|
183
|
-
if (encrypted.type === 'pkmsg') {
|
|
184
|
-
const signedIdentity = this.options.getCurrentCredentials()?.signedIdentity;
|
|
185
|
-
if (!signedIdentity) {
|
|
186
|
-
this.options.logger.warn('retry request rejected: missing signed identity for pkmsg group retry');
|
|
187
|
-
return 'ineligible';
|
|
188
|
-
}
|
|
189
|
-
deviceIdentity = proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
|
|
190
|
-
}
|
|
182
|
+
const deviceIdentity = encrypted.type === 'pkmsg' ? this.resolveSignedDeviceIdentity('group') : undefined;
|
|
191
183
|
const isStatus = isStatusBroadcastJid(payload.to);
|
|
192
184
|
const metaAttrs = {};
|
|
193
185
|
if (isStatus) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { WA_DEFAULTS, WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from '../../../protocol/constants.js';
|
|
2
|
+
import { buildIqNode } from '../../node/query.js';
|
|
3
|
+
/**
|
|
4
|
+
* IQ builders for the WhatsApp "Shortcake" companion-linking protocol
|
|
5
|
+
* (`xmlns="md"`). Wire shapes mirror the official client's
|
|
6
|
+
* `WASmaxOutMd…Request` builders. The stanza `id` is assigned by the socket
|
|
7
|
+
* query layer, matching the other pairing builders.
|
|
8
|
+
*/
|
|
9
|
+
function mdIq(type, content) {
|
|
10
|
+
return buildIqNode(type, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.MD, content);
|
|
11
|
+
}
|
|
12
|
+
/** `<iq type="get" xmlns="md"><passkey_request_options/></iq>` */
|
|
13
|
+
export function buildGetPasskeyRequestOptionsRequestNode() {
|
|
14
|
+
return mdIq(WA_IQ_TYPES.GET, [
|
|
15
|
+
{ tag: WA_NODE_TAGS.PASSKEY_REQUEST_OPTIONS, attrs: {}, content: undefined }
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
/** `<iq type="get" xmlns="md"><ref/></iq>` */
|
|
19
|
+
export function buildShortcakeGetRefRequestNode() {
|
|
20
|
+
return mdIq(WA_IQ_TYPES.GET, [{ tag: WA_NODE_TAGS.REF, attrs: {}, content: undefined }]);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `<iq type="set" xmlns="md"><passkey_prologue><credential_id/><webauthn_assertion/>`
|
|
24
|
+
* `<prologue_payload/>[<pairing_handoff_proof/>]</passkey_prologue></iq>`
|
|
25
|
+
*/
|
|
26
|
+
export function buildSetPasskeyPrologueRequestNode(args) {
|
|
27
|
+
const children = [
|
|
28
|
+
{ tag: 'credential_id', attrs: {}, content: args.credentialId },
|
|
29
|
+
{ tag: 'webauthn_assertion', attrs: {}, content: args.webauthnAssertion },
|
|
30
|
+
{ tag: 'prologue_payload', attrs: {}, content: args.prologuePayload }
|
|
31
|
+
];
|
|
32
|
+
if (args.pairingHandoffProof) {
|
|
33
|
+
children.push({
|
|
34
|
+
tag: 'pairing_handoff_proof',
|
|
35
|
+
attrs: {},
|
|
36
|
+
content: args.pairingHandoffProof
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return mdIq(WA_IQ_TYPES.SET, [{ tag: 'passkey_prologue', attrs: {}, content: children }]);
|
|
40
|
+
}
|
|
41
|
+
/** `<iq type="set" xmlns="md"><companion_nonce/></iq>` */
|
|
42
|
+
export function buildSetCompanionNonceRequestNode(companionNonce) {
|
|
43
|
+
return mdIq(WA_IQ_TYPES.SET, [{ tag: 'companion_nonce', attrs: {}, content: companionNonce }]);
|
|
44
|
+
}
|
|
45
|
+
/** `<iq type="set" xmlns="md"><encrypted_pairing_request/></iq>` */
|
|
46
|
+
export function buildSetEncryptedPairingRequestRequestNode(encryptedPairingRequest) {
|
|
47
|
+
return mdIq(WA_IQ_TYPES.SET, [
|
|
48
|
+
{ tag: 'encrypted_pairing_request', attrs: {}, content: encryptedPairingRequest }
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
@@ -33,7 +33,7 @@ function resolveLocale() {
|
|
|
33
33
|
function defaultWebSubPlatform() {
|
|
34
34
|
return proto.ClientPayload.WebInfo.WebSubPlatform.WEB_BROWSER;
|
|
35
35
|
}
|
|
36
|
-
function resolveDevicePropsPlatformType(deviceBrowser) {
|
|
36
|
+
export function resolveDevicePropsPlatformType(deviceBrowser) {
|
|
37
37
|
const normalized = deviceBrowser?.trim().toLowerCase();
|
|
38
38
|
switch (normalized) {
|
|
39
39
|
case 'chrome':
|
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type { WaSendContextInfo } from './message/context-info';
|
|
|
30
30
|
export type { WaLinkPreviewFetcher, WaLinkPreviewOptions, WaLinkPreviewOverride, WaLinkPreviewResolved, WaLinkPreviewThumbnailBytes, WaLinkPreviewThumbnailInput, WaLinkPreviewThumbnailStream, WaLinkPreviewType } from './message/addons/link-preview/types';
|
|
31
31
|
export type { SignalLidSyncResult } from './signal/api/SignalDeviceSyncApi';
|
|
32
32
|
export type { WaAuthCredentials, WaVersionResolver } from './auth/types';
|
|
33
|
+
export type { WaShortcakeAssertionSigner } from './auth/pairing/WaShortcakeFlow';
|
|
33
34
|
export type { BinaryNode } from './transport/types';
|
|
34
35
|
export { fetchLatestWaWebVersion } from './transport/wa-web-version-fetcher';
|
|
35
36
|
export type { WaFetchLatestWebVersionOptions, WaLatestWebVersion } from './transport/wa-web-version-fetcher';
|
package/dist/protocol/nodes.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export declare const WA_NODE_TAGS: Readonly<{
|
|
|
50
50
|
readonly COUNTRY_CODE: "country_code";
|
|
51
51
|
readonly DEVICE_IDENTITY: "device-identity";
|
|
52
52
|
readonly PLATFORM: "platform";
|
|
53
|
+
readonly PASSKEY_REQUEST_OPTIONS: "passkey_request_options";
|
|
54
|
+
readonly PRIMARY_EPHEMERAL_IDENTITY: "primary_ephemeral_identity";
|
|
53
55
|
readonly SUBJECT: "subject";
|
|
54
56
|
readonly BODY: "body";
|
|
55
57
|
readonly PARTICIPANT: "participant";
|
package/dist/protocol/nodes.js
CHANGED
|
@@ -53,6 +53,8 @@ exports.WA_NODE_TAGS = Object.freeze({
|
|
|
53
53
|
COUNTRY_CODE: 'country_code',
|
|
54
54
|
DEVICE_IDENTITY: 'device-identity',
|
|
55
55
|
PLATFORM: 'platform',
|
|
56
|
+
PASSKEY_REQUEST_OPTIONS: 'passkey_request_options',
|
|
57
|
+
PRIMARY_EPHEMERAL_IDENTITY: 'primary_ephemeral_identity',
|
|
56
58
|
SUBJECT: 'subject',
|
|
57
59
|
BODY: 'body',
|
|
58
60
|
PARTICIPANT: 'participant',
|
|
@@ -7,6 +7,8 @@ export declare const WA_NOTIFICATION_TYPES: Readonly<{
|
|
|
7
7
|
readonly NEWSLETTER: "newsletter";
|
|
8
8
|
readonly BUSINESS: "business";
|
|
9
9
|
readonly PICTURE: "picture";
|
|
10
|
+
readonly PASSKEY_PROLOGUE_REQUEST: "passkey_prologue_request";
|
|
11
|
+
readonly CRSC_CONTINUATION: "crsc_continuation";
|
|
10
12
|
}>;
|
|
11
13
|
export declare const WA_BUSINESS_NOTIFICATION_TAGS: Readonly<{
|
|
12
14
|
readonly VERIFIED_NAME: "verified_name";
|
|
@@ -9,7 +9,9 @@ exports.WA_NOTIFICATION_TYPES = Object.freeze({
|
|
|
9
9
|
REGISTRATION: 'registration',
|
|
10
10
|
NEWSLETTER: 'newsletter',
|
|
11
11
|
BUSINESS: 'business',
|
|
12
|
-
PICTURE: 'picture'
|
|
12
|
+
PICTURE: 'picture',
|
|
13
|
+
PASSKEY_PROLOGUE_REQUEST: 'passkey_prologue_request',
|
|
14
|
+
CRSC_CONTINUATION: 'crsc_continuation'
|
|
13
15
|
});
|
|
14
16
|
exports.WA_BUSINESS_NOTIFICATION_TAGS = Object.freeze({
|
|
15
17
|
VERIFIED_NAME: 'verified_name',
|
package/dist/retry/replay.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface WaRetryReplayServiceOptions {
|
|
|
12
12
|
readonly signalProtocol: SignalProtocol;
|
|
13
13
|
readonly sessionResolver: SignalSessionResolver;
|
|
14
14
|
readonly getCurrentCredentials: () => WaAuthCredentials | null;
|
|
15
|
+
readonly isMobilePrimary?: () => boolean;
|
|
15
16
|
readonly resolveUserIcdc?: (userJid: string) => Promise<IcdcMeta | null>;
|
|
16
17
|
/**
|
|
17
18
|
* Resolves the trusted-contact (privacy) token node for a recipient user
|