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