@zapo-js/fake-server 1.0.0 → 1.2.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/README.md +180 -33
- package/dist/api/FakeMobilePrimary.d.ts +55 -0
- package/dist/api/FakeMobilePrimary.js +106 -0
- package/dist/api/FakePairingDriver.js +1 -3
- package/dist/api/FakePeer.js +7 -1
- package/dist/api/FakeServerSession.d.ts +100 -0
- package/dist/api/FakeServerSession.js +355 -0
- package/dist/api/FakeWaServer.d.ts +145 -37
- package/dist/api/FakeWaServer.js +345 -270
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +20 -1
- package/dist/api/iq-handlers.js +119 -1
- package/dist/bin.js +69 -21
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakePeer.js +7 -1
- package/dist/esm/api/FakeServerSession.js +349 -0
- package/dist/esm/api/FakeWaServer.js +344 -270
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +119 -1
- package/dist/esm/bin.js +67 -19
- package/dist/esm/index.js +9 -1
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/esm/infra/WaFakeTcpServer.js +69 -0
- package/dist/esm/infra/WaFakeWsServer.js +2 -1
- package/dist/esm/infra/socket-adapters.js +79 -0
- package/dist/esm/protocol/auth/cert-chain.js +13 -2
- package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
- package/dist/esm/protocol/auth/pair-device.js +16 -1
- package/dist/esm/protocol/iq/companion-host.js +104 -0
- package/dist/esm/protocol/iq/link-code.js +90 -0
- package/dist/esm/protocol/iq/router.js +9 -3
- package/dist/esm/protocol/push/mobile-notification.js +97 -0
- package/dist/esm/state/fake-companion-host.js +158 -0
- package/dist/esm/transport/auth.js +7 -0
- package/dist/esm/transport/crypto.js +1 -1
- package/dist/esm/transport/util.js +2 -1
- package/dist/index.d.ts +19 -4
- package/dist/index.js +29 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
- package/dist/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/infra/WaFakeTcpServer.d.ts +29 -0
- package/dist/infra/WaFakeTcpServer.js +73 -0
- package/dist/infra/WaFakeWsServer.js +2 -1
- package/dist/infra/socket-adapters.d.ts +18 -0
- package/dist/infra/socket-adapters.js +83 -0
- package/dist/protocol/auth/cert-chain.d.ts +4 -0
- package/dist/protocol/auth/cert-chain.js +13 -2
- package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
- package/dist/protocol/auth/client-payload-validate.js +77 -1
- package/dist/protocol/auth/pair-device.d.ts +15 -1
- package/dist/protocol/auth/pair-device.js +16 -0
- package/dist/protocol/iq/companion-host.d.ts +52 -0
- package/dist/protocol/iq/companion-host.js +109 -0
- package/dist/protocol/iq/link-code.d.ts +46 -0
- package/dist/protocol/iq/link-code.js +95 -0
- package/dist/protocol/iq/router.d.ts +21 -2
- package/dist/protocol/iq/router.js +9 -3
- package/dist/protocol/push/mobile-notification.d.ts +58 -0
- package/dist/protocol/push/mobile-notification.js +102 -0
- package/dist/state/fake-companion-host.d.ts +73 -0
- package/dist/state/fake-companion-host.js +163 -0
- package/dist/transport/auth.d.ts +8 -0
- package/dist/transport/auth.js +8 -0
- package/dist/transport/crypto.d.ts +1 -1
- package/dist/transport/crypto.js +2 -1
- package/dist/transport/util.d.ts +2 -1
- package/dist/transport/util.js +3 -1
- package/package.json +6 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Carrier adapters: WebSocket (companion transport) and raw TCP (mobile). */
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
import type { WebSocket } from 'ws';
|
|
4
|
+
import type { WaFakeSocketLike } from './WaFakeConnection';
|
|
5
|
+
/**
|
|
6
|
+
* Adapts a `ws` socket to {@link WaFakeSocketLike}. Owns the two WebSocket
|
|
7
|
+
* quirks the rest of the server should not know about: a message can arrive as
|
|
8
|
+
* a fragment array, and a text frame is a protocol violation here because every
|
|
9
|
+
* WhatsApp frame is binary.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createWebSocketAdapter(socket: WebSocket): WaFakeSocketLike;
|
|
12
|
+
/**
|
|
13
|
+
* Adapts a Node TCP socket to {@link WaFakeSocketLike} for the mobile
|
|
14
|
+
* transport. A TCP stream has no message boundaries and no close codes: every
|
|
15
|
+
* chunk is forwarded as-is (the frame codec above reassembles them) and the
|
|
16
|
+
* code/reason the server asked for is replayed when the socket finally closes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createTcpSocketAdapter(socket: Socket): WaFakeSocketLike;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Carrier adapters: WebSocket (companion transport) and raw TCP (mobile). */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.createWebSocketAdapter = createWebSocketAdapter;
|
|
5
|
+
exports.createTcpSocketAdapter = createTcpSocketAdapter;
|
|
6
|
+
const util_1 = require("../transport/util");
|
|
7
|
+
/**
|
|
8
|
+
* Adapts a `ws` socket to {@link WaFakeSocketLike}. Owns the two WebSocket
|
|
9
|
+
* quirks the rest of the server should not know about: a message can arrive as
|
|
10
|
+
* a fragment array, and a text frame is a protocol violation here because every
|
|
11
|
+
* WhatsApp frame is binary.
|
|
12
|
+
*/
|
|
13
|
+
function createWebSocketAdapter(socket) {
|
|
14
|
+
return {
|
|
15
|
+
send: (frame) => socket.send(frame),
|
|
16
|
+
close: (code, reason) => socket.close(code, reason),
|
|
17
|
+
listen: (events) => {
|
|
18
|
+
socket.on('message', (data, isBinary) => {
|
|
19
|
+
if (!isBinary) {
|
|
20
|
+
events.onError(new Error('received unexpected text frame'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
events.onFrame(toFrameBytes(data));
|
|
24
|
+
});
|
|
25
|
+
socket.on('close', (code, reason) => {
|
|
26
|
+
events.onClose({ code, reason: util_1.TEXT_DECODER.decode(reason) });
|
|
27
|
+
});
|
|
28
|
+
socket.on('error', (error) => {
|
|
29
|
+
events.onError(error);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Adapts a Node TCP socket to {@link WaFakeSocketLike} for the mobile
|
|
36
|
+
* transport. A TCP stream has no message boundaries and no close codes: every
|
|
37
|
+
* chunk is forwarded as-is (the frame codec above reassembles them) and the
|
|
38
|
+
* code/reason the server asked for is replayed when the socket finally closes.
|
|
39
|
+
*/
|
|
40
|
+
function createTcpSocketAdapter(socket) {
|
|
41
|
+
let closeCode = 1000;
|
|
42
|
+
let closeReason = '';
|
|
43
|
+
return {
|
|
44
|
+
send: (frame) => {
|
|
45
|
+
socket.write(frame);
|
|
46
|
+
},
|
|
47
|
+
close: (code, reason) => {
|
|
48
|
+
closeCode = code;
|
|
49
|
+
closeReason = reason;
|
|
50
|
+
socket.end();
|
|
51
|
+
},
|
|
52
|
+
listen: (events) => {
|
|
53
|
+
socket.on('data', (chunk) => {
|
|
54
|
+
events.onFrame(chunk);
|
|
55
|
+
});
|
|
56
|
+
socket.on('close', () => {
|
|
57
|
+
events.onClose({ code: closeCode, reason: closeReason });
|
|
58
|
+
});
|
|
59
|
+
socket.on('error', (error) => {
|
|
60
|
+
events.onError(error);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function toFrameBytes(data) {
|
|
66
|
+
if (data instanceof Uint8Array) {
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
if (data instanceof ArrayBuffer) {
|
|
70
|
+
return new Uint8Array(data);
|
|
71
|
+
}
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (const part of data) {
|
|
74
|
+
total += part.byteLength;
|
|
75
|
+
}
|
|
76
|
+
const out = new Uint8Array(total);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (const part of data) {
|
|
79
|
+
out.set(part, offset);
|
|
80
|
+
offset += part.byteLength;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
@@ -12,6 +12,10 @@ export interface BuildFakeCertChainInput {
|
|
|
12
12
|
readonly leafKey: Uint8Array;
|
|
13
13
|
readonly intermediateSerial?: number;
|
|
14
14
|
readonly leafSerial?: number;
|
|
15
|
+
/** Unix seconds. Defaults to 24h before build time. */
|
|
16
|
+
readonly notBefore?: number;
|
|
17
|
+
/** Unix seconds. Defaults to 10 years after build time. */
|
|
18
|
+
readonly notAfter?: number;
|
|
15
19
|
}
|
|
16
20
|
export declare function generateFakeNoiseRootCa(): Promise<FakeNoiseRootCa>;
|
|
17
21
|
export declare function buildFakeCertChain(input: BuildFakeCertChainInput): Promise<FakeCertChainResult>;
|
|
@@ -5,6 +5,8 @@ exports.generateFakeNoiseRootCa = generateFakeNoiseRootCa;
|
|
|
5
5
|
exports.buildFakeCertChain = buildFakeCertChain;
|
|
6
6
|
const crypto_1 = require("../../transport/crypto");
|
|
7
7
|
const protos_1 = require("../../transport/protos");
|
|
8
|
+
const WA_CERT_NOT_BEFORE_BACKDATE_SECONDS = 86400;
|
|
9
|
+
const WA_CERT_NOT_AFTER_LIFETIME_SECONDS = 315360000;
|
|
8
10
|
async function generateFakeNoiseRootCa() {
|
|
9
11
|
const keyPair = await crypto_1.X25519.generateKeyPair();
|
|
10
12
|
return {
|
|
@@ -16,17 +18,26 @@ async function generateFakeNoiseRootCa() {
|
|
|
16
18
|
async function buildFakeCertChain(input) {
|
|
17
19
|
const intermediateSerial = input.intermediateSerial ?? 1;
|
|
18
20
|
const leafSerial = input.leafSerial ?? 2;
|
|
21
|
+
// Strict clients (e.g. whatsmeow) enforce the validity window; leaving
|
|
22
|
+
// notBefore/notAfter unset decodes as 0 (1970) and reads as expired.
|
|
23
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
24
|
+
const notBefore = input.notBefore ?? nowSeconds - WA_CERT_NOT_BEFORE_BACKDATE_SECONDS;
|
|
25
|
+
const notAfter = input.notAfter ?? nowSeconds + WA_CERT_NOT_AFTER_LIFETIME_SECONDS;
|
|
19
26
|
const intermediateKeyPair = await crypto_1.X25519.generateKeyPair();
|
|
20
27
|
const intermediateDetails = protos_1.proto.CertChain.NoiseCertificate.Details.encode({
|
|
21
28
|
serial: intermediateSerial,
|
|
22
29
|
issuerSerial: input.root.serial,
|
|
23
|
-
key: intermediateKeyPair.pubKey
|
|
30
|
+
key: intermediateKeyPair.pubKey,
|
|
31
|
+
notBefore,
|
|
32
|
+
notAfter
|
|
24
33
|
}).finish();
|
|
25
34
|
const intermediateSignature = await (0, crypto_1.xeddsaSign)(input.root.privateKey, intermediateDetails);
|
|
26
35
|
const leafDetails = protos_1.proto.CertChain.NoiseCertificate.Details.encode({
|
|
27
36
|
serial: leafSerial,
|
|
28
37
|
issuerSerial: intermediateSerial,
|
|
29
|
-
key: input.leafKey
|
|
38
|
+
key: input.leafKey,
|
|
39
|
+
notBefore,
|
|
40
|
+
notAfter
|
|
30
41
|
}).finish();
|
|
31
42
|
const leafSignature = await (0, crypto_1.xeddsaSign)(intermediateKeyPair.privKey, leafDetails);
|
|
32
43
|
const encoded = protos_1.proto.CertChain.encode({
|
|
@@ -5,12 +5,36 @@ export interface RegistrationPayload {
|
|
|
5
5
|
readonly raw: Proto.IClientPayload;
|
|
6
6
|
readonly devicePairingData: NonNullable<Proto.IClientPayload['devicePairingData']>;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Transport flavor a login `ClientPayload` was built for. `web` covers the
|
|
10
|
+
* companion/WhatsApp Web payload, `mobile` the phone payload sent over the
|
|
11
|
+
* mobile TCP transport.
|
|
12
|
+
*/
|
|
13
|
+
export type ClientPayloadFlavor = 'web' | 'mobile';
|
|
14
|
+
/** Phone identity a mobile login advertises in its `UserAgent`. */
|
|
15
|
+
export interface MobileLoginDetails {
|
|
16
|
+
readonly manufacturer: string;
|
|
17
|
+
readonly device: string;
|
|
18
|
+
readonly osVersion: string;
|
|
19
|
+
readonly osBuildNumber: string;
|
|
20
|
+
/** Dotted Android app version rebuilt from the `AppVersion` message. */
|
|
21
|
+
readonly appVersion: string;
|
|
22
|
+
readonly phoneId: string;
|
|
23
|
+
readonly mcc: string;
|
|
24
|
+
readonly mnc: string;
|
|
25
|
+
readonly pushName: string | null;
|
|
26
|
+
readonly yearClass: number | null;
|
|
27
|
+
readonly memClass: number | null;
|
|
28
|
+
}
|
|
8
29
|
export interface LoginPayload {
|
|
9
30
|
readonly kind: 'login';
|
|
10
31
|
readonly raw: Proto.IClientPayload;
|
|
11
32
|
readonly username: string;
|
|
12
33
|
readonly device: number;
|
|
13
34
|
readonly loginCounter: number;
|
|
35
|
+
readonly flavor: ClientPayloadFlavor;
|
|
36
|
+
/** Phone details for a `mobile` login; `null` for a web login. */
|
|
37
|
+
readonly mobile: MobileLoginDetails | null;
|
|
14
38
|
}
|
|
15
39
|
export type ParsedClientPayload = RegistrationPayload | LoginPayload;
|
|
16
40
|
export declare class ClientPayloadValidationError extends Error {
|
|
@@ -32,14 +32,90 @@ function parseClientPayload(bytes) {
|
|
|
32
32
|
if (raw.username === undefined || raw.username === null) {
|
|
33
33
|
throw new ClientPayloadValidationError('missing_username', 'login ClientPayload is missing the username field');
|
|
34
34
|
}
|
|
35
|
+
const flavor = resolveLoginFlavor(raw);
|
|
35
36
|
return {
|
|
36
37
|
kind: 'login',
|
|
37
38
|
raw,
|
|
38
39
|
username: String(raw.username),
|
|
39
40
|
device: typeof raw.device === 'number' ? raw.device : 0,
|
|
40
|
-
loginCounter: typeof raw.lc === 'number' ? raw.lc : 0
|
|
41
|
+
loginCounter: typeof raw.lc === 'number' ? raw.lc : 0,
|
|
42
|
+
flavor,
|
|
43
|
+
mobile: flavor === 'mobile' ? parseMobileDetails(raw) : null
|
|
41
44
|
};
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the transport flavor of a login payload. `webInfo` and the `WEB`
|
|
48
|
+
* platform mark a companion; a phone is identified by the device fields only
|
|
49
|
+
* the mobile payload carries. Sniffing those fields (rather than the platform
|
|
50
|
+
* enum alone) is deliberate: `ANDROID` is the zero value of the enum, so a
|
|
51
|
+
* payload that omits `platform` entirely would otherwise read as mobile.
|
|
52
|
+
*/
|
|
53
|
+
function resolveLoginFlavor(raw) {
|
|
54
|
+
if (raw.webInfo) {
|
|
55
|
+
return 'web';
|
|
56
|
+
}
|
|
57
|
+
const userAgent = raw.userAgent;
|
|
58
|
+
if (!userAgent || userAgent.platform === protos_1.proto.ClientPayload.UserAgent.Platform.WEB) {
|
|
59
|
+
return 'web';
|
|
60
|
+
}
|
|
61
|
+
return userAgent.manufacturer && userAgent.device ? 'mobile' : 'web';
|
|
62
|
+
}
|
|
63
|
+
function parseMobileDetails(raw) {
|
|
64
|
+
const userAgent = raw.userAgent;
|
|
65
|
+
if (!userAgent) {
|
|
66
|
+
throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload is missing the userAgent');
|
|
67
|
+
}
|
|
68
|
+
const required = [
|
|
69
|
+
['manufacturer', 'device manufacturer'],
|
|
70
|
+
['device', 'device model'],
|
|
71
|
+
['osVersion', 'os version'],
|
|
72
|
+
['osBuildNumber', 'os build number'],
|
|
73
|
+
['phoneId', 'phone id']
|
|
74
|
+
];
|
|
75
|
+
for (const [field, label] of required) {
|
|
76
|
+
const value = userAgent[field];
|
|
77
|
+
if (value === undefined || value === null || value === '') {
|
|
78
|
+
throw new ClientPayloadValidationError('missing_field', `mobile login ClientPayload missing ${label} (userAgent.${String(field)})`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const appVersion = userAgent.appVersion;
|
|
82
|
+
if (!appVersion || appVersion.primary === undefined || appVersion.primary === null) {
|
|
83
|
+
throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload missing the app version (userAgent.appVersion)');
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
manufacturer: String(userAgent.manufacturer),
|
|
87
|
+
device: String(userAgent.device),
|
|
88
|
+
osVersion: String(userAgent.osVersion),
|
|
89
|
+
osBuildNumber: String(userAgent.osBuildNumber),
|
|
90
|
+
appVersion: formatAppVersion(appVersion),
|
|
91
|
+
phoneId: String(userAgent.phoneId),
|
|
92
|
+
mcc: userAgent.mcc ? String(userAgent.mcc) : '000',
|
|
93
|
+
mnc: userAgent.mnc ? String(userAgent.mnc) : '000',
|
|
94
|
+
pushName: typeof raw.pushName === 'string' ? raw.pushName : null,
|
|
95
|
+
yearClass: toOptionalNumber(raw.yearClass),
|
|
96
|
+
memClass: toOptionalNumber(raw.memClass)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function formatAppVersion(version) {
|
|
100
|
+
const parts = [
|
|
101
|
+
version.primary,
|
|
102
|
+
version.secondary,
|
|
103
|
+
version.tertiary,
|
|
104
|
+
version.quaternary,
|
|
105
|
+
version.quinary
|
|
106
|
+
];
|
|
107
|
+
const present = [];
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
if (part === undefined || part === null) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
present.push(Number(part));
|
|
113
|
+
}
|
|
114
|
+
return present.join('.');
|
|
115
|
+
}
|
|
116
|
+
function toOptionalNumber(value) {
|
|
117
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
118
|
+
}
|
|
43
119
|
function validateRegistrationFields(devicePairingData) {
|
|
44
120
|
const required = [
|
|
45
121
|
['eIdent', 'identity public key'],
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/** Pairing IQ builders/parsers (source: `WAWebHandlePairDevice*`, `/wa-web`). */
|
|
2
2
|
import type { BinaryNode } from '../../transport/codec';
|
|
3
|
+
/**
|
|
4
|
+
* Mints the rotating refs a `pair-device` push carries.
|
|
5
|
+
*
|
|
6
|
+
* They are printable on purpose. A client reads `<ref>` as text and pastes it
|
|
7
|
+
* into a comma-separated QR payload, so raw random bytes can decode into a
|
|
8
|
+
* comma or a newline and quietly corrupt whatever reads that payload back -
|
|
9
|
+
* including the primary that has to echo the ref in its upload.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mintPairingRefs(count?: number): Promise<string[]>;
|
|
3
12
|
export interface BuildPairDeviceIqInput {
|
|
4
13
|
readonly id?: string;
|
|
5
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The six rotating refs the companion turns into a QR. The client reads
|
|
16
|
+
* them as text, so a primary-driven link (where the ref has to survive the
|
|
17
|
+
* round trip back in the primary's upload) issues printable strings.
|
|
18
|
+
*/
|
|
19
|
+
readonly refs: readonly (Uint8Array | string)[];
|
|
6
20
|
}
|
|
7
21
|
export declare function buildPairDeviceIq(input: BuildPairDeviceIqInput): BinaryNode;
|
|
8
22
|
export interface BuildPairSuccessIqInput {
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/** Pairing IQ builders/parsers (source: `WAWebHandlePairDevice*`, `/wa-web`). */
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.mintPairingRefs = mintPairingRefs;
|
|
4
5
|
exports.buildPairDeviceIq = buildPairDeviceIq;
|
|
5
6
|
exports.buildPairSuccessIq = buildPairSuccessIq;
|
|
6
7
|
exports.parsePairingQrString = parsePairingQrString;
|
|
8
|
+
const crypto_1 = require("../../transport/crypto");
|
|
7
9
|
const util_1 = require("../../transport/util");
|
|
10
|
+
const PAIR_DEVICE_REF_COUNT = 6;
|
|
11
|
+
const PAIR_DEVICE_REF_BYTES = 16;
|
|
12
|
+
/**
|
|
13
|
+
* Mints the rotating refs a `pair-device` push carries.
|
|
14
|
+
*
|
|
15
|
+
* They are printable on purpose. A client reads `<ref>` as text and pastes it
|
|
16
|
+
* into a comma-separated QR payload, so raw random bytes can decode into a
|
|
17
|
+
* comma or a newline and quietly corrupt whatever reads that payload back -
|
|
18
|
+
* including the primary that has to echo the ref in its upload.
|
|
19
|
+
*/
|
|
20
|
+
async function mintPairingRefs(count = PAIR_DEVICE_REF_COUNT) {
|
|
21
|
+
const raw = await Promise.all(Array.from({ length: count }, () => (0, crypto_1.randomBytesAsync)(PAIR_DEVICE_REF_BYTES)));
|
|
22
|
+
return raw.map((bytes) => (0, util_1.bytesToBase64UrlSafe)(bytes));
|
|
23
|
+
}
|
|
8
24
|
function buildPairDeviceIq(input) {
|
|
9
25
|
if (input.refs.length !== 6) {
|
|
10
26
|
throw new Error(`pair-device requires exactly 6 refs, got ${input.refs.length}`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primary-side `md` IQ parsers: a phone hosting companion devices.
|
|
3
|
+
*
|
|
4
|
+
* These are the inverse of `protocol/auth/pair-device.ts`, which drives the
|
|
5
|
+
* companion side. Here the client is the primary: it signs a companion's device
|
|
6
|
+
* identity and uploads it, republishes its key-index list, and revokes devices.
|
|
7
|
+
*/
|
|
8
|
+
import type { BinaryNode } from '../../transport/codec';
|
|
9
|
+
export interface ParsedPairDeviceUpload {
|
|
10
|
+
/** Pairing ref the companion advertised in its QR. */
|
|
11
|
+
readonly ref: string;
|
|
12
|
+
readonly companionNoisePublicKey: Uint8Array;
|
|
13
|
+
/** `ADVSignedDeviceIdentityHMAC` bytes the primary signed. */
|
|
14
|
+
readonly deviceIdentityBytes: Uint8Array;
|
|
15
|
+
/** `ADVSignedKeyIndexList` bytes for the account's device set. */
|
|
16
|
+
readonly keyIndexListBytes: Uint8Array;
|
|
17
|
+
readonly keyIndexListTimestampSeconds: number;
|
|
18
|
+
/** Encoded `ClientPairingProps`, when the primary declared them. */
|
|
19
|
+
readonly clientPropsBytes: Uint8Array | null;
|
|
20
|
+
/** AES-GCM key from the optional `<pem>` element. */
|
|
21
|
+
readonly pemKeyBytes: Uint8Array | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parses the primary's `<iq type="set" xmlns="md"><pair-device>` upload.
|
|
25
|
+
* Returns `null` when the stanza is not a well-formed pair-device upload, which
|
|
26
|
+
* lets the router fall through to another handler.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parsePairDeviceUpload(iq: BinaryNode): ParsedPairDeviceUpload | null;
|
|
29
|
+
export interface ParsedKeyIndexListPublish {
|
|
30
|
+
readonly keyIndexListBytes: Uint8Array;
|
|
31
|
+
readonly timestampSeconds: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parses the standalone key-index-list republish the primary sends whenever its
|
|
35
|
+
* companion set changes. Returns `null` when the stanza carries no list.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseKeyIndexListPublish(iq: BinaryNode): ParsedKeyIndexListPublish | null;
|
|
38
|
+
export interface ParsedRemoveCompanionDevice {
|
|
39
|
+
/** Device to unlink; `null` when the stanza revokes every companion. */
|
|
40
|
+
readonly deviceJid: string | null;
|
|
41
|
+
/** `all="true"` – the phone's "log out all companion devices". */
|
|
42
|
+
readonly all: boolean;
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
readonly excludeHostedCompanion: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parses `<remove-companion-device>`. The same stanza serves a companion
|
|
48
|
+
* logging itself out (`jid` = its own device) and a primary unlinking one of
|
|
49
|
+
* its companions, so the caller decides by comparing against the connection's
|
|
50
|
+
* own jid.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseRemoveCompanionDevice(iq: BinaryNode): ParsedRemoveCompanionDevice | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Primary-side `md` IQ parsers: a phone hosting companion devices.
|
|
4
|
+
*
|
|
5
|
+
* These are the inverse of `protocol/auth/pair-device.ts`, which drives the
|
|
6
|
+
* companion side. Here the client is the primary: it signs a companion's device
|
|
7
|
+
* identity and uploads it, republishes its key-index list, and revokes devices.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.parsePairDeviceUpload = parsePairDeviceUpload;
|
|
11
|
+
exports.parseKeyIndexListPublish = parseKeyIndexListPublish;
|
|
12
|
+
exports.parseRemoveCompanionDevice = parseRemoveCompanionDevice;
|
|
13
|
+
const util_1 = require("../../transport/util");
|
|
14
|
+
/**
|
|
15
|
+
* Parses the primary's `<iq type="set" xmlns="md"><pair-device>` upload.
|
|
16
|
+
* Returns `null` when the stanza is not a well-formed pair-device upload, which
|
|
17
|
+
* lets the router fall through to another handler.
|
|
18
|
+
*/
|
|
19
|
+
function parsePairDeviceUpload(iq) {
|
|
20
|
+
const pairDevice = findChild(iq, 'pair-device');
|
|
21
|
+
if (!pairDevice) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const ref = readText(findChild(pairDevice, 'ref'));
|
|
25
|
+
const companionNoisePublicKey = readBytes(findChild(pairDevice, 'pub-key'));
|
|
26
|
+
const deviceIdentityBytes = readBytes(findChild(pairDevice, 'device-identity'));
|
|
27
|
+
const keyIndexList = findChild(pairDevice, 'key-index-list');
|
|
28
|
+
const keyIndexListBytes = readBytes(keyIndexList);
|
|
29
|
+
if (!ref || !companionNoisePublicKey || !deviceIdentityBytes || !keyIndexListBytes) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
ref,
|
|
34
|
+
companionNoisePublicKey,
|
|
35
|
+
deviceIdentityBytes,
|
|
36
|
+
keyIndexListBytes,
|
|
37
|
+
keyIndexListTimestampSeconds: readTimestampSeconds(keyIndexList),
|
|
38
|
+
clientPropsBytes: readBytes(findChild(pairDevice, 'client-props')),
|
|
39
|
+
pemKeyBytes: readPemKey(pairDevice)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parses the standalone key-index-list republish the primary sends whenever its
|
|
44
|
+
* companion set changes. Returns `null` when the stanza carries no list.
|
|
45
|
+
*/
|
|
46
|
+
function parseKeyIndexListPublish(iq) {
|
|
47
|
+
const keyIndexList = findChild(iq, 'key-index-list');
|
|
48
|
+
const keyIndexListBytes = readBytes(keyIndexList);
|
|
49
|
+
if (!keyIndexListBytes) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
keyIndexListBytes,
|
|
54
|
+
timestampSeconds: readTimestampSeconds(keyIndexList)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses `<remove-companion-device>`. The same stanza serves a companion
|
|
59
|
+
* logging itself out (`jid` = its own device) and a primary unlinking one of
|
|
60
|
+
* its companions, so the caller decides by comparing against the connection's
|
|
61
|
+
* own jid.
|
|
62
|
+
*/
|
|
63
|
+
function parseRemoveCompanionDevice(iq) {
|
|
64
|
+
const remove = findChild(iq, 'remove-companion-device');
|
|
65
|
+
if (!remove) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const jid = remove.attrs.jid;
|
|
69
|
+
return {
|
|
70
|
+
deviceJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
|
|
71
|
+
all: remove.attrs.all === 'true',
|
|
72
|
+
reason: typeof remove.attrs.reason === 'string' ? remove.attrs.reason : '',
|
|
73
|
+
excludeHostedCompanion: remove.attrs.exclude_hosted_companion === 'true'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function findChild(parent, tag) {
|
|
77
|
+
if (!Array.isArray(parent.content)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return parent.content.find((child) => child.tag === tag) ?? null;
|
|
81
|
+
}
|
|
82
|
+
function readBytes(node) {
|
|
83
|
+
if (!node) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return node.content instanceof Uint8Array ? node.content : null;
|
|
87
|
+
}
|
|
88
|
+
function readText(node) {
|
|
89
|
+
if (!node) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (typeof node.content === 'string') {
|
|
93
|
+
return node.content;
|
|
94
|
+
}
|
|
95
|
+
return node.content instanceof Uint8Array ? util_1.TEXT_DECODER.decode(node.content) : null;
|
|
96
|
+
}
|
|
97
|
+
function readTimestampSeconds(node) {
|
|
98
|
+
const raw = node?.attrs.ts;
|
|
99
|
+
const parsed = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN;
|
|
100
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
101
|
+
}
|
|
102
|
+
/** The `<pem>` wrapper nests the key bytes in a second `<pem>` element. */
|
|
103
|
+
function readPemKey(pairDevice) {
|
|
104
|
+
const pem = findChild(pairDevice, 'pem');
|
|
105
|
+
if (!pem) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return readBytes(findChild(pem, 'pem'));
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-code (pairing-code) relay stanzas.
|
|
3
|
+
*
|
|
4
|
+
* The handshake runs between two clients with the server in the middle: the
|
|
5
|
+
* companion asks for a code (`companion_hello`), the primary answers with its
|
|
6
|
+
* wrapped ephemeral (`primary_hello`), and the companion closes with the key
|
|
7
|
+
* bundle (`companion_finish`). Each stage arrives as an IQ from one client and
|
|
8
|
+
* leaves as a notification to the other; the server only routes and mints the
|
|
9
|
+
* ref that ties the three together.
|
|
10
|
+
*/
|
|
11
|
+
import type { BinaryNode } from '../../transport/codec';
|
|
12
|
+
export type LinkCodeStage = 'companion_hello' | 'primary_hello' | 'companion_finish';
|
|
13
|
+
export interface ParsedLinkCodeStanza {
|
|
14
|
+
readonly stage: LinkCodeStage;
|
|
15
|
+
/** Account the companion wants to link to, on the stages that carry it. */
|
|
16
|
+
readonly phoneJid: string | null;
|
|
17
|
+
/** Ref the stage refers to; absent on `companion_hello`, which mints it. */
|
|
18
|
+
readonly ref: string | null;
|
|
19
|
+
/** The stage's payload elements, relayed verbatim to the other client. */
|
|
20
|
+
readonly children: readonly BinaryNode[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parses a `<link_code_companion_reg>` IQ. Returns `null` for a stage this
|
|
24
|
+
* server does not relay (for example `get_country_code`), letting the router
|
|
25
|
+
* fall through to another handler.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseLinkCodeStanza(iq: BinaryNode): ParsedLinkCodeStanza | null;
|
|
28
|
+
/**
|
|
29
|
+
* Builds the `companion_hello` IQ result carrying the ref the server minted.
|
|
30
|
+
* The companion keys its whole pairing session off it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildCompanionHelloResultContent(ref: string): BinaryNode[];
|
|
33
|
+
export interface BuildLinkCodeNotificationInput {
|
|
34
|
+
readonly stage: LinkCodeStage;
|
|
35
|
+
readonly ref: string;
|
|
36
|
+
/** Payload elements copied from the originating stanza. */
|
|
37
|
+
readonly children: readonly BinaryNode[];
|
|
38
|
+
readonly id?: string;
|
|
39
|
+
readonly to?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Wraps a relayed stage as the notification the receiving client handles. The
|
|
43
|
+
* ref is (re)stamped so the receiver can match it against its pairing session,
|
|
44
|
+
* replacing any copy carried by the source stanza.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildLinkCodeNotification(input: BuildLinkCodeNotificationInput): BinaryNode;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Link-code (pairing-code) relay stanzas.
|
|
4
|
+
*
|
|
5
|
+
* The handshake runs between two clients with the server in the middle: the
|
|
6
|
+
* companion asks for a code (`companion_hello`), the primary answers with its
|
|
7
|
+
* wrapped ephemeral (`primary_hello`), and the companion closes with the key
|
|
8
|
+
* bundle (`companion_finish`). Each stage arrives as an IQ from one client and
|
|
9
|
+
* leaves as a notification to the other; the server only routes and mints the
|
|
10
|
+
* ref that ties the three together.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseLinkCodeStanza = parseLinkCodeStanza;
|
|
14
|
+
exports.buildCompanionHelloResultContent = buildCompanionHelloResultContent;
|
|
15
|
+
exports.buildLinkCodeNotification = buildLinkCodeNotification;
|
|
16
|
+
const util_1 = require("../../transport/util");
|
|
17
|
+
const RELAYED_STAGES = new Set([
|
|
18
|
+
'companion_hello',
|
|
19
|
+
'primary_hello',
|
|
20
|
+
'companion_finish'
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Parses a `<link_code_companion_reg>` IQ. Returns `null` for a stage this
|
|
24
|
+
* server does not relay (for example `get_country_code`), letting the router
|
|
25
|
+
* fall through to another handler.
|
|
26
|
+
*/
|
|
27
|
+
function parseLinkCodeStanza(iq) {
|
|
28
|
+
const node = findChild(iq, 'link_code_companion_reg');
|
|
29
|
+
const stage = node?.attrs.stage;
|
|
30
|
+
if (!node || typeof stage !== 'string' || !RELAYED_STAGES.has(stage)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const jid = node.attrs.jid;
|
|
34
|
+
return {
|
|
35
|
+
stage: stage,
|
|
36
|
+
phoneJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
|
|
37
|
+
ref: readText(findChild(node, 'link_code_pairing_ref')),
|
|
38
|
+
children: Array.isArray(node.content) ? node.content : []
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds the `companion_hello` IQ result carrying the ref the server minted.
|
|
43
|
+
* The companion keys its whole pairing session off it.
|
|
44
|
+
*/
|
|
45
|
+
function buildCompanionHelloResultContent(ref) {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
tag: 'link_code_companion_reg',
|
|
49
|
+
attrs: { stage: 'companion_hello' },
|
|
50
|
+
content: [{ tag: 'link_code_pairing_ref', attrs: {}, content: ref }]
|
|
51
|
+
}
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wraps a relayed stage as the notification the receiving client handles. The
|
|
56
|
+
* ref is (re)stamped so the receiver can match it against its pairing session,
|
|
57
|
+
* replacing any copy carried by the source stanza.
|
|
58
|
+
*/
|
|
59
|
+
function buildLinkCodeNotification(input) {
|
|
60
|
+
const children = input.children.filter((child) => child.tag !== 'link_code_pairing_ref');
|
|
61
|
+
return {
|
|
62
|
+
tag: 'notification',
|
|
63
|
+
attrs: {
|
|
64
|
+
id: input.id ?? `link-code-${Math.random().toString(36).slice(2, 10)}`,
|
|
65
|
+
from: 's.whatsapp.net',
|
|
66
|
+
type: 'link_code_companion_reg',
|
|
67
|
+
...(input.to !== undefined ? { to: input.to } : {})
|
|
68
|
+
},
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
tag: 'link_code_companion_reg',
|
|
72
|
+
attrs: { stage: input.stage },
|
|
73
|
+
content: [
|
|
74
|
+
{ tag: 'link_code_pairing_ref', attrs: {}, content: input.ref },
|
|
75
|
+
...children
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function findChild(parent, tag) {
|
|
82
|
+
if (!Array.isArray(parent.content)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return parent.content.find((child) => child.tag === tag) ?? null;
|
|
86
|
+
}
|
|
87
|
+
function readText(node) {
|
|
88
|
+
if (!node) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (typeof node.content === 'string') {
|
|
92
|
+
return node.content;
|
|
93
|
+
}
|
|
94
|
+
return node.content instanceof Uint8Array ? util_1.TEXT_DECODER.decode(node.content) : null;
|
|
95
|
+
}
|