@zapo-js/fake-server 1.1.0 → 1.3.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 +89 -30
- 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/FakeServerSession.d.ts +35 -2
- package/dist/api/FakeServerSession.js +65 -3
- package/dist/api/FakeWaServer.d.ts +90 -1
- package/dist/api/FakeWaServer.js +231 -5
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +19 -1
- package/dist/api/iq-handlers.js +96 -1
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakeServerSession.js +66 -4
- package/dist/esm/api/FakeWaServer.js +231 -5
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +96 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +17 -5
- 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/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 +3 -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 +1 -0
- package/dist/index.d.ts +23 -2
- package/dist/index.js +28 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +1 -0
- package/dist/infra/WaFakeConnectionPipeline.js +17 -5
- 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/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 +15 -2
- package/dist/protocol/iq/router.js +3 -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 +1 -0
- package/dist/transport/util.js +2 -1
- package/package.json +1 -1
|
@@ -186,16 +186,31 @@ export class ServerRegistries {
|
|
|
186
186
|
}
|
|
187
187
|
registerPeer(peer) {
|
|
188
188
|
this.peerRegistry.set(peer.jid, peer);
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
this.registerDeviceId(toUserJidPart(peer.jid), toDeviceIdPart(peer.jid));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Makes a device visible to `usync` without minting a full fake peer – used
|
|
193
|
+
* for the primary's own device set, whose companions are real clients with
|
|
194
|
+
* their own keys rather than server-side fakes.
|
|
195
|
+
*/
|
|
196
|
+
registerDeviceId(userJid, deviceId) {
|
|
191
197
|
const ids = this.deviceIdsByUser.get(userJid);
|
|
192
|
-
if (ids) {
|
|
198
|
+
if (!ids) {
|
|
199
|
+
this.deviceIdsByUser.set(userJid, [deviceId]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!ids.includes(deviceId)) {
|
|
193
203
|
ids.push(deviceId);
|
|
194
204
|
ids.sort((a, b) => a - b);
|
|
195
205
|
}
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
}
|
|
207
|
+
unregisterDeviceId(userJid, deviceId) {
|
|
208
|
+
const ids = this.deviceIdsByUser.get(userJid);
|
|
209
|
+
if (!ids) {
|
|
210
|
+
return;
|
|
198
211
|
}
|
|
212
|
+
const next = ids.filter((id) => id !== deviceId);
|
|
213
|
+
this.deviceIdsByUser.set(userJid, next);
|
|
199
214
|
}
|
|
200
215
|
lookupDeviceIdsForUser(userJid) {
|
|
201
216
|
return this.deviceIdsByUser.get(userJid) ?? [];
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { buildAbPropsResult } from '../protocol/iq/abprops.js';
|
|
3
3
|
import { buildAppStateSyncFullResult, buildAppStateSyncResult, parseAppStateSyncRequest } from '../protocol/iq/appstate-sync.js';
|
|
4
4
|
import { buildBusinessProfileResult, parseGetBusinessProfileIq } from '../protocol/iq/business.js';
|
|
5
|
+
import { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from '../protocol/iq/companion-host.js';
|
|
5
6
|
import { parseClearDirtyBitsIq } from '../protocol/iq/dirty-bits.js';
|
|
6
7
|
import { buildGroupMetadataNode, buildGroupParticipantChangeResult, parseCreateGroupIq, parseGroupParticipantChangeIq, parseLeaveGroupIq, parseSetDescriptionIq, parseSetSubjectIq } from '../protocol/iq/group-ops.js';
|
|
7
8
|
import { buildNewsletterMyAddonsResult } from '../protocol/iq/newsletter.js';
|
|
@@ -73,6 +74,28 @@ function buildGroupMetadataReply(iq, metadata) {
|
|
|
73
74
|
]
|
|
74
75
|
};
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* True when the stanza arrived on the phone that owns this session's account.
|
|
79
|
+
* Being a phone login is not enough: a second number connecting to the same
|
|
80
|
+
* session must not drive the companion set of the account that owns it.
|
|
81
|
+
*/
|
|
82
|
+
function isSessionOwnerConnection(context, deps) {
|
|
83
|
+
const payload = context?.connection.clientPayload;
|
|
84
|
+
if (payload?.kind !== 'login' || payload.flavor !== 'mobile') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return deps.mobilePrimary?.username === payload.username;
|
|
88
|
+
}
|
|
89
|
+
/** The device jid a connection speaks for, or `null` before it logs in. */
|
|
90
|
+
function ownDeviceJid(context) {
|
|
91
|
+
const payload = context?.connection.clientPayload;
|
|
92
|
+
if (payload?.kind !== 'login') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return payload.device > 0
|
|
96
|
+
? `${payload.username}:${payload.device}@s.whatsapp.net`
|
|
97
|
+
: `${payload.username}@s.whatsapp.net`;
|
|
98
|
+
}
|
|
76
99
|
export function registerDefaultIqHandlers(router, deps) {
|
|
77
100
|
router.register({
|
|
78
101
|
label: 'prekey-upload',
|
|
@@ -244,10 +267,82 @@ export function registerDefaultIqHandlers(router, deps) {
|
|
|
244
267
|
matcher: { xmlns: 'encrypt', type: 'set', childTag: 'rotate' },
|
|
245
268
|
respond: (iq) => buildIqResult(iq)
|
|
246
269
|
});
|
|
270
|
+
router.register({
|
|
271
|
+
label: 'link-code-companion-reg',
|
|
272
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'link_code_companion_reg' },
|
|
273
|
+
respond: (iq, context) => deps.relayLinkCodeStage(iq, context)
|
|
274
|
+
});
|
|
275
|
+
router.register({
|
|
276
|
+
label: 'companion-pair-device',
|
|
277
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'pair-device' },
|
|
278
|
+
respond: async (iq, context) => {
|
|
279
|
+
const upload = parsePairDeviceUpload(iq);
|
|
280
|
+
if (!upload) {
|
|
281
|
+
return buildIqError(iq, { code: 400, text: 'invalid-pair-device' });
|
|
282
|
+
}
|
|
283
|
+
// The link is minted under the account that owns the session, so
|
|
284
|
+
// only that account's phone may ask for one.
|
|
285
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
286
|
+
return buildIqError(iq, { code: 403, text: 'not-authorized' });
|
|
287
|
+
}
|
|
288
|
+
const linked = await deps.linkCompanionDevice(upload);
|
|
289
|
+
if (!linked) {
|
|
290
|
+
return buildIqError(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
291
|
+
}
|
|
292
|
+
// Shape mirrors the phone's parser: <device jid> is a direct child
|
|
293
|
+
// of the result, with the companion's own props beside it.
|
|
294
|
+
const content = [{ tag: 'device', attrs: { jid: linked.deviceJid } }];
|
|
295
|
+
if (linked.companionPropsBytes) {
|
|
296
|
+
content.push({
|
|
297
|
+
tag: 'companion-props',
|
|
298
|
+
attrs: {},
|
|
299
|
+
content: linked.companionPropsBytes
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return buildIqResult(iq, { content });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
router.register({
|
|
306
|
+
label: 'companion-key-index-list',
|
|
307
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'key-index-list' },
|
|
308
|
+
respond: (iq, context) => {
|
|
309
|
+
const published = parseKeyIndexListPublish(iq);
|
|
310
|
+
if (!published) {
|
|
311
|
+
return buildIqError(iq, { code: 400, text: 'invalid-key-index-list' });
|
|
312
|
+
}
|
|
313
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
314
|
+
return buildIqError(iq, { code: 403, text: 'not-authorized' });
|
|
315
|
+
}
|
|
316
|
+
deps.recordKeyIndexList(published.keyIndexListBytes, published.timestampSeconds);
|
|
317
|
+
return buildIqResult(iq);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
247
320
|
router.register({
|
|
248
321
|
label: 'remove-companion-device',
|
|
249
322
|
matcher: { xmlns: 'md', type: 'set', childTag: 'remove-companion-device' },
|
|
250
|
-
respond: (iq) => {
|
|
323
|
+
respond: (iq, context) => {
|
|
324
|
+
const removal = parseRemoveCompanionDevice(iq);
|
|
325
|
+
// The same stanza means "log me out" from a companion and "unlink
|
|
326
|
+
// that device" from a primary. The wire does not say which, but the
|
|
327
|
+
// connection does: the account's own phone is always revoking
|
|
328
|
+
// someone else's device, and never ends its own session this way.
|
|
329
|
+
if (removal && isSessionOwnerConnection(context, deps)) {
|
|
330
|
+
if (!removal.all) {
|
|
331
|
+
deps.revokeCompanionDevices(removal.deviceJid ? [removal.deviceJid] : []);
|
|
332
|
+
}
|
|
333
|
+
else if (!removal.excludeHostedCompanion) {
|
|
334
|
+
// The hosted set is exactly what this session tracks, so
|
|
335
|
+
// sparing it leaves the registry untouched.
|
|
336
|
+
deps.revokeCompanionDevices(null);
|
|
337
|
+
}
|
|
338
|
+
return buildIqResult(iq);
|
|
339
|
+
}
|
|
340
|
+
// Anyone else may only unlink itself: a connection that is not the
|
|
341
|
+
// account's phone must never drop someone else's device. Its own
|
|
342
|
+
// session ends either way.
|
|
343
|
+
if (removal?.deviceJid && removal.deviceJid === ownDeviceJid(context)) {
|
|
344
|
+
deps.revokeCompanionDevices([removal.deviceJid]);
|
|
345
|
+
}
|
|
251
346
|
deps.notifyLogout();
|
|
252
347
|
return buildIqResult(iq);
|
|
253
348
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
export { createFakeMobilePrimary, seedFakeMobilePrimary } from './api/FakeMobilePrimary.js';
|
|
1
2
|
export { FakePairingDriver } from './api/FakePairingDriver.js';
|
|
2
3
|
export { FakePeer } from './api/FakePeer.js';
|
|
3
4
|
export { FakeServerSession, FakeWaServer } from './api/FakeWaServer.js';
|
|
4
5
|
export { IqExpectation, Scenario } from './api/Scenario.js';
|
|
5
6
|
export { WaFakeConnection } from './infra/WaFakeConnection.js';
|
|
7
|
+
export { WaFakeTcpServer } from './infra/WaFakeTcpServer.js';
|
|
8
|
+
export { createTcpSocketAdapter, createWebSocketAdapter } from './infra/socket-adapters.js';
|
|
9
|
+
export { ClientPayloadValidationError, parseClientPayload } from './protocol/auth/client-payload-validate.js';
|
|
10
|
+
export { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from './protocol/iq/companion-host.js';
|
|
11
|
+
export { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from './protocol/iq/link-code.js';
|
|
12
|
+
export { buildAccountSyncDevicesNotification, buildAccountTakeoverNotice, buildRegistrationCodeNotification } from './protocol/push/mobile-notification.js';
|
|
13
|
+
export { FakeCompanionHostState, readCompanionKeyIndex } from './state/fake-companion-host.js';
|
|
6
14
|
export { buildAppStateSyncFullResult, buildAppStateSyncResult, buildServerSyncNotification, parseAppStateSyncRequest } from './protocol/iq/appstate-sync.js';
|
|
7
15
|
export { buildIqError, buildIqResult } from './protocol/iq/router.js';
|
|
8
16
|
export { buildCall, buildFailure } from './protocol/push/call-failure.js';
|
|
@@ -4,7 +4,14 @@ export class WaFakeConnection {
|
|
|
4
4
|
this.currentState = 'open';
|
|
5
5
|
this.id = id;
|
|
6
6
|
this.socket = socket;
|
|
7
|
-
this.
|
|
7
|
+
this.socket.listen({
|
|
8
|
+
onFrame: (frame) => this.handlers.onFrame?.(frame),
|
|
9
|
+
onClose: (info) => {
|
|
10
|
+
this.currentState = 'closed';
|
|
11
|
+
this.handlers.onClose?.(info);
|
|
12
|
+
},
|
|
13
|
+
onError: (error) => this.handlers.onError?.(error)
|
|
14
|
+
});
|
|
8
15
|
}
|
|
9
16
|
get state() {
|
|
10
17
|
return this.currentState;
|
|
@@ -25,25 +32,4 @@ export class WaFakeConnection {
|
|
|
25
32
|
this.currentState = 'closing';
|
|
26
33
|
this.socket.close(code, reason);
|
|
27
34
|
}
|
|
28
|
-
bindSocketEvents() {
|
|
29
|
-
this.socket.on('message', (data, isBinary) => {
|
|
30
|
-
if (!isBinary) {
|
|
31
|
-
this.handlers.onError?.(new Error('received unexpected text frame'));
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const frame = data instanceof Uint8Array
|
|
35
|
-
? data
|
|
36
|
-
: Array.isArray(data)
|
|
37
|
-
? new Uint8Array(Buffer.concat(data))
|
|
38
|
-
: new Uint8Array(data);
|
|
39
|
-
this.handlers.onFrame?.(frame);
|
|
40
|
-
});
|
|
41
|
-
this.socket.on('close', (code, reasonBuf) => {
|
|
42
|
-
this.currentState = 'closed';
|
|
43
|
-
this.handlers.onClose?.({ code, reason: reasonBuf.toString('utf8') });
|
|
44
|
-
});
|
|
45
|
-
this.socket.on('error', (error) => {
|
|
46
|
-
this.handlers.onError?.(error);
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
35
|
}
|
|
@@ -10,17 +10,20 @@ import { WaFakeNoiseHandshake } from './WaFakeNoiseHandshake.js';
|
|
|
10
10
|
import { WaFakeTransport } from './WaFakeTransport.js';
|
|
11
11
|
const NOISE_XX_NAME = new TextEncoder().encode('Noise_XX_25519_AESGCM_SHA256\0\0\0\0');
|
|
12
12
|
const NOISE_IK_NAME = new TextEncoder().encode('Noise_IK_25519_AESGCM_SHA256\0\0\0\0');
|
|
13
|
-
const
|
|
13
|
+
const PROLOGUE_MAGIC_W = 0x57;
|
|
14
|
+
const PROLOGUE_MAGIC_A = 0x41;
|
|
15
|
+
const DEFAULT_PROLOGUE = new Uint8Array([PROLOGUE_MAGIC_W, PROLOGUE_MAGIC_A, 0x06, 0x03]);
|
|
14
16
|
export class WaFakeConnectionPipeline {
|
|
15
17
|
constructor(config) {
|
|
16
18
|
this.events = {};
|
|
17
19
|
this.state = { kind: 'awaiting_prologue' };
|
|
18
20
|
this.chain = Promise.resolve();
|
|
19
21
|
this.authenticatedPayload = null;
|
|
22
|
+
this.clientPrologue = DEFAULT_PROLOGUE;
|
|
20
23
|
this.config = config;
|
|
21
24
|
this.frameSocket = new WaFakeFrameSocket(config.connection);
|
|
22
25
|
this.frameSocket.setHandlers({
|
|
23
|
-
onPrologue: () => this.onPrologue(),
|
|
26
|
+
onPrologue: (prologue) => this.onPrologue(prologue),
|
|
24
27
|
onFrame: (frame) => this.scheduleFrame(frame),
|
|
25
28
|
onClose: (info) => {
|
|
26
29
|
this.state = { kind: 'closed' };
|
|
@@ -48,11 +51,20 @@ export class WaFakeConnectionPipeline {
|
|
|
48
51
|
const ciphertext = await this.state.transport.encryptFrame(stanza);
|
|
49
52
|
this.frameSocket.sendFrame(ciphertext);
|
|
50
53
|
}
|
|
51
|
-
onPrologue() {
|
|
54
|
+
onPrologue(prologue) {
|
|
52
55
|
if (this.state.kind !== 'awaiting_prologue') {
|
|
53
56
|
this.events.onError?.(new Error('received second prologue from client'));
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
59
|
+
// Mix back the header the client actually sent instead of a fixed one:
|
|
60
|
+
// it is hashed into the handshake, and a client is free to advertise a
|
|
61
|
+
// protocol/dict version other than the web client's 6/3.
|
|
62
|
+
this.clientPrologue = new Uint8Array([
|
|
63
|
+
PROLOGUE_MAGIC_W,
|
|
64
|
+
PROLOGUE_MAGIC_A,
|
|
65
|
+
prologue.protocolVersion,
|
|
66
|
+
prologue.dictVersion
|
|
67
|
+
]);
|
|
56
68
|
this.state = { kind: 'awaiting_client_hello' };
|
|
57
69
|
}
|
|
58
70
|
scheduleFrame(frame) {
|
|
@@ -94,7 +106,7 @@ export class WaFakeConnectionPipeline {
|
|
|
94
106
|
}
|
|
95
107
|
async handleXxClientHello(clientEphemeralPub) {
|
|
96
108
|
const handshake = new WaFakeNoiseHandshake();
|
|
97
|
-
handshake.start(NOISE_XX_NAME,
|
|
109
|
+
handshake.start(NOISE_XX_NAME, this.clientPrologue);
|
|
98
110
|
handshake.authenticate(clientEphemeralPub);
|
|
99
111
|
const serverEphemeral = await X25519.generateKeyPair();
|
|
100
112
|
handshake.authenticate(serverEphemeral.pubKey);
|
|
@@ -130,7 +142,7 @@ export class WaFakeConnectionPipeline {
|
|
|
130
142
|
const encryptedClientStatic = clientHello.static;
|
|
131
143
|
const encryptedClientPayload = clientHello.payload;
|
|
132
144
|
const handshake = new WaFakeNoiseHandshake();
|
|
133
|
-
handshake.start(NOISE_IK_NAME,
|
|
145
|
+
handshake.start(NOISE_IK_NAME, this.clientPrologue);
|
|
134
146
|
handshake.authenticate(this.config.serverStaticKeyPair.pubKey);
|
|
135
147
|
handshake.authenticate(clientEphemeralPub);
|
|
136
148
|
handshake.mixIntoKey(await X25519.scalarMult(this.config.serverStaticKeyPair.privKey, clientEphemeralPub));
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { createTcpSocketAdapter } from './socket-adapters.js';
|
|
3
|
+
import { WaFakeConnection } from './WaFakeConnection.js';
|
|
4
|
+
/**
|
|
5
|
+
* Raw-TCP listener for the WhatsApp Mobile transport. The mobile client dials
|
|
6
|
+
* `tcp://host:port` instead of upgrading to a WebSocket, but speaks the exact
|
|
7
|
+
* same prologue + length-prefixed frames on top, so every layer above the
|
|
8
|
+
* carrier is shared with {@link WaFakeWsServer}.
|
|
9
|
+
*/
|
|
10
|
+
export class WaFakeTcpServer {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.server = null;
|
|
13
|
+
this.connectionListener = null;
|
|
14
|
+
this.sockets = new Set();
|
|
15
|
+
this.nextConnectionId = 0;
|
|
16
|
+
this.options = {
|
|
17
|
+
host: options.host ?? '127.0.0.1',
|
|
18
|
+
port: options.port ?? 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
onConnection(listener) {
|
|
22
|
+
this.connectionListener = listener;
|
|
23
|
+
}
|
|
24
|
+
async listen() {
|
|
25
|
+
if (this.server) {
|
|
26
|
+
throw new Error('fake tcp server is already listening');
|
|
27
|
+
}
|
|
28
|
+
const server = createServer((socket) => {
|
|
29
|
+
this.sockets.add(socket);
|
|
30
|
+
socket.on('close', () => this.sockets.delete(socket));
|
|
31
|
+
const id = `t${this.nextConnectionId++}`;
|
|
32
|
+
this.connectionListener?.(new WaFakeConnection(id, createTcpSocketAdapter(socket)));
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const onError = (error) => {
|
|
36
|
+
server.off('listening', onListening);
|
|
37
|
+
reject(error);
|
|
38
|
+
};
|
|
39
|
+
const onListening = () => {
|
|
40
|
+
server.off('error', onError);
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
server.once('error', onError);
|
|
44
|
+
server.once('listening', onListening);
|
|
45
|
+
server.listen(this.options.port, this.options.host);
|
|
46
|
+
});
|
|
47
|
+
this.server = server;
|
|
48
|
+
const address = server.address();
|
|
49
|
+
return {
|
|
50
|
+
host: address.address,
|
|
51
|
+
port: address.port,
|
|
52
|
+
url: `tcp://${address.address}:${address.port}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
const server = this.server;
|
|
57
|
+
if (!server) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const socket of this.sockets) {
|
|
61
|
+
socket.destroy();
|
|
62
|
+
}
|
|
63
|
+
this.sockets.clear();
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
66
|
+
});
|
|
67
|
+
this.server = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { createWebSocketAdapter } from './socket-adapters.js';
|
|
3
4
|
import { WaFakeConnection } from './WaFakeConnection.js';
|
|
4
5
|
export class WaFakeWsServer {
|
|
5
6
|
constructor(options = {}) {
|
|
@@ -49,7 +50,7 @@ export class WaFakeWsServer {
|
|
|
49
50
|
const wsServer = new WebSocketServer({ server: httpServer, path: this.options.path });
|
|
50
51
|
wsServer.on('connection', (socket) => {
|
|
51
52
|
const id = `c${this.nextConnectionId++}`;
|
|
52
|
-
const connection = new WaFakeConnection(id, socket);
|
|
53
|
+
const connection = new WaFakeConnection(id, createWebSocketAdapter(socket));
|
|
53
54
|
this.connectionListener?.(connection);
|
|
54
55
|
});
|
|
55
56
|
await new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Carrier adapters: WebSocket (companion transport) and raw TCP (mobile). */
|
|
2
|
+
import { TEXT_DECODER } from '../transport/util.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adapts a `ws` socket to {@link WaFakeSocketLike}. Owns the two WebSocket
|
|
5
|
+
* quirks the rest of the server should not know about: a message can arrive as
|
|
6
|
+
* a fragment array, and a text frame is a protocol violation here because every
|
|
7
|
+
* WhatsApp frame is binary.
|
|
8
|
+
*/
|
|
9
|
+
export function createWebSocketAdapter(socket) {
|
|
10
|
+
return {
|
|
11
|
+
send: (frame) => socket.send(frame),
|
|
12
|
+
close: (code, reason) => socket.close(code, reason),
|
|
13
|
+
listen: (events) => {
|
|
14
|
+
socket.on('message', (data, isBinary) => {
|
|
15
|
+
if (!isBinary) {
|
|
16
|
+
events.onError(new Error('received unexpected text frame'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
events.onFrame(toFrameBytes(data));
|
|
20
|
+
});
|
|
21
|
+
socket.on('close', (code, reason) => {
|
|
22
|
+
events.onClose({ code, reason: TEXT_DECODER.decode(reason) });
|
|
23
|
+
});
|
|
24
|
+
socket.on('error', (error) => {
|
|
25
|
+
events.onError(error);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Adapts a Node TCP socket to {@link WaFakeSocketLike} for the mobile
|
|
32
|
+
* transport. A TCP stream has no message boundaries and no close codes: every
|
|
33
|
+
* chunk is forwarded as-is (the frame codec above reassembles them) and the
|
|
34
|
+
* code/reason the server asked for is replayed when the socket finally closes.
|
|
35
|
+
*/
|
|
36
|
+
export function createTcpSocketAdapter(socket) {
|
|
37
|
+
let closeCode = 1000;
|
|
38
|
+
let closeReason = '';
|
|
39
|
+
return {
|
|
40
|
+
send: (frame) => {
|
|
41
|
+
socket.write(frame);
|
|
42
|
+
},
|
|
43
|
+
close: (code, reason) => {
|
|
44
|
+
closeCode = code;
|
|
45
|
+
closeReason = reason;
|
|
46
|
+
socket.end();
|
|
47
|
+
},
|
|
48
|
+
listen: (events) => {
|
|
49
|
+
socket.on('data', (chunk) => {
|
|
50
|
+
events.onFrame(chunk);
|
|
51
|
+
});
|
|
52
|
+
socket.on('close', () => {
|
|
53
|
+
events.onClose({ code: closeCode, reason: closeReason });
|
|
54
|
+
});
|
|
55
|
+
socket.on('error', (error) => {
|
|
56
|
+
events.onError(error);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function toFrameBytes(data) {
|
|
62
|
+
if (data instanceof Uint8Array) {
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
if (data instanceof ArrayBuffer) {
|
|
66
|
+
return new Uint8Array(data);
|
|
67
|
+
}
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const part of data) {
|
|
70
|
+
total += part.byteLength;
|
|
71
|
+
}
|
|
72
|
+
const out = new Uint8Array(total);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const part of data) {
|
|
75
|
+
out.set(part, offset);
|
|
76
|
+
offset += part.byteLength;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -27,14 +27,90 @@ export function parseClientPayload(bytes) {
|
|
|
27
27
|
if (raw.username === undefined || raw.username === null) {
|
|
28
28
|
throw new ClientPayloadValidationError('missing_username', 'login ClientPayload is missing the username field');
|
|
29
29
|
}
|
|
30
|
+
const flavor = resolveLoginFlavor(raw);
|
|
30
31
|
return {
|
|
31
32
|
kind: 'login',
|
|
32
33
|
raw,
|
|
33
34
|
username: String(raw.username),
|
|
34
35
|
device: typeof raw.device === 'number' ? raw.device : 0,
|
|
35
|
-
loginCounter: typeof raw.lc === 'number' ? raw.lc : 0
|
|
36
|
+
loginCounter: typeof raw.lc === 'number' ? raw.lc : 0,
|
|
37
|
+
flavor,
|
|
38
|
+
mobile: flavor === 'mobile' ? parseMobileDetails(raw) : null
|
|
36
39
|
};
|
|
37
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves the transport flavor of a login payload. `webInfo` and the `WEB`
|
|
43
|
+
* platform mark a companion; a phone is identified by the device fields only
|
|
44
|
+
* the mobile payload carries. Sniffing those fields (rather than the platform
|
|
45
|
+
* enum alone) is deliberate: `ANDROID` is the zero value of the enum, so a
|
|
46
|
+
* payload that omits `platform` entirely would otherwise read as mobile.
|
|
47
|
+
*/
|
|
48
|
+
function resolveLoginFlavor(raw) {
|
|
49
|
+
if (raw.webInfo) {
|
|
50
|
+
return 'web';
|
|
51
|
+
}
|
|
52
|
+
const userAgent = raw.userAgent;
|
|
53
|
+
if (!userAgent || userAgent.platform === proto.ClientPayload.UserAgent.Platform.WEB) {
|
|
54
|
+
return 'web';
|
|
55
|
+
}
|
|
56
|
+
return userAgent.manufacturer && userAgent.device ? 'mobile' : 'web';
|
|
57
|
+
}
|
|
58
|
+
function parseMobileDetails(raw) {
|
|
59
|
+
const userAgent = raw.userAgent;
|
|
60
|
+
if (!userAgent) {
|
|
61
|
+
throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload is missing the userAgent');
|
|
62
|
+
}
|
|
63
|
+
const required = [
|
|
64
|
+
['manufacturer', 'device manufacturer'],
|
|
65
|
+
['device', 'device model'],
|
|
66
|
+
['osVersion', 'os version'],
|
|
67
|
+
['osBuildNumber', 'os build number'],
|
|
68
|
+
['phoneId', 'phone id']
|
|
69
|
+
];
|
|
70
|
+
for (const [field, label] of required) {
|
|
71
|
+
const value = userAgent[field];
|
|
72
|
+
if (value === undefined || value === null || value === '') {
|
|
73
|
+
throw new ClientPayloadValidationError('missing_field', `mobile login ClientPayload missing ${label} (userAgent.${String(field)})`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const appVersion = userAgent.appVersion;
|
|
77
|
+
if (!appVersion || appVersion.primary === undefined || appVersion.primary === null) {
|
|
78
|
+
throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload missing the app version (userAgent.appVersion)');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
manufacturer: String(userAgent.manufacturer),
|
|
82
|
+
device: String(userAgent.device),
|
|
83
|
+
osVersion: String(userAgent.osVersion),
|
|
84
|
+
osBuildNumber: String(userAgent.osBuildNumber),
|
|
85
|
+
appVersion: formatAppVersion(appVersion),
|
|
86
|
+
phoneId: String(userAgent.phoneId),
|
|
87
|
+
mcc: userAgent.mcc ? String(userAgent.mcc) : '000',
|
|
88
|
+
mnc: userAgent.mnc ? String(userAgent.mnc) : '000',
|
|
89
|
+
pushName: typeof raw.pushName === 'string' ? raw.pushName : null,
|
|
90
|
+
yearClass: toOptionalNumber(raw.yearClass),
|
|
91
|
+
memClass: toOptionalNumber(raw.memClass)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function formatAppVersion(version) {
|
|
95
|
+
const parts = [
|
|
96
|
+
version.primary,
|
|
97
|
+
version.secondary,
|
|
98
|
+
version.tertiary,
|
|
99
|
+
version.quaternary,
|
|
100
|
+
version.quinary
|
|
101
|
+
];
|
|
102
|
+
const present = [];
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
if (part === undefined || part === null) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
present.push(Number(part));
|
|
108
|
+
}
|
|
109
|
+
return present.join('.');
|
|
110
|
+
}
|
|
111
|
+
function toOptionalNumber(value) {
|
|
112
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
113
|
+
}
|
|
38
114
|
function validateRegistrationFields(devicePairingData) {
|
|
39
115
|
const required = [
|
|
40
116
|
['eIdent', 'identity public key'],
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
/** Pairing IQ builders/parsers (source: `WAWebHandlePairDevice*`, `/wa-web`). */
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytesAsync } from '../../transport/crypto.js';
|
|
3
|
+
import { bytesToBase64UrlSafe, decodeBase64Url } from '../../transport/util.js';
|
|
4
|
+
const PAIR_DEVICE_REF_COUNT = 6;
|
|
5
|
+
const PAIR_DEVICE_REF_BYTES = 16;
|
|
6
|
+
/**
|
|
7
|
+
* Mints the rotating refs a `pair-device` push carries.
|
|
8
|
+
*
|
|
9
|
+
* They are printable on purpose. A client reads `<ref>` as text and pastes it
|
|
10
|
+
* into a comma-separated QR payload, so raw random bytes can decode into a
|
|
11
|
+
* comma or a newline and quietly corrupt whatever reads that payload back -
|
|
12
|
+
* including the primary that has to echo the ref in its upload.
|
|
13
|
+
*/
|
|
14
|
+
export async function mintPairingRefs(count = PAIR_DEVICE_REF_COUNT) {
|
|
15
|
+
const raw = await Promise.all(Array.from({ length: count }, () => randomBytesAsync(PAIR_DEVICE_REF_BYTES)));
|
|
16
|
+
return raw.map((bytes) => bytesToBase64UrlSafe(bytes));
|
|
17
|
+
}
|
|
3
18
|
export function buildPairDeviceIq(input) {
|
|
4
19
|
if (input.refs.length !== 6) {
|
|
5
20
|
throw new Error(`pair-device requires exactly 6 refs, got ${input.refs.length}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { TEXT_DECODER } from '../../transport/util.js';
|
|
9
|
+
/**
|
|
10
|
+
* Parses the primary's `<iq type="set" xmlns="md"><pair-device>` upload.
|
|
11
|
+
* Returns `null` when the stanza is not a well-formed pair-device upload, which
|
|
12
|
+
* lets the router fall through to another handler.
|
|
13
|
+
*/
|
|
14
|
+
export function parsePairDeviceUpload(iq) {
|
|
15
|
+
const pairDevice = findChild(iq, 'pair-device');
|
|
16
|
+
if (!pairDevice) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const ref = readText(findChild(pairDevice, 'ref'));
|
|
20
|
+
const companionNoisePublicKey = readBytes(findChild(pairDevice, 'pub-key'));
|
|
21
|
+
const deviceIdentityBytes = readBytes(findChild(pairDevice, 'device-identity'));
|
|
22
|
+
const keyIndexList = findChild(pairDevice, 'key-index-list');
|
|
23
|
+
const keyIndexListBytes = readBytes(keyIndexList);
|
|
24
|
+
if (!ref || !companionNoisePublicKey || !deviceIdentityBytes || !keyIndexListBytes) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
ref,
|
|
29
|
+
companionNoisePublicKey,
|
|
30
|
+
deviceIdentityBytes,
|
|
31
|
+
keyIndexListBytes,
|
|
32
|
+
keyIndexListTimestampSeconds: readTimestampSeconds(keyIndexList),
|
|
33
|
+
clientPropsBytes: readBytes(findChild(pairDevice, 'client-props')),
|
|
34
|
+
pemKeyBytes: readPemKey(pairDevice)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parses the standalone key-index-list republish the primary sends whenever its
|
|
39
|
+
* companion set changes. Returns `null` when the stanza carries no list.
|
|
40
|
+
*/
|
|
41
|
+
export function parseKeyIndexListPublish(iq) {
|
|
42
|
+
const keyIndexList = findChild(iq, 'key-index-list');
|
|
43
|
+
const keyIndexListBytes = readBytes(keyIndexList);
|
|
44
|
+
if (!keyIndexListBytes) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
keyIndexListBytes,
|
|
49
|
+
timestampSeconds: readTimestampSeconds(keyIndexList)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parses `<remove-companion-device>`. The same stanza serves a companion
|
|
54
|
+
* logging itself out (`jid` = its own device) and a primary unlinking one of
|
|
55
|
+
* its companions, so the caller decides by comparing against the connection's
|
|
56
|
+
* own jid.
|
|
57
|
+
*/
|
|
58
|
+
export function parseRemoveCompanionDevice(iq) {
|
|
59
|
+
const remove = findChild(iq, 'remove-companion-device');
|
|
60
|
+
if (!remove) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const jid = remove.attrs.jid;
|
|
64
|
+
return {
|
|
65
|
+
deviceJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
|
|
66
|
+
all: remove.attrs.all === 'true',
|
|
67
|
+
reason: typeof remove.attrs.reason === 'string' ? remove.attrs.reason : '',
|
|
68
|
+
excludeHostedCompanion: remove.attrs.exclude_hosted_companion === 'true'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function findChild(parent, tag) {
|
|
72
|
+
if (!Array.isArray(parent.content)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return parent.content.find((child) => child.tag === tag) ?? null;
|
|
76
|
+
}
|
|
77
|
+
function readBytes(node) {
|
|
78
|
+
if (!node) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return node.content instanceof Uint8Array ? node.content : null;
|
|
82
|
+
}
|
|
83
|
+
function readText(node) {
|
|
84
|
+
if (!node) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (typeof node.content === 'string') {
|
|
88
|
+
return node.content;
|
|
89
|
+
}
|
|
90
|
+
return node.content instanceof Uint8Array ? TEXT_DECODER.decode(node.content) : null;
|
|
91
|
+
}
|
|
92
|
+
function readTimestampSeconds(node) {
|
|
93
|
+
const raw = node?.attrs.ts;
|
|
94
|
+
const parsed = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN;
|
|
95
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
96
|
+
}
|
|
97
|
+
/** The `<pem>` wrapper nests the key bytes in a second `<pem>` element. */
|
|
98
|
+
function readPemKey(pairDevice) {
|
|
99
|
+
const pem = findChild(pairDevice, 'pem');
|
|
100
|
+
if (!pem) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return readBytes(findChild(pem, 'pem'));
|
|
104
|
+
}
|