@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
package/dist/api/FakeWaServer.js
CHANGED
|
@@ -4,14 +4,35 @@ exports.FakeWaServer = exports.FakeServerSession = void 0;
|
|
|
4
4
|
const node_https_1 = require("node:https");
|
|
5
5
|
const WaFakeConnectionPipeline_1 = require("../infra/WaFakeConnectionPipeline");
|
|
6
6
|
const WaFakeMediaHttpsServer_1 = require("../infra/WaFakeMediaHttpsServer");
|
|
7
|
+
const WaFakeTcpServer_1 = require("../infra/WaFakeTcpServer");
|
|
7
8
|
const WaFakeWsServer_1 = require("../infra/WaFakeWsServer");
|
|
8
9
|
const cert_chain_1 = require("../protocol/auth/cert-chain");
|
|
10
|
+
const pair_device_1 = require("../protocol/auth/pair-device");
|
|
11
|
+
const link_code_1 = require("../protocol/iq/link-code");
|
|
12
|
+
const router_1 = require("../protocol/iq/router");
|
|
13
|
+
const mobile_notification_1 = require("../protocol/push/mobile-notification");
|
|
9
14
|
const fake_media_store_1 = require("../state/fake-media-store");
|
|
10
15
|
const crypto_1 = require("../transport/crypto");
|
|
11
16
|
const FakePairingDriver_1 = require("./FakePairingDriver");
|
|
12
17
|
const FakePeer_1 = require("./FakePeer");
|
|
13
18
|
const FakeServerSession_1 = require("./FakeServerSession");
|
|
14
19
|
const Scenario_1 = require("./Scenario");
|
|
20
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
21
|
+
const MOBILE_PRIMARY_PLATFORM = 'android';
|
|
22
|
+
/**
|
|
23
|
+
* Builds the mobile TCP listener when the option asks for one. Defaults its
|
|
24
|
+
* host to the WebSocket host so both listeners answer on the same interface.
|
|
25
|
+
*/
|
|
26
|
+
function createTcpServer(options) {
|
|
27
|
+
if (!options.tcp) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const config = options.tcp === true ? {} : options.tcp;
|
|
31
|
+
return new WaFakeTcpServer_1.WaFakeTcpServer({
|
|
32
|
+
host: config.host ?? options.host,
|
|
33
|
+
port: config.port
|
|
34
|
+
});
|
|
35
|
+
}
|
|
15
36
|
var FakeServerSession_2 = require("./FakeServerSession");
|
|
16
37
|
Object.defineProperty(exports, "FakeServerSession", { enumerable: true, get: function () { return FakeServerSession_2.FakeServerSession; } });
|
|
17
38
|
class FakeWaServer {
|
|
@@ -24,6 +45,10 @@ class FakeWaServer {
|
|
|
24
45
|
this.rootCa = null;
|
|
25
46
|
this.serverStaticKeyPair = null;
|
|
26
47
|
this.listenInfo = null;
|
|
48
|
+
this.tcpListenInfo = null;
|
|
49
|
+
this.pendingCompanionOffers = new Map();
|
|
50
|
+
this.mobilePrimaryPipelines = new Map();
|
|
51
|
+
this.linkCodeSessions = new Map();
|
|
27
52
|
this.pipelineListeners = new Set();
|
|
28
53
|
this.rejectMode = null;
|
|
29
54
|
this.mediaStore = new fake_media_store_1.FakeMediaStore();
|
|
@@ -34,12 +59,18 @@ class FakeWaServer {
|
|
|
34
59
|
this.options = options;
|
|
35
60
|
this.wsServer = new WaFakeWsServer_1.WaFakeWsServer(options);
|
|
36
61
|
this.wsServer.onConnection((connection) => this.handleConnection(connection));
|
|
62
|
+
this.tcpServer = createTcpServer(options);
|
|
63
|
+
this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
|
|
37
64
|
this.defaultSession = this.createSession('__default__');
|
|
38
65
|
}
|
|
39
66
|
/** State of the single default session (the only session for one client). */
|
|
40
67
|
get registries() {
|
|
41
68
|
return this.defaultSession.registries;
|
|
42
69
|
}
|
|
70
|
+
/** Companion-host account state of the default session (linked devices, key-index list). */
|
|
71
|
+
get companionHost() {
|
|
72
|
+
return this.defaultSession.companionHost;
|
|
73
|
+
}
|
|
43
74
|
get preKeyDispenser() {
|
|
44
75
|
return this.defaultSession.preKeyDispenser;
|
|
45
76
|
}
|
|
@@ -64,7 +95,11 @@ class FakeWaServer {
|
|
|
64
95
|
return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
|
|
65
96
|
}
|
|
66
97
|
createSession(id) {
|
|
67
|
-
const session = new FakeServerSession_1.FakeServerSession(id, {
|
|
98
|
+
const session = new FakeServerSession_1.FakeServerSession(id, {
|
|
99
|
+
requireMediaHttpsInfo: () => this.requireMediaHttpsInfo(),
|
|
100
|
+
completeCompanionPairing: (input) => this.completeCompanionPairing(input),
|
|
101
|
+
relayLinkCodeStage: (iq, context) => this.handleLinkCodeStage(iq, context)
|
|
102
|
+
}, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
|
|
68
103
|
this.sessions.set(id, session);
|
|
69
104
|
// Server-level custom handlers apply to every session, present and future.
|
|
70
105
|
for (const handler of this.globalIqHandlers) {
|
|
@@ -181,6 +216,27 @@ class FakeWaServer {
|
|
|
181
216
|
async pushServerSyncNotification(pipeline, input) {
|
|
182
217
|
return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
|
|
183
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Pushes the account's device set to a mobile primary as `account_sync`.
|
|
221
|
+
* Defaults to what this session tracks - the primary itself plus every
|
|
222
|
+
* companion it linked - so passing a shorter list is how a test tells the
|
|
223
|
+
* primary that a device disappeared while it was offline.
|
|
224
|
+
*/
|
|
225
|
+
async pushAccountSyncDevices(pipeline, options = {}) {
|
|
226
|
+
const session = this.sessionFor(pipeline);
|
|
227
|
+
const primary = session.companionHost.primary;
|
|
228
|
+
if (!primary && !options.devices) {
|
|
229
|
+
throw new Error('cannot push account_sync: this session has no mobile primary');
|
|
230
|
+
}
|
|
231
|
+
const devices = options.devices ?? [
|
|
232
|
+
{ deviceJid: primary.jid, keyIndex: 0 },
|
|
233
|
+
...session.companionHost.linkedCompanions().map((companion) => ({
|
|
234
|
+
deviceJid: companion.deviceJid,
|
|
235
|
+
keyIndex: companion.keyIndex
|
|
236
|
+
}))
|
|
237
|
+
];
|
|
238
|
+
await pipeline.sendStanza((0, mobile_notification_1.buildAccountSyncDevicesNotification)({ devices }));
|
|
239
|
+
}
|
|
184
240
|
onAuthenticatedPipeline(listener) {
|
|
185
241
|
this.authenticatedListeners.add(listener);
|
|
186
242
|
return () => this.authenticatedListeners.delete(listener);
|
|
@@ -344,6 +400,18 @@ class FakeWaServer {
|
|
|
344
400
|
get port() {
|
|
345
401
|
return this.requireListening().port;
|
|
346
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* `tcp://host:port` of the mobile listener, for the client's
|
|
405
|
+
* `mobileTransport.tcpUrl`.
|
|
406
|
+
*
|
|
407
|
+
* @throws when the server was started without the `tcp` option.
|
|
408
|
+
*/
|
|
409
|
+
get tcpUrl() {
|
|
410
|
+
if (!this.tcpListenInfo) {
|
|
411
|
+
throw new Error('fake server has no mobile listener; start it with { tcp: true } to serve the mobile transport');
|
|
412
|
+
}
|
|
413
|
+
return this.tcpListenInfo.url;
|
|
414
|
+
}
|
|
347
415
|
get noiseRootCa() {
|
|
348
416
|
const root = this.requireRootCa();
|
|
349
417
|
return { publicKey: root.publicKey, serial: root.serial };
|
|
@@ -371,11 +439,35 @@ class FakeWaServer {
|
|
|
371
439
|
this.wsServer.setHttpRequestHandler(mediaHandler);
|
|
372
440
|
this.mediaHttpsServer.setRequestHandler(mediaHandler);
|
|
373
441
|
this.listenInfo = await this.wsServer.listen();
|
|
374
|
-
|
|
442
|
+
try {
|
|
443
|
+
if (this.tcpServer) {
|
|
444
|
+
this.tcpListenInfo = await this.tcpServer.listen();
|
|
445
|
+
}
|
|
446
|
+
await this.mediaHttpsServer.listen('127.0.0.1');
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
// A half-open server is worse than none: the listeners that did
|
|
450
|
+
// come up would stay bound and `listenInfo` would make a retry a
|
|
451
|
+
// no-op. Roll everything back so the caller can start over.
|
|
452
|
+
await this.rollbackFailedListen();
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async rollbackFailedListen() {
|
|
457
|
+
const ignore = () => undefined;
|
|
458
|
+
await this.wsServer.close().catch(ignore);
|
|
459
|
+
await this.tcpServer?.close().catch(ignore);
|
|
460
|
+
await this.mediaHttpsServer.close().catch(ignore);
|
|
461
|
+
this.listenInfo = null;
|
|
462
|
+
this.tcpListenInfo = null;
|
|
463
|
+
this.rootCa = null;
|
|
464
|
+
this.serverStaticKeyPair = null;
|
|
375
465
|
}
|
|
376
466
|
async stop() {
|
|
377
467
|
this.pipelines.clear();
|
|
378
468
|
await this.wsServer.close();
|
|
469
|
+
await this.tcpServer?.close();
|
|
470
|
+
this.tcpListenInfo = null;
|
|
379
471
|
await this.mediaHttpsServer.close();
|
|
380
472
|
if (this.cachedMediaProxyAgent) {
|
|
381
473
|
this.cachedMediaProxyAgent.destroy();
|
|
@@ -398,7 +490,7 @@ class FakeWaServer {
|
|
|
398
490
|
connection,
|
|
399
491
|
rootCa: this.rootCa,
|
|
400
492
|
serverStaticKeyPair: this.serverStaticKeyPair,
|
|
401
|
-
routeIq: (node) => this.sessionFor(pipeline).routeIq(node),
|
|
493
|
+
routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
|
|
402
494
|
...(this.options.successNodeAttributes !== undefined
|
|
403
495
|
? { successNodeAttributes: this.options.successNodeAttributes }
|
|
404
496
|
: {})
|
|
@@ -408,6 +500,7 @@ class FakeWaServer {
|
|
|
408
500
|
pipeline.setEvents({
|
|
409
501
|
onAuthenticated: () => {
|
|
410
502
|
this.bindPipelineSession(pipeline);
|
|
503
|
+
this.bindMobilePrimary(pipeline);
|
|
411
504
|
for (const listener of this.authenticatedListeners) {
|
|
412
505
|
try {
|
|
413
506
|
void Promise.resolve(listener(pipeline)).catch(() => undefined);
|
|
@@ -418,7 +511,7 @@ class FakeWaServer {
|
|
|
418
511
|
}
|
|
419
512
|
},
|
|
420
513
|
onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
|
|
421
|
-
onClose: () => this.
|
|
514
|
+
onClose: () => this.forgetPipeline(pipeline)
|
|
422
515
|
});
|
|
423
516
|
for (const listener of this.pipelineListeners) {
|
|
424
517
|
try {
|
|
@@ -444,6 +537,139 @@ class FakeWaServer {
|
|
|
444
537
|
const id = resolveKey({ clientPayload, pipeline });
|
|
445
538
|
this.pipelineSessions.set(pipeline, this.session(id));
|
|
446
539
|
}
|
|
540
|
+
/** Drops every reference to a closed connection so a relay never targets it. */
|
|
541
|
+
forgetPipeline(pipeline) {
|
|
542
|
+
this.pipelines.delete(pipeline);
|
|
543
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
544
|
+
if (offered === pipeline) {
|
|
545
|
+
this.pendingCompanionOffers.delete(ref);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const [jid, primary] of this.mobilePrimaryPipelines) {
|
|
549
|
+
if (primary === pipeline) {
|
|
550
|
+
this.mobilePrimaryPipelines.delete(jid);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const [ref, session] of this.linkCodeSessions) {
|
|
554
|
+
if (session.companion === pipeline || session.primary === pipeline) {
|
|
555
|
+
this.linkCodeSessions.delete(ref);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Records a phone login as the session's account owner. Everything on the
|
|
561
|
+
* companion-host path keys off it: device jids are minted under its number,
|
|
562
|
+
* and `remove-companion-device` is read as a revoke rather than a logout.
|
|
563
|
+
*/
|
|
564
|
+
bindMobilePrimary(pipeline) {
|
|
565
|
+
const clientPayload = pipeline.clientPayload;
|
|
566
|
+
if (clientPayload?.kind !== 'login' || clientPayload.flavor !== 'mobile') {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const session = this.sessionFor(pipeline);
|
|
570
|
+
const jid = `${clientPayload.username}@${HOST_DOMAIN}`;
|
|
571
|
+
session.companionHost.bindPrimary({ username: clientPayload.username, jid });
|
|
572
|
+
if (session.companionHost.primary?.jid !== jid) {
|
|
573
|
+
// The session already belongs to another number. Wiring this login
|
|
574
|
+
// in anyway would let a link be relayed to this connection and then
|
|
575
|
+
// minted under the account that owns the session.
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
session.registries.registerDeviceId(jid, 0);
|
|
579
|
+
// A reconnect of the same account replaces its stale connection here.
|
|
580
|
+
this.mobilePrimaryPipelines.set(jid, pipeline);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Offers a companion connection the refs for a primary-driven link: pushes
|
|
584
|
+
* the `pair-device` IQ it turns into a QR, and remembers each ref so the
|
|
585
|
+
* primary's upload can be routed back to this connection. Returns the refs.
|
|
586
|
+
*
|
|
587
|
+
* This is the counterpart of {@link runPairing}, where the server itself
|
|
588
|
+
* plays the primary. Here a real mobile-primary client signs the identity
|
|
589
|
+
* and the server only relays.
|
|
590
|
+
*/
|
|
591
|
+
async offerCompanionPairing(pipeline, options = {}) {
|
|
592
|
+
const refs = await (0, pair_device_1.mintPairingRefs)(options.refCount);
|
|
593
|
+
// Send before registering: the builder rejects a bad ref count and the
|
|
594
|
+
// send can fail, and either would leave refs in the map that no
|
|
595
|
+
// connection will ever consume.
|
|
596
|
+
await pipeline.sendStanza((0, pair_device_1.buildPairDeviceIq)({ refs }));
|
|
597
|
+
for (const ref of refs) {
|
|
598
|
+
this.pendingCompanionOffers.set(ref, pipeline);
|
|
599
|
+
}
|
|
600
|
+
return refs;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Relays one link-code stage between the two clients running the pairing
|
|
604
|
+
* handshake. `companion_hello` mints the ref that ties the stages together
|
|
605
|
+
* and registers it as a pairing offer, so the primary's later `pair-device`
|
|
606
|
+
* upload lands on the same companion connection as the QR flow.
|
|
607
|
+
*/
|
|
608
|
+
async handleLinkCodeStage(iq, context) {
|
|
609
|
+
const parsed = (0, link_code_1.parseLinkCodeStanza)(iq);
|
|
610
|
+
if (!parsed || !context) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
const sender = context.connection;
|
|
614
|
+
if (parsed.stage === 'companion_hello') {
|
|
615
|
+
const primary = parsed.phoneJid
|
|
616
|
+
? this.mobilePrimaryPipelines.get(parsed.phoneJid)
|
|
617
|
+
: undefined;
|
|
618
|
+
if (!primary) {
|
|
619
|
+
return (0, router_1.buildIqError)(iq, { code: 404, text: 'primary-not-connected' });
|
|
620
|
+
}
|
|
621
|
+
const [ref] = await (0, pair_device_1.mintPairingRefs)(1);
|
|
622
|
+
this.linkCodeSessions.set(ref, { companion: sender, primary });
|
|
623
|
+
this.pendingCompanionOffers.set(ref, sender);
|
|
624
|
+
await primary.sendStanza((0, link_code_1.buildLinkCodeNotification)({
|
|
625
|
+
stage: 'companion_hello',
|
|
626
|
+
ref,
|
|
627
|
+
children: parsed.children
|
|
628
|
+
}));
|
|
629
|
+
return (0, router_1.buildIqResult)(iq, { content: (0, link_code_1.buildCompanionHelloResultContent)(ref) });
|
|
630
|
+
}
|
|
631
|
+
const session = parsed.ref ? this.linkCodeSessions.get(parsed.ref) : undefined;
|
|
632
|
+
if (!session || !parsed.ref) {
|
|
633
|
+
return (0, router_1.buildIqError)(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
634
|
+
}
|
|
635
|
+
const target = parsed.stage === 'primary_hello' ? session.companion : session.primary;
|
|
636
|
+
await target.sendStanza((0, link_code_1.buildLinkCodeNotification)({
|
|
637
|
+
stage: parsed.stage,
|
|
638
|
+
ref: parsed.ref,
|
|
639
|
+
children: parsed.children
|
|
640
|
+
}));
|
|
641
|
+
return (0, router_1.buildIqResult)(iq);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Host side of a companion link: hands the primary-signed identity to the
|
|
645
|
+
* connection that owns `ref` and reports what that companion advertised at
|
|
646
|
+
* registration. Resolves `null` for an unknown or already-consumed ref.
|
|
647
|
+
*/
|
|
648
|
+
async completeCompanionPairing(input) {
|
|
649
|
+
const pipeline = this.pendingCompanionOffers.get(input.ref);
|
|
650
|
+
if (!pipeline) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const registration = pipeline.clientPayload;
|
|
654
|
+
const companionPropsBytes = registration?.kind === 'registration'
|
|
655
|
+
? (registration.devicePairingData.deviceProps ?? null)
|
|
656
|
+
: null;
|
|
657
|
+
// `<platform>` describes the primary that signed the link, not the
|
|
658
|
+
// companion. A zapo mobile session always logs in as ANDROID, so this
|
|
659
|
+
// is the only value a primary-driven link can carry here.
|
|
660
|
+
const platform = MOBILE_PRIMARY_PLATFORM;
|
|
661
|
+
await pipeline.sendStanza((0, pair_device_1.buildPairSuccessIq)({
|
|
662
|
+
deviceJid: input.deviceJid,
|
|
663
|
+
platform,
|
|
664
|
+
deviceIdentityBytes: input.deviceIdentityBytes
|
|
665
|
+
}));
|
|
666
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
667
|
+
if (offered === pipeline) {
|
|
668
|
+
this.pendingCompanionOffers.delete(ref);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return { companionPropsBytes, platform };
|
|
672
|
+
}
|
|
447
673
|
buildMediaRequestHandler() {
|
|
448
674
|
return (req, res) => {
|
|
449
675
|
const rawUrl = req.url ?? '';
|
|
@@ -99,6 +99,13 @@ export declare class ServerRegistries {
|
|
|
99
99
|
mutateBlocklist(action: 'block' | 'unblock', jid: string): void;
|
|
100
100
|
notifyGroupOp(op: CapturedGroupOp): void;
|
|
101
101
|
registerPeer(peer: FakePeer): void;
|
|
102
|
+
/**
|
|
103
|
+
* Makes a device visible to `usync` without minting a full fake peer – used
|
|
104
|
+
* for the primary's own device set, whose companions are real clients with
|
|
105
|
+
* their own keys rather than server-side fakes.
|
|
106
|
+
*/
|
|
107
|
+
registerDeviceId(userJid: string, deviceId: number): void;
|
|
108
|
+
unregisterDeviceId(userJid: string, deviceId: number): void;
|
|
102
109
|
lookupDeviceIdsForUser(userJid: string): readonly number[];
|
|
103
110
|
notifyProfilePictureSet(op: CapturedProfilePictureSet): void;
|
|
104
111
|
notifyStatusSet(text: string): void;
|
|
@@ -191,16 +191,31 @@ class ServerRegistries {
|
|
|
191
191
|
}
|
|
192
192
|
registerPeer(peer) {
|
|
193
193
|
this.peerRegistry.set(peer.jid, peer);
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
this.registerDeviceId(toUserJidPart(peer.jid), toDeviceIdPart(peer.jid));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Makes a device visible to `usync` without minting a full fake peer – used
|
|
198
|
+
* for the primary's own device set, whose companions are real clients with
|
|
199
|
+
* their own keys rather than server-side fakes.
|
|
200
|
+
*/
|
|
201
|
+
registerDeviceId(userJid, deviceId) {
|
|
196
202
|
const ids = this.deviceIdsByUser.get(userJid);
|
|
197
|
-
if (ids) {
|
|
203
|
+
if (!ids) {
|
|
204
|
+
this.deviceIdsByUser.set(userJid, [deviceId]);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!ids.includes(deviceId)) {
|
|
198
208
|
ids.push(deviceId);
|
|
199
209
|
ids.sort((a, b) => a - b);
|
|
200
210
|
}
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
}
|
|
212
|
+
unregisterDeviceId(userJid, deviceId) {
|
|
213
|
+
const ids = this.deviceIdsByUser.get(userJid);
|
|
214
|
+
if (!ids) {
|
|
215
|
+
return;
|
|
203
216
|
}
|
|
217
|
+
const next = ids.filter((id) => id !== deviceId);
|
|
218
|
+
this.deviceIdsByUser.set(userJid, next);
|
|
204
219
|
}
|
|
205
220
|
lookupDeviceIdsForUser(userJid) {
|
|
206
221
|
return this.deviceIdsByUser.get(userJid) ?? [];
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { type BuildAbPropsResultInput } from '../protocol/iq/abprops';
|
|
3
3
|
import { type FakeAppStateCollectionPayload } from '../protocol/iq/appstate-sync';
|
|
4
4
|
import { type FakeBusinessProfile } from '../protocol/iq/business';
|
|
5
|
+
import { type ParsedPairDeviceUpload } from '../protocol/iq/companion-host';
|
|
5
6
|
import { type FakePrivacyCategoryName, type FakePrivacySettingsState } from '../protocol/iq/privacy';
|
|
6
7
|
import { type FakePrivacyTokenIssue } from '../protocol/iq/privacy-token';
|
|
7
8
|
import { type FakeProfilePictureResult } from '../protocol/iq/profile';
|
|
8
|
-
import { type WaFakeIqRouter } from '../protocol/iq/router';
|
|
9
|
+
import { type WaFakeIqContext, type WaFakeIqRouter } from '../protocol/iq/router';
|
|
9
10
|
import { type ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
|
|
11
|
+
import type { FakeMobilePrimaryIdentity } from '../state/fake-companion-host';
|
|
10
12
|
import { type BinaryNode } from '../transport/codec';
|
|
11
13
|
import type { FakePeer } from './FakePeer';
|
|
12
14
|
import { type CapturedBlocklistChange, type CapturedDirtyBitsClear, type CapturedGroupOp, type CapturedPrivacySet, type CapturedProfilePictureSet, type MutableFakeGroup } from './ServerRegistries';
|
|
@@ -41,6 +43,22 @@ export interface IqHandlerDeps {
|
|
|
41
43
|
readonly host: string;
|
|
42
44
|
readonly port: number;
|
|
43
45
|
};
|
|
46
|
+
/** Mobile primary bound to this session, or `null` for a web session. */
|
|
47
|
+
readonly mobilePrimary: FakeMobilePrimaryIdentity | null;
|
|
48
|
+
/**
|
|
49
|
+
* Completes the primary's `pair-device` upload. Resolves `null` when the
|
|
50
|
+
* session has no primary or the ref belongs to no live connection.
|
|
51
|
+
*/
|
|
52
|
+
linkCompanionDevice(upload: ParsedPairDeviceUpload): Promise<LinkedCompanionResult | null>;
|
|
53
|
+
/** Unlinks the given companions, or every one of them when passed `null`. */
|
|
54
|
+
revokeCompanionDevices(deviceJids: readonly string[] | null): readonly string[];
|
|
55
|
+
recordKeyIndexList(bytes: Uint8Array, timestampSeconds: number): void;
|
|
56
|
+
/** Routes a link-code stage to the other client, or `null` to fall through. */
|
|
57
|
+
relayLinkCodeStage(iq: BinaryNode, context: WaFakeIqContext | undefined): Promise<BinaryNode | null>;
|
|
58
|
+
}
|
|
59
|
+
export interface LinkedCompanionResult {
|
|
60
|
+
readonly deviceJid: string;
|
|
61
|
+
readonly companionPropsBytes: Uint8Array | null;
|
|
44
62
|
}
|
|
45
63
|
export declare function parseUsyncRequestedUserJids(iq: BinaryNode): readonly string[];
|
|
46
64
|
export declare function parseRequestedKeyJids(iq: BinaryNode): readonly string[];
|
package/dist/api/iq-handlers.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.registerDefaultIqHandlers = registerDefaultIqHandlers;
|
|
|
7
7
|
const abprops_1 = require("../protocol/iq/abprops");
|
|
8
8
|
const appstate_sync_1 = require("../protocol/iq/appstate-sync");
|
|
9
9
|
const business_1 = require("../protocol/iq/business");
|
|
10
|
+
const companion_host_1 = require("../protocol/iq/companion-host");
|
|
10
11
|
const dirty_bits_1 = require("../protocol/iq/dirty-bits");
|
|
11
12
|
const group_ops_1 = require("../protocol/iq/group-ops");
|
|
12
13
|
const newsletter_1 = require("../protocol/iq/newsletter");
|
|
@@ -78,6 +79,28 @@ function buildGroupMetadataReply(iq, metadata) {
|
|
|
78
79
|
]
|
|
79
80
|
};
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* True when the stanza arrived on the phone that owns this session's account.
|
|
84
|
+
* Being a phone login is not enough: a second number connecting to the same
|
|
85
|
+
* session must not drive the companion set of the account that owns it.
|
|
86
|
+
*/
|
|
87
|
+
function isSessionOwnerConnection(context, deps) {
|
|
88
|
+
const payload = context?.connection.clientPayload;
|
|
89
|
+
if (payload?.kind !== 'login' || payload.flavor !== 'mobile') {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return deps.mobilePrimary?.username === payload.username;
|
|
93
|
+
}
|
|
94
|
+
/** The device jid a connection speaks for, or `null` before it logs in. */
|
|
95
|
+
function ownDeviceJid(context) {
|
|
96
|
+
const payload = context?.connection.clientPayload;
|
|
97
|
+
if (payload?.kind !== 'login') {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return payload.device > 0
|
|
101
|
+
? `${payload.username}:${payload.device}@s.whatsapp.net`
|
|
102
|
+
: `${payload.username}@s.whatsapp.net`;
|
|
103
|
+
}
|
|
81
104
|
function registerDefaultIqHandlers(router, deps) {
|
|
82
105
|
router.register({
|
|
83
106
|
label: 'prekey-upload',
|
|
@@ -249,10 +272,82 @@ function registerDefaultIqHandlers(router, deps) {
|
|
|
249
272
|
matcher: { xmlns: 'encrypt', type: 'set', childTag: 'rotate' },
|
|
250
273
|
respond: (iq) => (0, router_1.buildIqResult)(iq)
|
|
251
274
|
});
|
|
275
|
+
router.register({
|
|
276
|
+
label: 'link-code-companion-reg',
|
|
277
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'link_code_companion_reg' },
|
|
278
|
+
respond: (iq, context) => deps.relayLinkCodeStage(iq, context)
|
|
279
|
+
});
|
|
280
|
+
router.register({
|
|
281
|
+
label: 'companion-pair-device',
|
|
282
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'pair-device' },
|
|
283
|
+
respond: async (iq, context) => {
|
|
284
|
+
const upload = (0, companion_host_1.parsePairDeviceUpload)(iq);
|
|
285
|
+
if (!upload) {
|
|
286
|
+
return (0, router_1.buildIqError)(iq, { code: 400, text: 'invalid-pair-device' });
|
|
287
|
+
}
|
|
288
|
+
// The link is minted under the account that owns the session, so
|
|
289
|
+
// only that account's phone may ask for one.
|
|
290
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
291
|
+
return (0, router_1.buildIqError)(iq, { code: 403, text: 'not-authorized' });
|
|
292
|
+
}
|
|
293
|
+
const linked = await deps.linkCompanionDevice(upload);
|
|
294
|
+
if (!linked) {
|
|
295
|
+
return (0, router_1.buildIqError)(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
296
|
+
}
|
|
297
|
+
// Shape mirrors the phone's parser: <device jid> is a direct child
|
|
298
|
+
// of the result, with the companion's own props beside it.
|
|
299
|
+
const content = [{ tag: 'device', attrs: { jid: linked.deviceJid } }];
|
|
300
|
+
if (linked.companionPropsBytes) {
|
|
301
|
+
content.push({
|
|
302
|
+
tag: 'companion-props',
|
|
303
|
+
attrs: {},
|
|
304
|
+
content: linked.companionPropsBytes
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return (0, router_1.buildIqResult)(iq, { content });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
router.register({
|
|
311
|
+
label: 'companion-key-index-list',
|
|
312
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'key-index-list' },
|
|
313
|
+
respond: (iq, context) => {
|
|
314
|
+
const published = (0, companion_host_1.parseKeyIndexListPublish)(iq);
|
|
315
|
+
if (!published) {
|
|
316
|
+
return (0, router_1.buildIqError)(iq, { code: 400, text: 'invalid-key-index-list' });
|
|
317
|
+
}
|
|
318
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
319
|
+
return (0, router_1.buildIqError)(iq, { code: 403, text: 'not-authorized' });
|
|
320
|
+
}
|
|
321
|
+
deps.recordKeyIndexList(published.keyIndexListBytes, published.timestampSeconds);
|
|
322
|
+
return (0, router_1.buildIqResult)(iq);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
252
325
|
router.register({
|
|
253
326
|
label: 'remove-companion-device',
|
|
254
327
|
matcher: { xmlns: 'md', type: 'set', childTag: 'remove-companion-device' },
|
|
255
|
-
respond: (iq) => {
|
|
328
|
+
respond: (iq, context) => {
|
|
329
|
+
const removal = (0, companion_host_1.parseRemoveCompanionDevice)(iq);
|
|
330
|
+
// The same stanza means "log me out" from a companion and "unlink
|
|
331
|
+
// that device" from a primary. The wire does not say which, but the
|
|
332
|
+
// connection does: the account's own phone is always revoking
|
|
333
|
+
// someone else's device, and never ends its own session this way.
|
|
334
|
+
if (removal && isSessionOwnerConnection(context, deps)) {
|
|
335
|
+
if (!removal.all) {
|
|
336
|
+
deps.revokeCompanionDevices(removal.deviceJid ? [removal.deviceJid] : []);
|
|
337
|
+
}
|
|
338
|
+
else if (!removal.excludeHostedCompanion) {
|
|
339
|
+
// The hosted set is exactly what this session tracks, so
|
|
340
|
+
// sparing it leaves the registry untouched.
|
|
341
|
+
deps.revokeCompanionDevices(null);
|
|
342
|
+
}
|
|
343
|
+
return (0, router_1.buildIqResult)(iq);
|
|
344
|
+
}
|
|
345
|
+
// Anyone else may only unlink itself: a connection that is not the
|
|
346
|
+
// account's phone must never drop someone else's device. Its own
|
|
347
|
+
// session ends either way.
|
|
348
|
+
if (removal?.deviceJid && removal.deviceJid === ownDeviceJid(context)) {
|
|
349
|
+
deps.revokeCompanionDevices([removal.deviceJid]);
|
|
350
|
+
}
|
|
256
351
|
deps.notifyLogout();
|
|
257
352
|
return (0, router_1.buildIqResult)(iq);
|
|
258
353
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Registered mobile-primary credentials for driving a phone-side client. */
|
|
2
|
+
import { randomBytesAsync, randomIntAsync, toSerializedPubKey, X25519, xeddsaSign } from '../transport/crypto.js';
|
|
3
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
4
|
+
const LID_DOMAIN = 'lid';
|
|
5
|
+
const ADV_SECRET_BYTES = 32;
|
|
6
|
+
const SIGNED_PRE_KEY_ID = 1;
|
|
7
|
+
/**
|
|
8
|
+
* Device the seeded primary advertises. A phone always sends these in its login
|
|
9
|
+
* `UserAgent`, so they double as the assertion target for a cross-check test.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_DEVICE_INFO = {
|
|
12
|
+
manufacturer: 'Google',
|
|
13
|
+
device: 'Pixel 7',
|
|
14
|
+
osVersion: '14',
|
|
15
|
+
osBuildNumber: 'UQ1A.240205.004',
|
|
16
|
+
appVersion: '2.26.27.70',
|
|
17
|
+
mcc: '724',
|
|
18
|
+
mnc: '10',
|
|
19
|
+
localeLanguageIso6391: 'pt',
|
|
20
|
+
localeCountryIso31661Alpha2: 'BR'
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Seeds a store session with the credentials of an already-registered mobile
|
|
24
|
+
* primary, so a client can connect in mobile mode without a registration flow
|
|
25
|
+
* (which happens out of band, against WhatsApp's HTTP registration endpoints,
|
|
26
|
+
* and is therefore out of this server's reach).
|
|
27
|
+
*
|
|
28
|
+
* The returned `deviceInfo` goes straight into the client's `mobileTransport`;
|
|
29
|
+
* point its `tcpUrl` at {@link FakeWaServer.tcpUrl}:
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const server = await FakeWaServer.start({ tcp: true })
|
|
34
|
+
* const store = createStore({})
|
|
35
|
+
* const primary = await seedFakeMobilePrimary(store, 'mobile-session', {
|
|
36
|
+
* phoneNumber: '5511999999999'
|
|
37
|
+
* })
|
|
38
|
+
* const client = new WaClient({
|
|
39
|
+
* store,
|
|
40
|
+
* sessionId: 'mobile-session',
|
|
41
|
+
* mobileTransport: { deviceInfo: primary.deviceInfo, tcpUrl: server.tcpUrl },
|
|
42
|
+
* testHooks: { noiseRootCa: server.noiseRootCa }
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
47
|
+
*/
|
|
48
|
+
export async function seedFakeMobilePrimary(store, sessionId, options) {
|
|
49
|
+
const primary = await createFakeMobilePrimary(options);
|
|
50
|
+
await store.session(sessionId).auth.save(primary.credentials);
|
|
51
|
+
return primary;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Builds registered mobile-primary credentials without persisting them. Use
|
|
55
|
+
* {@link seedFakeMobilePrimary} unless you own the persistence yourself.
|
|
56
|
+
*
|
|
57
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
58
|
+
*/
|
|
59
|
+
export async function createFakeMobilePrimary(options) {
|
|
60
|
+
if (!/^[1-9]\d*$/.test(options.phoneNumber)) {
|
|
61
|
+
throw new Error(`phoneNumber must be digits only without a leading zero, got: ${options.phoneNumber}`);
|
|
62
|
+
}
|
|
63
|
+
// The login payload carries the number as a numeric username, so anything
|
|
64
|
+
// past the safe-integer range would seed credentials that only fail later,
|
|
65
|
+
// on connect.
|
|
66
|
+
if (!Number.isSafeInteger(Number(options.phoneNumber))) {
|
|
67
|
+
throw new Error(`phoneNumber is too long to be sent as a numeric username: ${options.phoneNumber}`);
|
|
68
|
+
}
|
|
69
|
+
const [noiseKeyPair, identityKeyPair, signedPreKeyPair, advSecretKey, registrationId] = await Promise.all([
|
|
70
|
+
X25519.generateKeyPair(),
|
|
71
|
+
X25519.generateKeyPair(),
|
|
72
|
+
X25519.generateKeyPair(),
|
|
73
|
+
randomBytesAsync(ADV_SECRET_BYTES),
|
|
74
|
+
randomIntAsync(1, 16381)
|
|
75
|
+
]);
|
|
76
|
+
const signature = await xeddsaSign(identityKeyPair.privKey, toSerializedPubKey(signedPreKeyPair.pubKey));
|
|
77
|
+
const deviceInfo = {
|
|
78
|
+
...DEFAULT_DEVICE_INFO,
|
|
79
|
+
...options.deviceInfo
|
|
80
|
+
};
|
|
81
|
+
const meJid = `${options.phoneNumber}@${HOST_DOMAIN}`;
|
|
82
|
+
const meLid = options.lidUser ? `${options.lidUser}@${LID_DOMAIN}` : null;
|
|
83
|
+
const credentials = {
|
|
84
|
+
noiseKeyPair,
|
|
85
|
+
registrationInfo: { registrationId, identityKeyPair },
|
|
86
|
+
signedPreKey: {
|
|
87
|
+
keyId: SIGNED_PRE_KEY_ID,
|
|
88
|
+
keyPair: signedPreKeyPair,
|
|
89
|
+
signature,
|
|
90
|
+
uploaded: false
|
|
91
|
+
},
|
|
92
|
+
advSecretKey,
|
|
93
|
+
meJid,
|
|
94
|
+
deviceInfo,
|
|
95
|
+
// A phone has no companion device-identity and no key-index list of its
|
|
96
|
+
// own: both are minted at pairing time, by this primary, for others.
|
|
97
|
+
serverHasPreKeys: false,
|
|
98
|
+
...(meLid ? { meLid } : {}),
|
|
99
|
+
...(options.pushName ? { pushName: options.pushName } : {})
|
|
100
|
+
};
|
|
101
|
+
return { meJid, meLid, deviceInfo, credentials };
|
|
102
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/** Runs the server side of the QR pairing flow. */
|
|
2
2
|
import { buildAdvSignedDeviceIdentity, generateFakePrimaryDevice } from '../protocol/auth/fake-primary-device.js';
|
|
3
|
-
import { buildPairDeviceIq, buildPairSuccessIq } from '../protocol/auth/pair-device.js';
|
|
4
|
-
import { randomBytesAsync } from '../transport/crypto.js';
|
|
3
|
+
import { buildPairDeviceIq, buildPairSuccessIq, mintPairingRefs } from '../protocol/auth/pair-device.js';
|
|
5
4
|
export class FakePairingDriver {
|
|
6
5
|
constructor(options, deps) {
|
|
7
6
|
this.primary = null;
|
|
@@ -10,8 +9,7 @@ export class FakePairingDriver {
|
|
|
10
9
|
}
|
|
11
10
|
async run() {
|
|
12
11
|
this.primary = this.options.primary ?? (await generateFakePrimaryDevice());
|
|
13
|
-
const
|
|
14
|
-
const pairDeviceIq = buildPairDeviceIq({ refs });
|
|
12
|
+
const pairDeviceIq = buildPairDeviceIq({ refs: await mintPairingRefs() });
|
|
15
13
|
await this.deps.pipeline.sendStanza(pairDeviceIq);
|
|
16
14
|
const waitForAck = this.deps.waitForPairDeviceAck?.(pairDeviceIq.attrs.id) ?? Promise.resolve();
|
|
17
15
|
const [material] = await Promise.all([this.deps.companionMaterialResolver(), waitForAck]);
|