zapo-js 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/appstate/types.d.ts +1 -2
- package/dist/auth/WaAuthClient.d.ts +25 -0
- package/dist/auth/WaAuthClient.js +63 -7
- package/dist/auth/credentials-flow.d.ts +8 -0
- package/dist/auth/credentials-flow.js +35 -2
- package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
- package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
- package/dist/auth/pairing/companion-host.d.ts +105 -0
- package/dist/auth/pairing/companion-host.js +148 -0
- package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
- package/dist/auth/pairing/pairing-code-crypto.js +3 -2
- package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
- package/dist/auth/pairing/shortcake-crypto.js +106 -0
- package/dist/auth/types.d.ts +20 -9
- package/dist/client/WaClient.d.ts +15 -5
- package/dist/client/WaClient.js +46 -12
- package/dist/client/WaClientFactory.d.ts +3 -0
- package/dist/client/WaClientFactory.js +27 -7
- package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
- package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
- package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/client/messaging/companion-host.d.ts +43 -0
- package/dist/client/messaging/companion-host.js +61 -0
- package/dist/client/messaging/key-protocol.js +25 -1
- package/dist/client/persistence/companion-host.d.ts +36 -0
- package/dist/client/persistence/companion-host.js +57 -0
- package/dist/client/plugins/install.d.ts +2 -1
- package/dist/client/plugins/install.js +44 -11
- package/dist/client/types.d.ts +90 -7
- package/dist/esm/auth/WaAuthClient.js +64 -8
- package/dist/esm/auth/credentials-flow.js +36 -3
- package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
- package/dist/esm/auth/pairing/companion-host.js +140 -0
- package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
- package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
- package/dist/esm/client/WaClient.js +46 -12
- package/dist/esm/client/WaClientFactory.js +28 -8
- package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
- package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
- package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
- package/dist/esm/client/messaging/companion-host.js +56 -0
- package/dist/esm/client/messaging/key-protocol.js +25 -1
- package/dist/esm/client/persistence/companion-host.js +54 -0
- package/dist/esm/client/plugins/install.js +44 -11
- package/dist/esm/index.js +5 -2
- package/dist/esm/message/WaMessageClient.js +3 -2
- package/dist/esm/message/context-info.js +4 -0
- package/dist/esm/protocol/constants.js +4 -3
- package/dist/esm/protocol/device-identity.js +18 -0
- package/dist/esm/protocol/group.js +6 -0
- package/dist/esm/protocol/message.js +12 -0
- package/dist/esm/protocol/nodes.js +17 -1
- package/dist/esm/protocol/notification.js +3 -1
- package/dist/esm/retry/replay.js +7 -15
- package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
- package/dist/esm/signal/attestation/constants.js +2 -0
- package/dist/esm/signal/index.js +1 -0
- package/dist/esm/transport/index.js +1 -1
- package/dist/esm/transport/node/builders/group.js +4 -3
- package/dist/esm/transport/node/builders/mobile.js +113 -0
- package/dist/esm/transport/node/builders/shortcake.js +50 -0
- package/dist/esm/transport/noise/WaClientPayload.js +17 -8
- package/dist/esm/transport/wa-version-fetcher.js +152 -0
- package/dist/esm/util/index.js +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +13 -4
- package/dist/message/WaMessageClient.js +2 -1
- package/dist/message/context-info.d.ts +2 -0
- package/dist/message/context-info.js +5 -0
- package/dist/protocol/constants.d.ts +6 -4
- package/dist/protocol/constants.js +8 -2
- package/dist/protocol/device-identity.d.ts +25 -0
- package/dist/protocol/device-identity.js +21 -0
- package/dist/protocol/group.d.ts +6 -0
- package/dist/protocol/group.js +7 -1
- package/dist/protocol/message.d.ts +14 -0
- package/dist/protocol/message.js +13 -1
- package/dist/protocol/nodes.d.ts +16 -0
- package/dist/protocol/nodes.js +17 -1
- package/dist/protocol/notification.d.ts +2 -0
- package/dist/protocol/notification.js +3 -1
- package/dist/retry/replay.d.ts +1 -0
- package/dist/retry/replay.js +6 -14
- package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
- package/dist/signal/attestation/WaAdvSignature.js +71 -1
- package/dist/signal/attestation/constants.d.ts +2 -0
- package/dist/signal/attestation/constants.js +3 -1
- package/dist/signal/index.d.ts +1 -0
- package/dist/signal/index.js +13 -1
- package/dist/transport/index.d.ts +2 -2
- package/dist/transport/index.js +4 -3
- package/dist/transport/node/builders/group.js +4 -3
- package/dist/transport/node/builders/mobile.d.ts +68 -0
- package/dist/transport/node/builders/mobile.js +120 -0
- package/dist/transport/node/builders/shortcake.d.ts +19 -0
- package/dist/transport/node/builders/shortcake.js +57 -0
- package/dist/transport/noise/WaClientPayload.d.ts +2 -0
- package/dist/transport/noise/WaClientPayload.js +17 -7
- package/dist/transport/wa-version-fetcher.d.ts +92 -0
- package/dist/transport/wa-version-fetcher.js +156 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +3 -1
- package/package.json +1 -1
- package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
- package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
- package/dist/transport/wa-web-version-fetcher.js +0 -94
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type SignalKeyPair } from '../../crypto';
|
|
2
|
+
/**
|
|
3
|
+
* `@sensitive` `advSecretKey` is companion secret material; consumers must not
|
|
4
|
+
* persist it without encryption at rest.
|
|
5
|
+
*
|
|
6
|
+
* Fields decoded from a companion pairing QR string. The companion (the device
|
|
7
|
+
* being linked) generates these; the primary consumes them to sign the device
|
|
8
|
+
* identity. `advSecretKey` is the companion's secret, not the primary's.
|
|
9
|
+
*/
|
|
10
|
+
export interface ParsedCompanionQr {
|
|
11
|
+
readonly ref: string;
|
|
12
|
+
readonly noisePublicKey: Uint8Array;
|
|
13
|
+
readonly identityPublicKey: Uint8Array;
|
|
14
|
+
readonly advSecretKey: Uint8Array;
|
|
15
|
+
readonly platform: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parses a companion pairing QR of the form
|
|
19
|
+
* `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
|
|
20
|
+
* contain commas, so the four trailing fields are taken from the end.
|
|
21
|
+
*
|
|
22
|
+
* @throws when fewer than 5 comma-separated fields are present.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseCompanionQr(qr: string): ParsedCompanionQr;
|
|
25
|
+
/** `@sensitive` Carries the primary's `accountIdentityKeyPair` private key. */
|
|
26
|
+
export interface BuildSignedKeyIndexListInput {
|
|
27
|
+
readonly accountIdentityKeyPair: SignalKeyPair;
|
|
28
|
+
readonly rawId: number;
|
|
29
|
+
readonly currentIndex: number;
|
|
30
|
+
readonly timestampSeconds: number;
|
|
31
|
+
readonly validIndexes: readonly number[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
|
|
35
|
+
* set. The zero-valued `accountType` enum is omitted to match the phone's byte
|
|
36
|
+
* layout.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildSignedKeyIndexList(input: BuildSignedKeyIndexListInput): Promise<Uint8Array>;
|
|
39
|
+
/**
|
|
40
|
+
* `@sensitive` Carries the primary's `accountIdentityKeyPair` private key and the
|
|
41
|
+
* companion's `advSecretKey`; do not persist unencrypted.
|
|
42
|
+
*/
|
|
43
|
+
export interface BuildSignedCompanionIdentityInput {
|
|
44
|
+
readonly accountIdentityKeyPair: SignalKeyPair;
|
|
45
|
+
readonly companionIdentityPublicKey: Uint8Array;
|
|
46
|
+
readonly advSecretKey: Uint8Array;
|
|
47
|
+
readonly rawId: number;
|
|
48
|
+
readonly keyIndex: number;
|
|
49
|
+
readonly timestampSeconds: number;
|
|
50
|
+
readonly validIndexes: readonly number[];
|
|
51
|
+
}
|
|
52
|
+
export interface SignedCompanionIdentity {
|
|
53
|
+
/** `ADVSignedDeviceIdentityHMAC` bytes for the `<device-identity>` node. */
|
|
54
|
+
readonly deviceIdentityBytes: Uint8Array;
|
|
55
|
+
/** `ADVSignedKeyIndexList` bytes for the `<key-index-list>` node. */
|
|
56
|
+
readonly keyIndexListBytes: Uint8Array;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Builds the signed device identity and key-index list for a linking companion.
|
|
60
|
+
* The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
|
|
61
|
+
* reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
|
|
62
|
+
* so the signed `details` match what the companion verifies.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildSignedCompanionIdentity(input: BuildSignedCompanionIdentityInput): Promise<SignedCompanionIdentity>;
|
|
65
|
+
/**
|
|
66
|
+
* Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
|
|
67
|
+
* `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
|
|
68
|
+
* verified against a live server from this implementation; see
|
|
69
|
+
* `CompanionHostOptions.includePem`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function derivePemKey(advSecretKey: Uint8Array, companionNoisePublicKey: Uint8Array): Uint8Array;
|
|
72
|
+
/**
|
|
73
|
+
* `@sensitive` Holds the `primaryEphemeralKeyPair` private key and the derived
|
|
74
|
+
* `sharedEphemeral`; keep in memory only, never persist unencrypted.
|
|
75
|
+
*/
|
|
76
|
+
export interface LinkCodePrimaryHello {
|
|
77
|
+
readonly primaryEphemeralKeyPair: SignalKeyPair;
|
|
78
|
+
/** `salt(32) || counter(16) || AES-CTR(primaryEphemeralPub)` for `primary_hello`. */
|
|
79
|
+
readonly wrappedPrimaryEphemeralPub: Uint8Array;
|
|
80
|
+
/** ECDH(primaryEphemeralPriv, companionEphemeralPub); stashed for the finish step. */
|
|
81
|
+
readonly sharedEphemeral: Uint8Array;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Link-code handshake, primary step 1: unwrap the companion ephemeral with the
|
|
85
|
+
* pairing code, generate the primary ephemeral, compute the shared ECDH secret,
|
|
86
|
+
* and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
|
|
87
|
+
* companion's `createCompanionHello` + primary-ephemeral unwrap.
|
|
88
|
+
*/
|
|
89
|
+
export declare function preparePrimaryHello(args: {
|
|
90
|
+
readonly pairingCode: string;
|
|
91
|
+
readonly wrappedCompanionEphemeralPub: Uint8Array;
|
|
92
|
+
}): Promise<LinkCodePrimaryHello>;
|
|
93
|
+
/**
|
|
94
|
+
* Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
|
|
95
|
+
* and derive the shared `advSecret` (byte-identical to what the companion
|
|
96
|
+
* derives). Validates that the bundle binds both identities.
|
|
97
|
+
*
|
|
98
|
+
* @throws when the bundle's embedded identities do not match.
|
|
99
|
+
*/
|
|
100
|
+
export declare function completePrimaryHandshake(args: {
|
|
101
|
+
readonly sharedEphemeral: Uint8Array;
|
|
102
|
+
readonly wrappedKeyBundle: Uint8Array;
|
|
103
|
+
readonly companionIdentityPub: Uint8Array;
|
|
104
|
+
readonly primaryIdentityKeyPair: SignalKeyPair;
|
|
105
|
+
}): Promise<Uint8Array>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseCompanionQr = parseCompanionQr;
|
|
4
|
+
exports.buildSignedKeyIndexList = buildSignedKeyIndexList;
|
|
5
|
+
exports.buildSignedCompanionIdentity = buildSignedCompanionIdentity;
|
|
6
|
+
exports.derivePemKey = derivePemKey;
|
|
7
|
+
exports.preparePrimaryHello = preparePrimaryHello;
|
|
8
|
+
exports.completePrimaryHandshake = completePrimaryHandshake;
|
|
9
|
+
const _crypto_1 = require("../../crypto");
|
|
10
|
+
const _proto_1 = require("../../proto");
|
|
11
|
+
const _protocol_1 = require("../../protocol");
|
|
12
|
+
const _signal_1 = require("../../signal");
|
|
13
|
+
const bytes_1 = require("../../util/bytes");
|
|
14
|
+
const PEM_HKDF_INFO = bytes_1.TEXT_ENCODER.encode('Canonical Ent Companion Nonce Encrypt');
|
|
15
|
+
const PBKDF2_ITERATIONS = 131072;
|
|
16
|
+
const PAIRING_AES_KEY_BYTES = 32;
|
|
17
|
+
/**
|
|
18
|
+
* Parses a companion pairing QR of the form
|
|
19
|
+
* `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
|
|
20
|
+
* contain commas, so the four trailing fields are taken from the end.
|
|
21
|
+
*
|
|
22
|
+
* @throws when fewer than 5 comma-separated fields are present.
|
|
23
|
+
*/
|
|
24
|
+
function parseCompanionQr(qr) {
|
|
25
|
+
const parts = qr.split(',');
|
|
26
|
+
if (parts.length < 5) {
|
|
27
|
+
throw new Error(`companion qr must have at least 5 comma-separated parts, got ${parts.length}`);
|
|
28
|
+
}
|
|
29
|
+
const platform = parts[parts.length - 1];
|
|
30
|
+
const advSecretB64 = parts[parts.length - 2];
|
|
31
|
+
const identityPubB64 = parts[parts.length - 3];
|
|
32
|
+
const noisePubB64 = parts[parts.length - 4];
|
|
33
|
+
const ref = parts.slice(0, parts.length - 4).join(',');
|
|
34
|
+
return {
|
|
35
|
+
ref,
|
|
36
|
+
noisePublicKey: (0, bytes_1.decodeBase64Url)(noisePubB64, 'companionQr.noisePublicKey'),
|
|
37
|
+
identityPublicKey: (0, bytes_1.decodeBase64Url)(identityPubB64, 'companionQr.identityPublicKey'),
|
|
38
|
+
advSecretKey: (0, bytes_1.decodeBase64Url)(advSecretB64, 'companionQr.advSecretKey'),
|
|
39
|
+
platform
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
|
|
44
|
+
* set. The zero-valued `accountType` enum is omitted to match the phone's byte
|
|
45
|
+
* layout.
|
|
46
|
+
*/
|
|
47
|
+
async function buildSignedKeyIndexList(input) {
|
|
48
|
+
const details = _proto_1.proto.ADVKeyIndexList.encode({
|
|
49
|
+
rawId: input.rawId,
|
|
50
|
+
timestamp: input.timestampSeconds,
|
|
51
|
+
currentIndex: input.currentIndex,
|
|
52
|
+
validIndexes: [...input.validIndexes]
|
|
53
|
+
}).finish();
|
|
54
|
+
const accountSignature = await (0, _signal_1.signKeyIndexList)(details, input.accountIdentityKeyPair);
|
|
55
|
+
return _proto_1.proto.ADVSignedKeyIndexList.encode({ details, accountSignature }).finish();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds the signed device identity and key-index list for a linking companion.
|
|
59
|
+
* The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
|
|
60
|
+
* reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
|
|
61
|
+
* so the signed `details` match what the companion verifies.
|
|
62
|
+
*/
|
|
63
|
+
async function buildSignedCompanionIdentity(input) {
|
|
64
|
+
const accountSignatureKey = (0, _crypto_1.toRawPubKey)(input.accountIdentityKeyPair.pubKey);
|
|
65
|
+
const details = _proto_1.proto.ADVDeviceIdentity.encode({
|
|
66
|
+
rawId: input.rawId,
|
|
67
|
+
timestamp: input.timestampSeconds,
|
|
68
|
+
keyIndex: input.keyIndex
|
|
69
|
+
}).finish();
|
|
70
|
+
const accountSignature = await (0, _signal_1.generateDeviceIdentityAccountSignature)(details, input.companionIdentityPublicKey, input.accountIdentityKeyPair);
|
|
71
|
+
const signedIdentity = _proto_1.proto.ADVSignedDeviceIdentity.encode({
|
|
72
|
+
details,
|
|
73
|
+
accountSignatureKey,
|
|
74
|
+
accountSignature
|
|
75
|
+
}).finish();
|
|
76
|
+
const hmac = (0, _signal_1.computeAdvIdentityHmac)(input.advSecretKey, signedIdentity);
|
|
77
|
+
const deviceIdentityBytes = _proto_1.proto.ADVSignedDeviceIdentityHMAC.encode({
|
|
78
|
+
details: signedIdentity,
|
|
79
|
+
hmac
|
|
80
|
+
}).finish();
|
|
81
|
+
const keyIndexListBytes = await buildSignedKeyIndexList({
|
|
82
|
+
accountIdentityKeyPair: input.accountIdentityKeyPair,
|
|
83
|
+
rawId: input.rawId,
|
|
84
|
+
currentIndex: input.keyIndex,
|
|
85
|
+
timestampSeconds: input.timestampSeconds,
|
|
86
|
+
validIndexes: input.validIndexes
|
|
87
|
+
});
|
|
88
|
+
return { deviceIdentityBytes, keyIndexListBytes };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
|
|
92
|
+
* `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
|
|
93
|
+
* verified against a live server from this implementation; see
|
|
94
|
+
* `CompanionHostOptions.includePem`.
|
|
95
|
+
*/
|
|
96
|
+
function derivePemKey(advSecretKey, companionNoisePublicKey) {
|
|
97
|
+
return (0, _crypto_1.hkdf)(advSecretKey, companionNoisePublicKey, PEM_HKDF_INFO, 32);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Link-code handshake, primary step 1: unwrap the companion ephemeral with the
|
|
101
|
+
* pairing code, generate the primary ephemeral, compute the shared ECDH secret,
|
|
102
|
+
* and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
|
|
103
|
+
* companion's `createCompanionHello` + primary-ephemeral unwrap.
|
|
104
|
+
*/
|
|
105
|
+
async function preparePrimaryHello(args) {
|
|
106
|
+
if (args.wrappedCompanionEphemeralPub.length < 48) {
|
|
107
|
+
throw new Error(`wrapped companion ephemeral pub too short: ${args.wrappedCompanionEphemeralPub.length} bytes (need >= 48)`);
|
|
108
|
+
}
|
|
109
|
+
const codeBytes = bytes_1.TEXT_ENCODER.encode(args.pairingCode);
|
|
110
|
+
const companionCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, args.wrappedCompanionEphemeralPub.subarray(0, 32), PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
111
|
+
const companionEphemeralPub = (0, _crypto_1.aesCtrDecrypt)(companionCipherKey, args.wrappedCompanionEphemeralPub.subarray(32, 48), args.wrappedCompanionEphemeralPub.subarray(48));
|
|
112
|
+
const [primaryEphemeralKeyPair, salt, counter] = await Promise.all([
|
|
113
|
+
_crypto_1.X25519.generateKeyPair(),
|
|
114
|
+
(0, _crypto_1.randomBytesAsync)(32),
|
|
115
|
+
(0, _crypto_1.randomBytesAsync)(16)
|
|
116
|
+
]);
|
|
117
|
+
const primaryCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
|
|
118
|
+
const encrypted = (0, _crypto_1.aesCtrEncrypt)(primaryCipherKey, counter, primaryEphemeralKeyPair.pubKey);
|
|
119
|
+
const sharedEphemeral = await _crypto_1.X25519.scalarMult(primaryEphemeralKeyPair.privKey, companionEphemeralPub);
|
|
120
|
+
return {
|
|
121
|
+
primaryEphemeralKeyPair,
|
|
122
|
+
wrappedPrimaryEphemeralPub: (0, bytes_1.concatBytes)([salt, counter, encrypted]),
|
|
123
|
+
sharedEphemeral
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
|
|
128
|
+
* and derive the shared `advSecret` (byte-identical to what the companion
|
|
129
|
+
* derives). Validates that the bundle binds both identities.
|
|
130
|
+
*
|
|
131
|
+
* @throws when the bundle's embedded identities do not match.
|
|
132
|
+
*/
|
|
133
|
+
async function completePrimaryHandshake(args) {
|
|
134
|
+
const bundleEncryptionKey = (0, _crypto_1.hkdf)(args.sharedEphemeral, args.wrappedKeyBundle.subarray(0, 32), _protocol_1.WA_PAIRING_KDF_INFO.LINK_CODE_BUNDLE, 32);
|
|
135
|
+
const plaintextBundle = (0, _crypto_1.aesGcmDecrypt)(bundleEncryptionKey, args.wrappedKeyBundle.subarray(32, 44), args.wrappedKeyBundle.subarray(44));
|
|
136
|
+
const bundleCompanionIdentity = plaintextBundle.subarray(0, 32);
|
|
137
|
+
const bundlePrimaryIdentity = plaintextBundle.subarray(32, 64);
|
|
138
|
+
const bundleSecret = plaintextBundle.subarray(64);
|
|
139
|
+
const rawCompanionIdentity = (0, _crypto_1.toRawPubKey)(args.companionIdentityPub);
|
|
140
|
+
if (!(0, bytes_1.uint8Equal)(bundlePrimaryIdentity, (0, _crypto_1.toRawPubKey)(args.primaryIdentityKeyPair.pubKey))) {
|
|
141
|
+
throw new Error('link-code bundle primary identity mismatch');
|
|
142
|
+
}
|
|
143
|
+
if (!(0, bytes_1.uint8Equal)(bundleCompanionIdentity, rawCompanionIdentity)) {
|
|
144
|
+
throw new Error('link-code bundle companion identity mismatch');
|
|
145
|
+
}
|
|
146
|
+
const sharedIdentity = await _crypto_1.X25519.scalarMult(args.primaryIdentityKeyPair.privKey, rawCompanionIdentity);
|
|
147
|
+
return (0, _crypto_1.hkdf)((0, bytes_1.concatBytes)([args.sharedEphemeral, sharedIdentity, bundleSecret]), null, _protocol_1.WA_PAIRING_KDF_INFO.ADV_SECRET, 32);
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -39,10 +39,11 @@ export type WaAuthSocketOptions = Pick<WaCommsConfig, 'url' | 'urls' | 'protocol
|
|
|
39
39
|
};
|
|
40
40
|
};
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* letting callers refresh the version (e.g. via `fetchLatestWaWebVersion`
|
|
45
|
-
* without rebuilding the client.
|
|
42
|
+
* Version supplied to {@link WaAuthClientOptions.version}. Either a literal
|
|
43
|
+
* dotted-numeric string or an async resolver invoked once per connect,
|
|
44
|
+
* letting callers refresh the version (e.g. via `fetchLatestWaWebVersion` or
|
|
45
|
+
* `fetchLatestWaMobileVersion`) without rebuilding the client. See
|
|
46
|
+
* {@link WaAuthClientOptions.version} for the per-transport part-count rules.
|
|
46
47
|
*/
|
|
47
48
|
export type WaVersionResolver = string | (() => string | Promise<string>);
|
|
48
49
|
export interface WaAuthClientOptions {
|
|
@@ -70,11 +71,21 @@ export interface WaAuthClientOptions {
|
|
|
70
71
|
*/
|
|
71
72
|
readonly requireFullSync?: boolean;
|
|
72
73
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Version the client advertises. Either a dotted-numeric literal or a
|
|
75
|
+
* (sync/async) resolver invoked once per connect – use the resolver form
|
|
76
|
+
* together with {@link fetchLatestWaWebVersion} /
|
|
77
|
+
* {@link fetchLatestWaMobileVersion} to refresh the version when the
|
|
78
|
+
* hardcoded default starts hitting `failure_client_too_old`.
|
|
79
|
+
*
|
|
80
|
+
* The accepted shape depends on the transport resolved for the connect:
|
|
81
|
+
* - **Web** sessions take a 3- to 5-part version (`2.3000.x[.y.z]`); all
|
|
82
|
+
* supplied parts are advertised in the noise payload.
|
|
83
|
+
* - **Mobile** sessions take exactly a 4-part Android app version
|
|
84
|
+
* (`2.26.27.70`); it overrides `mobileTransport.deviceInfo.appVersion`
|
|
85
|
+
* in the login payload.
|
|
86
|
+
*
|
|
87
|
+
* An invalid part count for the resolved transport throws on connect.
|
|
88
|
+
* Defaults to a tested production version per transport.
|
|
78
89
|
*/
|
|
79
90
|
readonly version?: WaVersionResolver;
|
|
80
91
|
/**
|
|
@@ -8,6 +8,7 @@ import type { WaEmailCoordinator } from './coordinators/WaEmailCoordinator';
|
|
|
8
8
|
import type { WaGroupCoordinator } from './coordinators/WaGroupCoordinator';
|
|
9
9
|
import type { WaLowLevelCoordinator } from './coordinators/WaLowLevelCoordinator';
|
|
10
10
|
import type { WaMessageCoordinator } from './coordinators/WaMessageCoordinator';
|
|
11
|
+
import type { WaMobileCoordinator } from './coordinators/WaMobileCoordinator';
|
|
11
12
|
import type { WaNewsletterCoordinator } from './coordinators/WaNewsletterCoordinator';
|
|
12
13
|
import type { WaPresenceCoordinator } from './coordinators/WaPresenceCoordinator';
|
|
13
14
|
import type { WaPrivacyCoordinator } from './coordinators/WaPrivacyCoordinator';
|
|
@@ -31,11 +32,13 @@ declare class WaClientImpl extends EventEmitter {
|
|
|
31
32
|
private activeIncomingHandlers;
|
|
32
33
|
private readonly incomingHandlersDrainedWaiters;
|
|
33
34
|
private disposePlugins;
|
|
35
|
+
private readonly pluginInstallInput;
|
|
34
36
|
/**
|
|
35
37
|
* @param options Client configuration (store, transport, addons, history...).
|
|
36
38
|
* @param logger Optional structured logger. Defaults to a `ConsoleLogger('info')`.
|
|
37
39
|
*/
|
|
38
40
|
constructor(options: WaClientOptions, logger?: Logger);
|
|
41
|
+
private installPlugins;
|
|
39
42
|
private runClientTooOldRecover;
|
|
40
43
|
/** Strongly-typed `EventEmitter#on` over {@link WaClientEventMap}. */
|
|
41
44
|
on<K extends keyof WaClientEventMap>(event: K, listener: WaClientEventMap[K]): this;
|
|
@@ -100,11 +103,12 @@ declare class WaClientImpl extends EventEmitter {
|
|
|
100
103
|
connect(): Promise<void>;
|
|
101
104
|
/**
|
|
102
105
|
* Closes the transport gracefully: pauses incoming events, flushes the
|
|
103
|
-
* write-behind persistence queue, and emits a
|
|
104
|
-
* with reason `client_disconnected`. Does not
|
|
105
|
-
* call {@link connect} again to resume the same
|
|
106
|
-
*
|
|
107
|
-
*
|
|
106
|
+
* write-behind persistence queue, disposes installed plugins, and emits a
|
|
107
|
+
* `connection` close event with reason `client_disconnected`. Does not
|
|
108
|
+
* clear stored credentials - call {@link connect} again to resume the same
|
|
109
|
+
* session, which reinstalls the plugins. There is no built-in
|
|
110
|
+
* auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
|
|
111
|
+
* your own backoff.
|
|
108
112
|
*/
|
|
109
113
|
disconnect(): Promise<void>;
|
|
110
114
|
/**
|
|
@@ -166,6 +170,12 @@ declare class WaClientImpl extends EventEmitter {
|
|
|
166
170
|
* via `options.mobileTransport`. See {@link WaEmailCoordinator}.
|
|
167
171
|
*/
|
|
168
172
|
get email(): WaEmailCoordinator;
|
|
173
|
+
/**
|
|
174
|
+
* Mobile coordinator: host and manage companion devices linked to this
|
|
175
|
+
* mobile-primary account (link via QR or pairing code, list, revoke).
|
|
176
|
+
* Requires a registered primary session. See {@link WaMobileCoordinator}.
|
|
177
|
+
*/
|
|
178
|
+
get mobile(): WaMobileCoordinator;
|
|
169
179
|
/**
|
|
170
180
|
* Unpairs this companion device by removing it server-side. Requires an
|
|
171
181
|
* authenticated session; throws when no `meJid` is present.
|