@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
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]);
|
|
@@ -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 };
|
|
@@ -360,18 +428,42 @@ export class FakeWaServer {
|
|
|
360
428
|
}
|
|
361
429
|
;
|
|
362
430
|
[this.rootCa, this.serverStaticKeyPair] = await Promise.all([
|
|
363
|
-
generateFakeNoiseRootCa(),
|
|
431
|
+
this.options.noiseRootCa ?? generateFakeNoiseRootCa(),
|
|
364
432
|
X25519.generateKeyPair()
|
|
365
433
|
]);
|
|
366
434
|
const mediaHandler = this.buildMediaRequestHandler();
|
|
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 ?? '';
|