@zapo-js/fake-server 1.1.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 +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 +73 -1
- package/dist/api/FakeWaServer.js +230 -4
- 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 +230 -4
- 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 +17 -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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
|
|
2
2
|
import { WaFakeIqRouter } from '../protocol/iq/router.js';
|
|
3
|
+
import { FakeCompanionHostState, readCompanionKeyIndex } from '../state/fake-companion-host.js';
|
|
3
4
|
import { AppStateSyncManager } from './AppStateSyncManager.js';
|
|
4
5
|
import { registerDefaultIqHandlers } from './iq-handlers.js';
|
|
5
6
|
import { PreKeyDispenser } from './PreKeyDispenser.js';
|
|
6
|
-
import { ServerRegistries } from './ServerRegistries.js';
|
|
7
|
+
import { ServerRegistries, toDeviceIdPart } from './ServerRegistries.js';
|
|
8
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
7
9
|
/**
|
|
8
10
|
* One isolated logical device's worth of server state. Every `WaClient`
|
|
9
11
|
* connected to a `FakeWaServer` is bound to exactly one session; peers,
|
|
@@ -16,6 +18,7 @@ export class FakeServerSession {
|
|
|
16
18
|
this.registries = new ServerRegistries();
|
|
17
19
|
this.preKeyDispenser = new PreKeyDispenser();
|
|
18
20
|
this.appStateSync = new AppStateSyncManager();
|
|
21
|
+
this.companionHost = new FakeCompanionHostState();
|
|
19
22
|
this.iqRouter = new WaFakeIqRouter();
|
|
20
23
|
this.capturedStanzas = [];
|
|
21
24
|
this.pendingIqExpectations = new Set();
|
|
@@ -31,8 +34,8 @@ export class FakeServerSession {
|
|
|
31
34
|
const handler = { matcher, respond, label };
|
|
32
35
|
return this.iqRouter.register(handler, { priority: 'high' });
|
|
33
36
|
}
|
|
34
|
-
routeIq(iq) {
|
|
35
|
-
return this.iqRouter.route(iq);
|
|
37
|
+
routeIq(iq, context) {
|
|
38
|
+
return this.iqRouter.route(iq, context);
|
|
36
39
|
}
|
|
37
40
|
handleCapturedStanza(node) {
|
|
38
41
|
this.capturedStanzas.push(node);
|
|
@@ -148,6 +151,7 @@ export class FakeServerSession {
|
|
|
148
151
|
const reg = this.registries;
|
|
149
152
|
const preKey = this.preKeyDispenser;
|
|
150
153
|
const appState = this.appStateSync;
|
|
154
|
+
const companionHost = this.companionHost;
|
|
151
155
|
return {
|
|
152
156
|
get peerRegistry() {
|
|
153
157
|
return reg.peerRegistry;
|
|
@@ -215,9 +219,67 @@ export class FakeServerSession {
|
|
|
215
219
|
get appStateCollectionProviders() {
|
|
216
220
|
return appState.appStateCollectionProviders;
|
|
217
221
|
},
|
|
218
|
-
requireMediaHttpsInfo: () => host.requireMediaHttpsInfo()
|
|
222
|
+
requireMediaHttpsInfo: () => host.requireMediaHttpsInfo(),
|
|
223
|
+
get mobilePrimary() {
|
|
224
|
+
return companionHost.primary;
|
|
225
|
+
},
|
|
226
|
+
relayLinkCodeStage: (iq, context) => host.relayLinkCodeStage(iq, context),
|
|
227
|
+
linkCompanionDevice: (upload) => this.linkCompanionDevice(host, upload),
|
|
228
|
+
revokeCompanionDevices: (deviceJids) => this.revokeCompanionDevices(deviceJids),
|
|
229
|
+
recordKeyIndexList: (bytes, timestampSeconds) => companionHost.recordKeyIndexList(bytes, timestampSeconds)
|
|
219
230
|
};
|
|
220
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Server side of the primary's `pair-device` upload: allocates the device
|
|
234
|
+
* slot, hands the signed identity to the companion connection, and records
|
|
235
|
+
* the link. Resolves `null` when this session has no primary or the ref
|
|
236
|
+
* belongs to no live connection, which the handler turns into an IQ error.
|
|
237
|
+
*/
|
|
238
|
+
async linkCompanionDevice(host, upload) {
|
|
239
|
+
const primary = this.companionHost.primary;
|
|
240
|
+
if (!primary) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const deviceId = this.companionHost.allocateDeviceId();
|
|
244
|
+
const deviceJid = `${primary.username}:${deviceId}@${HOST_DOMAIN}`;
|
|
245
|
+
const completed = await host
|
|
246
|
+
.completeCompanionPairing({
|
|
247
|
+
ref: upload.ref,
|
|
248
|
+
deviceJid,
|
|
249
|
+
deviceIdentityBytes: upload.deviceIdentityBytes
|
|
250
|
+
})
|
|
251
|
+
.catch((error) => {
|
|
252
|
+
this.companionHost.releaseDeviceId(deviceId);
|
|
253
|
+
throw error;
|
|
254
|
+
});
|
|
255
|
+
if (!completed) {
|
|
256
|
+
this.companionHost.releaseDeviceId(deviceId);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
this.companionHost.recordCompanion({
|
|
260
|
+
deviceJid,
|
|
261
|
+
deviceId,
|
|
262
|
+
ref: upload.ref,
|
|
263
|
+
keyIndex: readCompanionKeyIndex(upload.deviceIdentityBytes),
|
|
264
|
+
deviceIdentityBytes: upload.deviceIdentityBytes,
|
|
265
|
+
linkedAtSeconds: Math.floor(Date.now() / 1000)
|
|
266
|
+
});
|
|
267
|
+
this.companionHost.recordKeyIndexList(upload.keyIndexListBytes, upload.keyIndexListTimestampSeconds);
|
|
268
|
+
this.registries.registerDeviceId(primary.jid, deviceId);
|
|
269
|
+
return { deviceJid, companionPropsBytes: completed.companionPropsBytes };
|
|
270
|
+
}
|
|
271
|
+
revokeCompanionDevices(deviceJids) {
|
|
272
|
+
const removed = deviceJids
|
|
273
|
+
? this.companionHost.removeCompanions(deviceJids)
|
|
274
|
+
: this.companionHost.removeAllCompanions();
|
|
275
|
+
const primary = this.companionHost.primary;
|
|
276
|
+
if (primary) {
|
|
277
|
+
for (const deviceJid of removed) {
|
|
278
|
+
this.registries.unregisterDeviceId(primary.jid, toDeviceIdPart(deviceJid));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return removed;
|
|
282
|
+
}
|
|
221
283
|
}
|
|
222
284
|
export function matchesIq(iq, matcher) {
|
|
223
285
|
if (iq.tag !== 'iq')
|
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import { Agent as HttpsAgent } from 'node:https';
|
|
2
2
|
import { WaFakeConnectionPipeline } from '../infra/WaFakeConnectionPipeline.js';
|
|
3
3
|
import { WaFakeMediaHttpsServer } from '../infra/WaFakeMediaHttpsServer.js';
|
|
4
|
+
import { WaFakeTcpServer } from '../infra/WaFakeTcpServer.js';
|
|
4
5
|
import { WaFakeWsServer } from '../infra/WaFakeWsServer.js';
|
|
5
6
|
import { generateFakeNoiseRootCa } from '../protocol/auth/cert-chain.js';
|
|
7
|
+
import { buildPairDeviceIq, buildPairSuccessIq, mintPairingRefs } from '../protocol/auth/pair-device.js';
|
|
8
|
+
import { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from '../protocol/iq/link-code.js';
|
|
9
|
+
import { buildIqError, buildIqResult } from '../protocol/iq/router.js';
|
|
10
|
+
import { buildAccountSyncDevicesNotification } from '../protocol/push/mobile-notification.js';
|
|
6
11
|
import { FakeMediaStore } from '../state/fake-media-store.js';
|
|
7
12
|
import { X25519 } from '../transport/crypto.js';
|
|
8
13
|
import { FakePairingDriver } from './FakePairingDriver.js';
|
|
9
14
|
import { FakePeer } from './FakePeer.js';
|
|
10
15
|
import { FakeServerSession } from './FakeServerSession.js';
|
|
11
16
|
import { Scenario } from './Scenario.js';
|
|
17
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
18
|
+
const MOBILE_PRIMARY_PLATFORM = 'android';
|
|
19
|
+
/**
|
|
20
|
+
* Builds the mobile TCP listener when the option asks for one. Defaults its
|
|
21
|
+
* host to the WebSocket host so both listeners answer on the same interface.
|
|
22
|
+
*/
|
|
23
|
+
function createTcpServer(options) {
|
|
24
|
+
if (!options.tcp) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const config = options.tcp === true ? {} : options.tcp;
|
|
28
|
+
return new WaFakeTcpServer({
|
|
29
|
+
host: config.host ?? options.host,
|
|
30
|
+
port: config.port
|
|
31
|
+
});
|
|
32
|
+
}
|
|
12
33
|
export { FakeServerSession } from './FakeServerSession.js';
|
|
13
34
|
export class FakeWaServer {
|
|
14
35
|
constructor(options = {}) {
|
|
@@ -20,6 +41,10 @@ export class FakeWaServer {
|
|
|
20
41
|
this.rootCa = null;
|
|
21
42
|
this.serverStaticKeyPair = null;
|
|
22
43
|
this.listenInfo = null;
|
|
44
|
+
this.tcpListenInfo = null;
|
|
45
|
+
this.pendingCompanionOffers = new Map();
|
|
46
|
+
this.mobilePrimaryPipelines = new Map();
|
|
47
|
+
this.linkCodeSessions = new Map();
|
|
23
48
|
this.pipelineListeners = new Set();
|
|
24
49
|
this.rejectMode = null;
|
|
25
50
|
this.mediaStore = new FakeMediaStore();
|
|
@@ -30,12 +55,18 @@ export class FakeWaServer {
|
|
|
30
55
|
this.options = options;
|
|
31
56
|
this.wsServer = new WaFakeWsServer(options);
|
|
32
57
|
this.wsServer.onConnection((connection) => this.handleConnection(connection));
|
|
58
|
+
this.tcpServer = createTcpServer(options);
|
|
59
|
+
this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
|
|
33
60
|
this.defaultSession = this.createSession('__default__');
|
|
34
61
|
}
|
|
35
62
|
/** State of the single default session (the only session for one client). */
|
|
36
63
|
get registries() {
|
|
37
64
|
return this.defaultSession.registries;
|
|
38
65
|
}
|
|
66
|
+
/** Companion-host account state of the default session (linked devices, key-index list). */
|
|
67
|
+
get companionHost() {
|
|
68
|
+
return this.defaultSession.companionHost;
|
|
69
|
+
}
|
|
39
70
|
get preKeyDispenser() {
|
|
40
71
|
return this.defaultSession.preKeyDispenser;
|
|
41
72
|
}
|
|
@@ -60,7 +91,11 @@ export class FakeWaServer {
|
|
|
60
91
|
return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
|
|
61
92
|
}
|
|
62
93
|
createSession(id) {
|
|
63
|
-
const session = new FakeServerSession(id, {
|
|
94
|
+
const session = new FakeServerSession(id, {
|
|
95
|
+
requireMediaHttpsInfo: () => this.requireMediaHttpsInfo(),
|
|
96
|
+
completeCompanionPairing: (input) => this.completeCompanionPairing(input),
|
|
97
|
+
relayLinkCodeStage: (iq, context) => this.handleLinkCodeStage(iq, context)
|
|
98
|
+
}, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
|
|
64
99
|
this.sessions.set(id, session);
|
|
65
100
|
// Server-level custom handlers apply to every session, present and future.
|
|
66
101
|
for (const handler of this.globalIqHandlers) {
|
|
@@ -177,6 +212,27 @@ export class FakeWaServer {
|
|
|
177
212
|
async pushServerSyncNotification(pipeline, input) {
|
|
178
213
|
return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
|
|
179
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Pushes the account's device set to a mobile primary as `account_sync`.
|
|
217
|
+
* Defaults to what this session tracks - the primary itself plus every
|
|
218
|
+
* companion it linked - so passing a shorter list is how a test tells the
|
|
219
|
+
* primary that a device disappeared while it was offline.
|
|
220
|
+
*/
|
|
221
|
+
async pushAccountSyncDevices(pipeline, options = {}) {
|
|
222
|
+
const session = this.sessionFor(pipeline);
|
|
223
|
+
const primary = session.companionHost.primary;
|
|
224
|
+
if (!primary && !options.devices) {
|
|
225
|
+
throw new Error('cannot push account_sync: this session has no mobile primary');
|
|
226
|
+
}
|
|
227
|
+
const devices = options.devices ?? [
|
|
228
|
+
{ deviceJid: primary.jid, keyIndex: 0 },
|
|
229
|
+
...session.companionHost.linkedCompanions().map((companion) => ({
|
|
230
|
+
deviceJid: companion.deviceJid,
|
|
231
|
+
keyIndex: companion.keyIndex
|
|
232
|
+
}))
|
|
233
|
+
];
|
|
234
|
+
await pipeline.sendStanza(buildAccountSyncDevicesNotification({ devices }));
|
|
235
|
+
}
|
|
180
236
|
onAuthenticatedPipeline(listener) {
|
|
181
237
|
this.authenticatedListeners.add(listener);
|
|
182
238
|
return () => this.authenticatedListeners.delete(listener);
|
|
@@ -340,6 +396,18 @@ export class FakeWaServer {
|
|
|
340
396
|
get port() {
|
|
341
397
|
return this.requireListening().port;
|
|
342
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* `tcp://host:port` of the mobile listener, for the client's
|
|
401
|
+
* `mobileTransport.tcpUrl`.
|
|
402
|
+
*
|
|
403
|
+
* @throws when the server was started without the `tcp` option.
|
|
404
|
+
*/
|
|
405
|
+
get tcpUrl() {
|
|
406
|
+
if (!this.tcpListenInfo) {
|
|
407
|
+
throw new Error('fake server has no mobile listener; start it with { tcp: true } to serve the mobile transport');
|
|
408
|
+
}
|
|
409
|
+
return this.tcpListenInfo.url;
|
|
410
|
+
}
|
|
343
411
|
get noiseRootCa() {
|
|
344
412
|
const root = this.requireRootCa();
|
|
345
413
|
return { publicKey: root.publicKey, serial: root.serial };
|
|
@@ -367,11 +435,35 @@ export class FakeWaServer {
|
|
|
367
435
|
this.wsServer.setHttpRequestHandler(mediaHandler);
|
|
368
436
|
this.mediaHttpsServer.setRequestHandler(mediaHandler);
|
|
369
437
|
this.listenInfo = await this.wsServer.listen();
|
|
370
|
-
|
|
438
|
+
try {
|
|
439
|
+
if (this.tcpServer) {
|
|
440
|
+
this.tcpListenInfo = await this.tcpServer.listen();
|
|
441
|
+
}
|
|
442
|
+
await this.mediaHttpsServer.listen('127.0.0.1');
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
// A half-open server is worse than none: the listeners that did
|
|
446
|
+
// come up would stay bound and `listenInfo` would make a retry a
|
|
447
|
+
// no-op. Roll everything back so the caller can start over.
|
|
448
|
+
await this.rollbackFailedListen();
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async rollbackFailedListen() {
|
|
453
|
+
const ignore = () => undefined;
|
|
454
|
+
await this.wsServer.close().catch(ignore);
|
|
455
|
+
await this.tcpServer?.close().catch(ignore);
|
|
456
|
+
await this.mediaHttpsServer.close().catch(ignore);
|
|
457
|
+
this.listenInfo = null;
|
|
458
|
+
this.tcpListenInfo = null;
|
|
459
|
+
this.rootCa = null;
|
|
460
|
+
this.serverStaticKeyPair = null;
|
|
371
461
|
}
|
|
372
462
|
async stop() {
|
|
373
463
|
this.pipelines.clear();
|
|
374
464
|
await this.wsServer.close();
|
|
465
|
+
await this.tcpServer?.close();
|
|
466
|
+
this.tcpListenInfo = null;
|
|
375
467
|
await this.mediaHttpsServer.close();
|
|
376
468
|
if (this.cachedMediaProxyAgent) {
|
|
377
469
|
this.cachedMediaProxyAgent.destroy();
|
|
@@ -394,7 +486,7 @@ export class FakeWaServer {
|
|
|
394
486
|
connection,
|
|
395
487
|
rootCa: this.rootCa,
|
|
396
488
|
serverStaticKeyPair: this.serverStaticKeyPair,
|
|
397
|
-
routeIq: (node) => this.sessionFor(pipeline).routeIq(node),
|
|
489
|
+
routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
|
|
398
490
|
...(this.options.successNodeAttributes !== undefined
|
|
399
491
|
? { successNodeAttributes: this.options.successNodeAttributes }
|
|
400
492
|
: {})
|
|
@@ -404,6 +496,7 @@ export class FakeWaServer {
|
|
|
404
496
|
pipeline.setEvents({
|
|
405
497
|
onAuthenticated: () => {
|
|
406
498
|
this.bindPipelineSession(pipeline);
|
|
499
|
+
this.bindMobilePrimary(pipeline);
|
|
407
500
|
for (const listener of this.authenticatedListeners) {
|
|
408
501
|
try {
|
|
409
502
|
void Promise.resolve(listener(pipeline)).catch(() => undefined);
|
|
@@ -414,7 +507,7 @@ export class FakeWaServer {
|
|
|
414
507
|
}
|
|
415
508
|
},
|
|
416
509
|
onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
|
|
417
|
-
onClose: () => this.
|
|
510
|
+
onClose: () => this.forgetPipeline(pipeline)
|
|
418
511
|
});
|
|
419
512
|
for (const listener of this.pipelineListeners) {
|
|
420
513
|
try {
|
|
@@ -440,6 +533,139 @@ export class FakeWaServer {
|
|
|
440
533
|
const id = resolveKey({ clientPayload, pipeline });
|
|
441
534
|
this.pipelineSessions.set(pipeline, this.session(id));
|
|
442
535
|
}
|
|
536
|
+
/** Drops every reference to a closed connection so a relay never targets it. */
|
|
537
|
+
forgetPipeline(pipeline) {
|
|
538
|
+
this.pipelines.delete(pipeline);
|
|
539
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
540
|
+
if (offered === pipeline) {
|
|
541
|
+
this.pendingCompanionOffers.delete(ref);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const [jid, primary] of this.mobilePrimaryPipelines) {
|
|
545
|
+
if (primary === pipeline) {
|
|
546
|
+
this.mobilePrimaryPipelines.delete(jid);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const [ref, session] of this.linkCodeSessions) {
|
|
550
|
+
if (session.companion === pipeline || session.primary === pipeline) {
|
|
551
|
+
this.linkCodeSessions.delete(ref);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Records a phone login as the session's account owner. Everything on the
|
|
557
|
+
* companion-host path keys off it: device jids are minted under its number,
|
|
558
|
+
* and `remove-companion-device` is read as a revoke rather than a logout.
|
|
559
|
+
*/
|
|
560
|
+
bindMobilePrimary(pipeline) {
|
|
561
|
+
const clientPayload = pipeline.clientPayload;
|
|
562
|
+
if (clientPayload?.kind !== 'login' || clientPayload.flavor !== 'mobile') {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const session = this.sessionFor(pipeline);
|
|
566
|
+
const jid = `${clientPayload.username}@${HOST_DOMAIN}`;
|
|
567
|
+
session.companionHost.bindPrimary({ username: clientPayload.username, jid });
|
|
568
|
+
if (session.companionHost.primary?.jid !== jid) {
|
|
569
|
+
// The session already belongs to another number. Wiring this login
|
|
570
|
+
// in anyway would let a link be relayed to this connection and then
|
|
571
|
+
// minted under the account that owns the session.
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
session.registries.registerDeviceId(jid, 0);
|
|
575
|
+
// A reconnect of the same account replaces its stale connection here.
|
|
576
|
+
this.mobilePrimaryPipelines.set(jid, pipeline);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Offers a companion connection the refs for a primary-driven link: pushes
|
|
580
|
+
* the `pair-device` IQ it turns into a QR, and remembers each ref so the
|
|
581
|
+
* primary's upload can be routed back to this connection. Returns the refs.
|
|
582
|
+
*
|
|
583
|
+
* This is the counterpart of {@link runPairing}, where the server itself
|
|
584
|
+
* plays the primary. Here a real mobile-primary client signs the identity
|
|
585
|
+
* and the server only relays.
|
|
586
|
+
*/
|
|
587
|
+
async offerCompanionPairing(pipeline, options = {}) {
|
|
588
|
+
const refs = await mintPairingRefs(options.refCount);
|
|
589
|
+
// Send before registering: the builder rejects a bad ref count and the
|
|
590
|
+
// send can fail, and either would leave refs in the map that no
|
|
591
|
+
// connection will ever consume.
|
|
592
|
+
await pipeline.sendStanza(buildPairDeviceIq({ refs }));
|
|
593
|
+
for (const ref of refs) {
|
|
594
|
+
this.pendingCompanionOffers.set(ref, pipeline);
|
|
595
|
+
}
|
|
596
|
+
return refs;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Relays one link-code stage between the two clients running the pairing
|
|
600
|
+
* handshake. `companion_hello` mints the ref that ties the stages together
|
|
601
|
+
* and registers it as a pairing offer, so the primary's later `pair-device`
|
|
602
|
+
* upload lands on the same companion connection as the QR flow.
|
|
603
|
+
*/
|
|
604
|
+
async handleLinkCodeStage(iq, context) {
|
|
605
|
+
const parsed = parseLinkCodeStanza(iq);
|
|
606
|
+
if (!parsed || !context) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const sender = context.connection;
|
|
610
|
+
if (parsed.stage === 'companion_hello') {
|
|
611
|
+
const primary = parsed.phoneJid
|
|
612
|
+
? this.mobilePrimaryPipelines.get(parsed.phoneJid)
|
|
613
|
+
: undefined;
|
|
614
|
+
if (!primary) {
|
|
615
|
+
return buildIqError(iq, { code: 404, text: 'primary-not-connected' });
|
|
616
|
+
}
|
|
617
|
+
const [ref] = await mintPairingRefs(1);
|
|
618
|
+
this.linkCodeSessions.set(ref, { companion: sender, primary });
|
|
619
|
+
this.pendingCompanionOffers.set(ref, sender);
|
|
620
|
+
await primary.sendStanza(buildLinkCodeNotification({
|
|
621
|
+
stage: 'companion_hello',
|
|
622
|
+
ref,
|
|
623
|
+
children: parsed.children
|
|
624
|
+
}));
|
|
625
|
+
return buildIqResult(iq, { content: buildCompanionHelloResultContent(ref) });
|
|
626
|
+
}
|
|
627
|
+
const session = parsed.ref ? this.linkCodeSessions.get(parsed.ref) : undefined;
|
|
628
|
+
if (!session || !parsed.ref) {
|
|
629
|
+
return buildIqError(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
630
|
+
}
|
|
631
|
+
const target = parsed.stage === 'primary_hello' ? session.companion : session.primary;
|
|
632
|
+
await target.sendStanza(buildLinkCodeNotification({
|
|
633
|
+
stage: parsed.stage,
|
|
634
|
+
ref: parsed.ref,
|
|
635
|
+
children: parsed.children
|
|
636
|
+
}));
|
|
637
|
+
return buildIqResult(iq);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Host side of a companion link: hands the primary-signed identity to the
|
|
641
|
+
* connection that owns `ref` and reports what that companion advertised at
|
|
642
|
+
* registration. Resolves `null` for an unknown or already-consumed ref.
|
|
643
|
+
*/
|
|
644
|
+
async completeCompanionPairing(input) {
|
|
645
|
+
const pipeline = this.pendingCompanionOffers.get(input.ref);
|
|
646
|
+
if (!pipeline) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const registration = pipeline.clientPayload;
|
|
650
|
+
const companionPropsBytes = registration?.kind === 'registration'
|
|
651
|
+
? (registration.devicePairingData.deviceProps ?? null)
|
|
652
|
+
: null;
|
|
653
|
+
// `<platform>` describes the primary that signed the link, not the
|
|
654
|
+
// companion. A zapo mobile session always logs in as ANDROID, so this
|
|
655
|
+
// is the only value a primary-driven link can carry here.
|
|
656
|
+
const platform = MOBILE_PRIMARY_PLATFORM;
|
|
657
|
+
await pipeline.sendStanza(buildPairSuccessIq({
|
|
658
|
+
deviceJid: input.deviceJid,
|
|
659
|
+
platform,
|
|
660
|
+
deviceIdentityBytes: input.deviceIdentityBytes
|
|
661
|
+
}));
|
|
662
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
663
|
+
if (offered === pipeline) {
|
|
664
|
+
this.pendingCompanionOffers.delete(ref);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { companionPropsBytes, platform };
|
|
668
|
+
}
|
|
443
669
|
buildMediaRequestHandler() {
|
|
444
670
|
return (req, res) => {
|
|
445
671
|
const rawUrl = req.url ?? '';
|
|
@@ -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
|
}
|