@zapo-js/fake-server 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -33
- package/dist/api/FakeMobilePrimary.d.ts +55 -0
- package/dist/api/FakeMobilePrimary.js +106 -0
- package/dist/api/FakePairingDriver.js +1 -3
- package/dist/api/FakePeer.js +7 -1
- package/dist/api/FakeServerSession.d.ts +100 -0
- package/dist/api/FakeServerSession.js +355 -0
- package/dist/api/FakeWaServer.d.ts +145 -37
- package/dist/api/FakeWaServer.js +345 -270
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +20 -1
- package/dist/api/iq-handlers.js +119 -1
- package/dist/bin.js +69 -21
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakePeer.js +7 -1
- package/dist/esm/api/FakeServerSession.js +349 -0
- package/dist/esm/api/FakeWaServer.js +344 -270
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +119 -1
- package/dist/esm/bin.js +67 -19
- package/dist/esm/index.js +9 -1
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/esm/infra/WaFakeTcpServer.js +69 -0
- package/dist/esm/infra/WaFakeWsServer.js +2 -1
- package/dist/esm/infra/socket-adapters.js +79 -0
- package/dist/esm/protocol/auth/cert-chain.js +13 -2
- package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
- package/dist/esm/protocol/auth/pair-device.js +16 -1
- package/dist/esm/protocol/iq/companion-host.js +104 -0
- package/dist/esm/protocol/iq/link-code.js +90 -0
- package/dist/esm/protocol/iq/router.js +9 -3
- package/dist/esm/protocol/push/mobile-notification.js +97 -0
- package/dist/esm/state/fake-companion-host.js +158 -0
- package/dist/esm/transport/auth.js +7 -0
- package/dist/esm/transport/crypto.js +1 -1
- package/dist/esm/transport/util.js +2 -1
- package/dist/index.d.ts +19 -4
- package/dist/index.js +29 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
- package/dist/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/infra/WaFakeTcpServer.d.ts +29 -0
- package/dist/infra/WaFakeTcpServer.js +73 -0
- package/dist/infra/WaFakeWsServer.js +2 -1
- package/dist/infra/socket-adapters.d.ts +18 -0
- package/dist/infra/socket-adapters.js +83 -0
- package/dist/protocol/auth/cert-chain.d.ts +4 -0
- package/dist/protocol/auth/cert-chain.js +13 -2
- package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
- package/dist/protocol/auth/client-payload-validate.js +77 -1
- package/dist/protocol/auth/pair-device.d.ts +15 -1
- package/dist/protocol/auth/pair-device.js +16 -0
- package/dist/protocol/iq/companion-host.d.ts +52 -0
- package/dist/protocol/iq/companion-host.js +109 -0
- package/dist/protocol/iq/link-code.d.ts +46 -0
- package/dist/protocol/iq/link-code.js +95 -0
- package/dist/protocol/iq/router.d.ts +21 -2
- package/dist/protocol/iq/router.js +9 -3
- package/dist/protocol/push/mobile-notification.d.ts +58 -0
- package/dist/protocol/push/mobile-notification.js +102 -0
- package/dist/state/fake-companion-host.d.ts +73 -0
- package/dist/state/fake-companion-host.js +163 -0
- package/dist/transport/auth.d.ts +8 -0
- package/dist/transport/auth.js +8 -0
- package/dist/transport/crypto.d.ts +1 -1
- package/dist/transport/crypto.js +2 -1
- package/dist/transport/util.d.ts +2 -1
- package/dist/transport/util.js +3 -1
- package/package.json +6 -1
|
@@ -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';
|
|
@@ -34,12 +36,29 @@ export interface IqHandlerDeps {
|
|
|
34
36
|
notifyPrivacySet(change: CapturedPrivacySet): void;
|
|
35
37
|
notifyBlocklistChange(change: CapturedBlocklistChange): void;
|
|
36
38
|
capturePreKeyBundle(bundle: ClientPreKeyBundle): void;
|
|
39
|
+
countServerPreKeys(): number;
|
|
37
40
|
consumeOutboundAppStatePatches(iq: BinaryNode): Promise<void>;
|
|
38
41
|
readonly appStateCollectionProviders: ReadonlyMap<string, () => Promise<FakeAppStateCollectionPayload | null> | FakeAppStateCollectionPayload | null>;
|
|
39
42
|
requireMediaHttpsInfo(): {
|
|
40
43
|
readonly host: string;
|
|
41
44
|
readonly port: number;
|
|
42
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;
|
|
43
62
|
}
|
|
44
63
|
export declare function parseUsyncRequestedUserJids(iq: BinaryNode): readonly string[];
|
|
45
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',
|
|
@@ -98,6 +121,29 @@ function registerDefaultIqHandlers(router, deps) {
|
|
|
98
121
|
matcher: { xmlns: 'encrypt', type: 'get', childTag: 'digest' },
|
|
99
122
|
respond: (iq) => (0, router_1.buildIqError)(iq, { code: 404, text: 'item-not-found' })
|
|
100
123
|
});
|
|
124
|
+
// Baileys-family and whatsmeow clients block on this query before
|
|
125
|
+
// reporting the connection as open; zapo-js uses <digest> instead.
|
|
126
|
+
router.register({
|
|
127
|
+
label: 'prekey-count',
|
|
128
|
+
matcher: { xmlns: 'encrypt', type: 'get', childTag: 'count' },
|
|
129
|
+
respond: (iq) => {
|
|
130
|
+
const result = (0, router_1.buildIqResult)(iq);
|
|
131
|
+
return {
|
|
132
|
+
...result,
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
tag: 'count',
|
|
136
|
+
attrs: { value: String(deps.countServerPreKeys()) }
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
router.register({
|
|
143
|
+
label: 'passive-mode',
|
|
144
|
+
matcher: { xmlns: 'passive', type: 'set' },
|
|
145
|
+
respond: (iq) => (0, router_1.buildIqResult)(iq)
|
|
146
|
+
});
|
|
101
147
|
router.register({
|
|
102
148
|
label: 'media-conn',
|
|
103
149
|
matcher: { xmlns: 'w:m', type: 'set', childTag: 'media_conn' },
|
|
@@ -226,10 +272,82 @@ function registerDefaultIqHandlers(router, deps) {
|
|
|
226
272
|
matcher: { xmlns: 'encrypt', type: 'set', childTag: 'rotate' },
|
|
227
273
|
respond: (iq) => (0, router_1.buildIqResult)(iq)
|
|
228
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
|
+
});
|
|
229
325
|
router.register({
|
|
230
326
|
label: 'remove-companion-device',
|
|
231
327
|
matcher: { xmlns: 'md', type: 'set', childTag: 'remove-companion-device' },
|
|
232
|
-
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
|
+
}
|
|
233
351
|
deps.notifyLogout();
|
|
234
352
|
return (0, router_1.buildIqResult)(iq);
|
|
235
353
|
}
|
package/dist/bin.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
/** Standalone CLI for `@zapo-js/fake-server`. */
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
const node_readline_1 = require("node:readline");
|
|
5
6
|
const FakeWaServer_1 = require("./api/FakeWaServer");
|
|
7
|
+
const pair_device_1 = require("./protocol/auth/pair-device");
|
|
8
|
+
const util_1 = require("./transport/util");
|
|
6
9
|
function parseArgs(argv) {
|
|
7
10
|
let host = '127.0.0.1';
|
|
8
11
|
let port = 0;
|
|
9
12
|
let path = '/ws/chat';
|
|
10
13
|
const peerJids = [];
|
|
11
14
|
const groupSpecs = [];
|
|
15
|
+
let pairJid = null;
|
|
12
16
|
let log = false;
|
|
13
17
|
let quiet = false;
|
|
14
18
|
let json = false;
|
|
@@ -46,6 +50,9 @@ function parseArgs(argv) {
|
|
|
46
50
|
case '--group':
|
|
47
51
|
groupSpecs.push(next());
|
|
48
52
|
break;
|
|
53
|
+
case '--pair':
|
|
54
|
+
pairJid = next();
|
|
55
|
+
break;
|
|
49
56
|
case '--log':
|
|
50
57
|
log = true;
|
|
51
58
|
break;
|
|
@@ -59,7 +66,7 @@ function parseArgs(argv) {
|
|
|
59
66
|
throw new Error(`unknown flag: ${arg}`);
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
|
-
return { host, port, path, peerJids, groupSpecs, log, quiet, json, help };
|
|
69
|
+
return { host, port, path, peerJids, groupSpecs, pairJid, log, quiet, json, help };
|
|
63
70
|
}
|
|
64
71
|
function printHelp() {
|
|
65
72
|
process.stdout.write(`@zapo-js/fake-server – standalone fake WhatsApp Web server
|
|
@@ -75,6 +82,12 @@ FLAGS
|
|
|
75
82
|
--group <spec> Pre-create a fake group; spec format
|
|
76
83
|
<group-jid>=<peer-jid>,<peer-jid>,...
|
|
77
84
|
All peers must already be passed via --peer.
|
|
85
|
+
--pair <jid> Drive QR pairing for unregistered clients: the server
|
|
86
|
+
sends pair-device refs, then prompts on stdin for the
|
|
87
|
+
QR payload the client displays
|
|
88
|
+
(ref,noisePubB64,identityPubB64,advSecretB64,platform)
|
|
89
|
+
and answers pair-success assigning <jid> as the device
|
|
90
|
+
jid. Without this flag the CLI is login-only.
|
|
78
91
|
--log Print every captured inbound stanza
|
|
79
92
|
--quiet Suppress the startup info banner
|
|
80
93
|
--json Print connection info as JSON
|
|
@@ -105,7 +118,7 @@ async function main() {
|
|
|
105
118
|
args = parseArgs(process.argv.slice(2));
|
|
106
119
|
}
|
|
107
120
|
catch (error) {
|
|
108
|
-
process.stderr.write(`error: ${
|
|
121
|
+
process.stderr.write(`error: ${(0, util_1.toError)(error).message}\n`);
|
|
109
122
|
process.stderr.write('run with --help to see usage.\n');
|
|
110
123
|
process.exit(2);
|
|
111
124
|
}
|
|
@@ -127,11 +140,39 @@ async function main() {
|
|
|
127
140
|
port: args.port,
|
|
128
141
|
path: args.path
|
|
129
142
|
});
|
|
143
|
+
if (args.pairJid !== null) {
|
|
144
|
+
const pairJid = args.pairJid;
|
|
145
|
+
let pairingStarted = false;
|
|
146
|
+
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
147
|
+
if (pairingStarted)
|
|
148
|
+
return;
|
|
149
|
+
if (pipeline.clientPayload?.kind !== 'registration')
|
|
150
|
+
return;
|
|
151
|
+
pairingStarted = true;
|
|
152
|
+
try {
|
|
153
|
+
await server.runPairing(pipeline, { deviceJid: pairJid }, async () => {
|
|
154
|
+
const qr = await readStdinLine('[fake-server] paste the QR payload displayed by the client and press Enter:\n');
|
|
155
|
+
const parsed = (0, pair_device_1.parsePairingQrString)(qr.trim());
|
|
156
|
+
return {
|
|
157
|
+
advSecretKey: parsed.advSecretKey,
|
|
158
|
+
identityPublicKey: parsed.identityPublicKey
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
process.stdout.write(`[fake-server] pair-success sent for ${pairJid}; the client will now reconnect and log in\n`);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
pairingStarted = false;
|
|
165
|
+
process.stderr.write(`pairing failed: ${(0, util_1.toError)(error).message}\n`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
130
169
|
let setupComplete = false;
|
|
131
170
|
const setupPromise = new Promise((resolve, reject) => {
|
|
132
171
|
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
133
172
|
if (setupComplete)
|
|
134
173
|
return;
|
|
174
|
+
if (pipeline.clientPayload?.kind !== 'login')
|
|
175
|
+
return;
|
|
135
176
|
setupComplete = true;
|
|
136
177
|
try {
|
|
137
178
|
const peers = new Map();
|
|
@@ -157,7 +198,7 @@ async function main() {
|
|
|
157
198
|
resolve();
|
|
158
199
|
}
|
|
159
200
|
catch (error) {
|
|
160
|
-
reject(
|
|
201
|
+
reject((0, util_1.toError)(error));
|
|
161
202
|
}
|
|
162
203
|
});
|
|
163
204
|
});
|
|
@@ -166,12 +207,11 @@ async function main() {
|
|
|
166
207
|
process.exitCode = 1;
|
|
167
208
|
});
|
|
168
209
|
if (args.log) {
|
|
169
|
-
server
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
});
|
|
210
|
+
// onCapturedStanza taps the server's own capture stream; overriding
|
|
211
|
+
// pipeline.setEvents here would replace the server's handlers and
|
|
212
|
+
// silently break --peer setup and stanza capture.
|
|
213
|
+
server.onCapturedStanza((node) => {
|
|
214
|
+
process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
|
|
175
215
|
});
|
|
176
216
|
}
|
|
177
217
|
if (!args.quiet) {
|
|
@@ -184,10 +224,11 @@ async function main() {
|
|
|
184
224
|
path: args.path,
|
|
185
225
|
noiseRootCa: {
|
|
186
226
|
serial: noiseRoot.serial,
|
|
187
|
-
publicKeyHex: bytesToHex(noiseRoot.publicKey)
|
|
227
|
+
publicKeyHex: (0, util_1.bytesToHex)(noiseRoot.publicKey)
|
|
188
228
|
},
|
|
189
229
|
peers: args.peerJids,
|
|
190
|
-
groups: parsedGroups
|
|
230
|
+
groups: parsedGroups,
|
|
231
|
+
pair: args.pairJid
|
|
191
232
|
}, null, 2) + '\n');
|
|
192
233
|
}
|
|
193
234
|
else {
|
|
@@ -200,9 +241,10 @@ async function main() {
|
|
|
200
241
|
`│ host ${server.host}`,
|
|
201
242
|
`│ port ${server.port}`,
|
|
202
243
|
`│ path ${args.path}`,
|
|
203
|
-
`│ noise root ca serial=${noiseRoot.serial} pub=${bytesToHex(noiseRoot.publicKey).slice(0, 32)}…`,
|
|
244
|
+
`│ noise root ca serial=${noiseRoot.serial} pub=${(0, util_1.bytesToHex)(noiseRoot.publicKey).slice(0, 32)}…`,
|
|
204
245
|
`│ peers ${args.peerJids.length === 0 ? '(none)' : args.peerJids.join(', ')}`,
|
|
205
246
|
`│ groups ${parsedGroups.length === 0 ? '(none)' : parsedGroups.map((g) => g.groupJid).join(', ')}`,
|
|
247
|
+
`│ pair mode ${args.pairJid ?? '(off – login only)'}`,
|
|
206
248
|
'├─────────────────────────────────────────────────────────────',
|
|
207
249
|
'│ Wire your WaClient with:',
|
|
208
250
|
'│ chatSocketUrls: [server.url]',
|
|
@@ -226,7 +268,7 @@ async function main() {
|
|
|
226
268
|
process.exit(0);
|
|
227
269
|
}
|
|
228
270
|
catch (error) {
|
|
229
|
-
process.stderr.write(`error during shutdown: ${
|
|
271
|
+
process.stderr.write(`error during shutdown: ${(0, util_1.toError)(error).message}\n`);
|
|
230
272
|
process.exit(1);
|
|
231
273
|
}
|
|
232
274
|
};
|
|
@@ -234,15 +276,21 @@ async function main() {
|
|
|
234
276
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
235
277
|
await new Promise(() => undefined);
|
|
236
278
|
}
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
279
|
+
function readStdinLine(prompt) {
|
|
280
|
+
process.stdout.write(prompt);
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const rl = (0, node_readline_1.createInterface)({ input: process.stdin });
|
|
283
|
+
// resolve before close(): rl.close() emits 'close' synchronously,
|
|
284
|
+
// which would otherwise reject the still-pending promise first.
|
|
285
|
+
rl.once('line', (line) => {
|
|
286
|
+
resolve(line);
|
|
287
|
+
rl.close();
|
|
288
|
+
});
|
|
289
|
+
rl.once('close', () => reject(new Error('stdin closed before a QR payload was provided')));
|
|
290
|
+
});
|
|
244
291
|
}
|
|
245
292
|
main().catch((error) => {
|
|
246
|
-
|
|
293
|
+
const normalized = (0, util_1.toError)(error);
|
|
294
|
+
process.stderr.write(`fatal: ${normalized.stack ?? normalized.message}\n`);
|
|
247
295
|
process.exit(1);
|
|
248
296
|
});
|
|
@@ -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]);
|
package/dist/esm/api/FakePeer.js
CHANGED
|
@@ -547,7 +547,13 @@ export class FakePeer {
|
|
|
547
547
|
}
|
|
548
548
|
nextId() {
|
|
549
549
|
this.nextMessageCounter += 1;
|
|
550
|
-
|
|
550
|
+
// WA-style uppercase hex, no '@': strict binary decoders tokenize an
|
|
551
|
+
// id containing '@' as a JID and reject the stanza. The registration
|
|
552
|
+
// id keeps ids from colliding across peers created in the same ms.
|
|
553
|
+
const peerPart = this.keyBundle.registrationId.toString(16).padStart(4, '0');
|
|
554
|
+
const timePart = Date.now().toString(16);
|
|
555
|
+
const counterPart = this.nextMessageCounter.toString(16).padStart(4, '0');
|
|
556
|
+
return `3EB0${peerPart}${timePart}${counterPart}`.toUpperCase();
|
|
551
557
|
}
|
|
552
558
|
}
|
|
553
559
|
function findEncForPeer(stanza, peerJid) {
|