@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
|
@@ -186,16 +186,31 @@ export class ServerRegistries {
|
|
|
186
186
|
}
|
|
187
187
|
registerPeer(peer) {
|
|
188
188
|
this.peerRegistry.set(peer.jid, peer);
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
this.registerDeviceId(toUserJidPart(peer.jid), toDeviceIdPart(peer.jid));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Makes a device visible to `usync` without minting a full fake peer – used
|
|
193
|
+
* for the primary's own device set, whose companions are real clients with
|
|
194
|
+
* their own keys rather than server-side fakes.
|
|
195
|
+
*/
|
|
196
|
+
registerDeviceId(userJid, deviceId) {
|
|
191
197
|
const ids = this.deviceIdsByUser.get(userJid);
|
|
192
|
-
if (ids) {
|
|
198
|
+
if (!ids) {
|
|
199
|
+
this.deviceIdsByUser.set(userJid, [deviceId]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!ids.includes(deviceId)) {
|
|
193
203
|
ids.push(deviceId);
|
|
194
204
|
ids.sort((a, b) => a - b);
|
|
195
205
|
}
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
}
|
|
207
|
+
unregisterDeviceId(userJid, deviceId) {
|
|
208
|
+
const ids = this.deviceIdsByUser.get(userJid);
|
|
209
|
+
if (!ids) {
|
|
210
|
+
return;
|
|
198
211
|
}
|
|
212
|
+
const next = ids.filter((id) => id !== deviceId);
|
|
213
|
+
this.deviceIdsByUser.set(userJid, next);
|
|
199
214
|
}
|
|
200
215
|
lookupDeviceIdsForUser(userJid) {
|
|
201
216
|
return this.deviceIdsByUser.get(userJid) ?? [];
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { buildAbPropsResult } from '../protocol/iq/abprops.js';
|
|
3
3
|
import { buildAppStateSyncFullResult, buildAppStateSyncResult, parseAppStateSyncRequest } from '../protocol/iq/appstate-sync.js';
|
|
4
4
|
import { buildBusinessProfileResult, parseGetBusinessProfileIq } from '../protocol/iq/business.js';
|
|
5
|
+
import { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from '../protocol/iq/companion-host.js';
|
|
5
6
|
import { parseClearDirtyBitsIq } from '../protocol/iq/dirty-bits.js';
|
|
6
7
|
import { buildGroupMetadataNode, buildGroupParticipantChangeResult, parseCreateGroupIq, parseGroupParticipantChangeIq, parseLeaveGroupIq, parseSetDescriptionIq, parseSetSubjectIq } from '../protocol/iq/group-ops.js';
|
|
7
8
|
import { buildNewsletterMyAddonsResult } from '../protocol/iq/newsletter.js';
|
|
@@ -73,6 +74,28 @@ function buildGroupMetadataReply(iq, metadata) {
|
|
|
73
74
|
]
|
|
74
75
|
};
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* True when the stanza arrived on the phone that owns this session's account.
|
|
79
|
+
* Being a phone login is not enough: a second number connecting to the same
|
|
80
|
+
* session must not drive the companion set of the account that owns it.
|
|
81
|
+
*/
|
|
82
|
+
function isSessionOwnerConnection(context, deps) {
|
|
83
|
+
const payload = context?.connection.clientPayload;
|
|
84
|
+
if (payload?.kind !== 'login' || payload.flavor !== 'mobile') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return deps.mobilePrimary?.username === payload.username;
|
|
88
|
+
}
|
|
89
|
+
/** The device jid a connection speaks for, or `null` before it logs in. */
|
|
90
|
+
function ownDeviceJid(context) {
|
|
91
|
+
const payload = context?.connection.clientPayload;
|
|
92
|
+
if (payload?.kind !== 'login') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return payload.device > 0
|
|
96
|
+
? `${payload.username}:${payload.device}@s.whatsapp.net`
|
|
97
|
+
: `${payload.username}@s.whatsapp.net`;
|
|
98
|
+
}
|
|
76
99
|
export function registerDefaultIqHandlers(router, deps) {
|
|
77
100
|
router.register({
|
|
78
101
|
label: 'prekey-upload',
|
|
@@ -93,6 +116,29 @@ export function registerDefaultIqHandlers(router, deps) {
|
|
|
93
116
|
matcher: { xmlns: 'encrypt', type: 'get', childTag: 'digest' },
|
|
94
117
|
respond: (iq) => buildIqError(iq, { code: 404, text: 'item-not-found' })
|
|
95
118
|
});
|
|
119
|
+
// Baileys-family and whatsmeow clients block on this query before
|
|
120
|
+
// reporting the connection as open; zapo-js uses <digest> instead.
|
|
121
|
+
router.register({
|
|
122
|
+
label: 'prekey-count',
|
|
123
|
+
matcher: { xmlns: 'encrypt', type: 'get', childTag: 'count' },
|
|
124
|
+
respond: (iq) => {
|
|
125
|
+
const result = buildIqResult(iq);
|
|
126
|
+
return {
|
|
127
|
+
...result,
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
tag: 'count',
|
|
131
|
+
attrs: { value: String(deps.countServerPreKeys()) }
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
router.register({
|
|
138
|
+
label: 'passive-mode',
|
|
139
|
+
matcher: { xmlns: 'passive', type: 'set' },
|
|
140
|
+
respond: (iq) => buildIqResult(iq)
|
|
141
|
+
});
|
|
96
142
|
router.register({
|
|
97
143
|
label: 'media-conn',
|
|
98
144
|
matcher: { xmlns: 'w:m', type: 'set', childTag: 'media_conn' },
|
|
@@ -221,10 +267,82 @@ export function registerDefaultIqHandlers(router, deps) {
|
|
|
221
267
|
matcher: { xmlns: 'encrypt', type: 'set', childTag: 'rotate' },
|
|
222
268
|
respond: (iq) => buildIqResult(iq)
|
|
223
269
|
});
|
|
270
|
+
router.register({
|
|
271
|
+
label: 'link-code-companion-reg',
|
|
272
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'link_code_companion_reg' },
|
|
273
|
+
respond: (iq, context) => deps.relayLinkCodeStage(iq, context)
|
|
274
|
+
});
|
|
275
|
+
router.register({
|
|
276
|
+
label: 'companion-pair-device',
|
|
277
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'pair-device' },
|
|
278
|
+
respond: async (iq, context) => {
|
|
279
|
+
const upload = parsePairDeviceUpload(iq);
|
|
280
|
+
if (!upload) {
|
|
281
|
+
return buildIqError(iq, { code: 400, text: 'invalid-pair-device' });
|
|
282
|
+
}
|
|
283
|
+
// The link is minted under the account that owns the session, so
|
|
284
|
+
// only that account's phone may ask for one.
|
|
285
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
286
|
+
return buildIqError(iq, { code: 403, text: 'not-authorized' });
|
|
287
|
+
}
|
|
288
|
+
const linked = await deps.linkCompanionDevice(upload);
|
|
289
|
+
if (!linked) {
|
|
290
|
+
return buildIqError(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
291
|
+
}
|
|
292
|
+
// Shape mirrors the phone's parser: <device jid> is a direct child
|
|
293
|
+
// of the result, with the companion's own props beside it.
|
|
294
|
+
const content = [{ tag: 'device', attrs: { jid: linked.deviceJid } }];
|
|
295
|
+
if (linked.companionPropsBytes) {
|
|
296
|
+
content.push({
|
|
297
|
+
tag: 'companion-props',
|
|
298
|
+
attrs: {},
|
|
299
|
+
content: linked.companionPropsBytes
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return buildIqResult(iq, { content });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
router.register({
|
|
306
|
+
label: 'companion-key-index-list',
|
|
307
|
+
matcher: { xmlns: 'md', type: 'set', childTag: 'key-index-list' },
|
|
308
|
+
respond: (iq, context) => {
|
|
309
|
+
const published = parseKeyIndexListPublish(iq);
|
|
310
|
+
if (!published) {
|
|
311
|
+
return buildIqError(iq, { code: 400, text: 'invalid-key-index-list' });
|
|
312
|
+
}
|
|
313
|
+
if (!isSessionOwnerConnection(context, deps)) {
|
|
314
|
+
return buildIqError(iq, { code: 403, text: 'not-authorized' });
|
|
315
|
+
}
|
|
316
|
+
deps.recordKeyIndexList(published.keyIndexListBytes, published.timestampSeconds);
|
|
317
|
+
return buildIqResult(iq);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
224
320
|
router.register({
|
|
225
321
|
label: 'remove-companion-device',
|
|
226
322
|
matcher: { xmlns: 'md', type: 'set', childTag: 'remove-companion-device' },
|
|
227
|
-
respond: (iq) => {
|
|
323
|
+
respond: (iq, context) => {
|
|
324
|
+
const removal = parseRemoveCompanionDevice(iq);
|
|
325
|
+
// The same stanza means "log me out" from a companion and "unlink
|
|
326
|
+
// that device" from a primary. The wire does not say which, but the
|
|
327
|
+
// connection does: the account's own phone is always revoking
|
|
328
|
+
// someone else's device, and never ends its own session this way.
|
|
329
|
+
if (removal && isSessionOwnerConnection(context, deps)) {
|
|
330
|
+
if (!removal.all) {
|
|
331
|
+
deps.revokeCompanionDevices(removal.deviceJid ? [removal.deviceJid] : []);
|
|
332
|
+
}
|
|
333
|
+
else if (!removal.excludeHostedCompanion) {
|
|
334
|
+
// The hosted set is exactly what this session tracks, so
|
|
335
|
+
// sparing it leaves the registry untouched.
|
|
336
|
+
deps.revokeCompanionDevices(null);
|
|
337
|
+
}
|
|
338
|
+
return buildIqResult(iq);
|
|
339
|
+
}
|
|
340
|
+
// Anyone else may only unlink itself: a connection that is not the
|
|
341
|
+
// account's phone must never drop someone else's device. Its own
|
|
342
|
+
// session ends either way.
|
|
343
|
+
if (removal?.deviceJid && removal.deviceJid === ownDeviceJid(context)) {
|
|
344
|
+
deps.revokeCompanionDevices([removal.deviceJid]);
|
|
345
|
+
}
|
|
228
346
|
deps.notifyLogout();
|
|
229
347
|
return buildIqResult(iq);
|
|
230
348
|
}
|
package/dist/esm/bin.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Standalone CLI for `@zapo-js/fake-server`. */
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
3
4
|
import { FakeWaServer } from './api/FakeWaServer.js';
|
|
5
|
+
import { parsePairingQrString } from './protocol/auth/pair-device.js';
|
|
6
|
+
import { bytesToHex, toError } from './transport/util.js';
|
|
4
7
|
function parseArgs(argv) {
|
|
5
8
|
let host = '127.0.0.1';
|
|
6
9
|
let port = 0;
|
|
7
10
|
let path = '/ws/chat';
|
|
8
11
|
const peerJids = [];
|
|
9
12
|
const groupSpecs = [];
|
|
13
|
+
let pairJid = null;
|
|
10
14
|
let log = false;
|
|
11
15
|
let quiet = false;
|
|
12
16
|
let json = false;
|
|
@@ -44,6 +48,9 @@ function parseArgs(argv) {
|
|
|
44
48
|
case '--group':
|
|
45
49
|
groupSpecs.push(next());
|
|
46
50
|
break;
|
|
51
|
+
case '--pair':
|
|
52
|
+
pairJid = next();
|
|
53
|
+
break;
|
|
47
54
|
case '--log':
|
|
48
55
|
log = true;
|
|
49
56
|
break;
|
|
@@ -57,7 +64,7 @@ function parseArgs(argv) {
|
|
|
57
64
|
throw new Error(`unknown flag: ${arg}`);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
|
-
return { host, port, path, peerJids, groupSpecs, log, quiet, json, help };
|
|
67
|
+
return { host, port, path, peerJids, groupSpecs, pairJid, log, quiet, json, help };
|
|
61
68
|
}
|
|
62
69
|
function printHelp() {
|
|
63
70
|
process.stdout.write(`@zapo-js/fake-server – standalone fake WhatsApp Web server
|
|
@@ -73,6 +80,12 @@ FLAGS
|
|
|
73
80
|
--group <spec> Pre-create a fake group; spec format
|
|
74
81
|
<group-jid>=<peer-jid>,<peer-jid>,...
|
|
75
82
|
All peers must already be passed via --peer.
|
|
83
|
+
--pair <jid> Drive QR pairing for unregistered clients: the server
|
|
84
|
+
sends pair-device refs, then prompts on stdin for the
|
|
85
|
+
QR payload the client displays
|
|
86
|
+
(ref,noisePubB64,identityPubB64,advSecretB64,platform)
|
|
87
|
+
and answers pair-success assigning <jid> as the device
|
|
88
|
+
jid. Without this flag the CLI is login-only.
|
|
76
89
|
--log Print every captured inbound stanza
|
|
77
90
|
--quiet Suppress the startup info banner
|
|
78
91
|
--json Print connection info as JSON
|
|
@@ -103,7 +116,7 @@ async function main() {
|
|
|
103
116
|
args = parseArgs(process.argv.slice(2));
|
|
104
117
|
}
|
|
105
118
|
catch (error) {
|
|
106
|
-
process.stderr.write(`error: ${error
|
|
119
|
+
process.stderr.write(`error: ${toError(error).message}\n`);
|
|
107
120
|
process.stderr.write('run with --help to see usage.\n');
|
|
108
121
|
process.exit(2);
|
|
109
122
|
}
|
|
@@ -125,11 +138,39 @@ async function main() {
|
|
|
125
138
|
port: args.port,
|
|
126
139
|
path: args.path
|
|
127
140
|
});
|
|
141
|
+
if (args.pairJid !== null) {
|
|
142
|
+
const pairJid = args.pairJid;
|
|
143
|
+
let pairingStarted = false;
|
|
144
|
+
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
145
|
+
if (pairingStarted)
|
|
146
|
+
return;
|
|
147
|
+
if (pipeline.clientPayload?.kind !== 'registration')
|
|
148
|
+
return;
|
|
149
|
+
pairingStarted = true;
|
|
150
|
+
try {
|
|
151
|
+
await server.runPairing(pipeline, { deviceJid: pairJid }, async () => {
|
|
152
|
+
const qr = await readStdinLine('[fake-server] paste the QR payload displayed by the client and press Enter:\n');
|
|
153
|
+
const parsed = parsePairingQrString(qr.trim());
|
|
154
|
+
return {
|
|
155
|
+
advSecretKey: parsed.advSecretKey,
|
|
156
|
+
identityPublicKey: parsed.identityPublicKey
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
process.stdout.write(`[fake-server] pair-success sent for ${pairJid}; the client will now reconnect and log in\n`);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
pairingStarted = false;
|
|
163
|
+
process.stderr.write(`pairing failed: ${toError(error).message}\n`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
128
167
|
let setupComplete = false;
|
|
129
168
|
const setupPromise = new Promise((resolve, reject) => {
|
|
130
169
|
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
131
170
|
if (setupComplete)
|
|
132
171
|
return;
|
|
172
|
+
if (pipeline.clientPayload?.kind !== 'login')
|
|
173
|
+
return;
|
|
133
174
|
setupComplete = true;
|
|
134
175
|
try {
|
|
135
176
|
const peers = new Map();
|
|
@@ -155,7 +196,7 @@ async function main() {
|
|
|
155
196
|
resolve();
|
|
156
197
|
}
|
|
157
198
|
catch (error) {
|
|
158
|
-
reject(
|
|
199
|
+
reject(toError(error));
|
|
159
200
|
}
|
|
160
201
|
});
|
|
161
202
|
});
|
|
@@ -164,12 +205,11 @@ async function main() {
|
|
|
164
205
|
process.exitCode = 1;
|
|
165
206
|
});
|
|
166
207
|
if (args.log) {
|
|
167
|
-
server
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
208
|
+
// onCapturedStanza taps the server's own capture stream; overriding
|
|
209
|
+
// pipeline.setEvents here would replace the server's handlers and
|
|
210
|
+
// silently break --peer setup and stanza capture.
|
|
211
|
+
server.onCapturedStanza((node) => {
|
|
212
|
+
process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
|
|
173
213
|
});
|
|
174
214
|
}
|
|
175
215
|
if (!args.quiet) {
|
|
@@ -185,7 +225,8 @@ async function main() {
|
|
|
185
225
|
publicKeyHex: bytesToHex(noiseRoot.publicKey)
|
|
186
226
|
},
|
|
187
227
|
peers: args.peerJids,
|
|
188
|
-
groups: parsedGroups
|
|
228
|
+
groups: parsedGroups,
|
|
229
|
+
pair: args.pairJid
|
|
189
230
|
}, null, 2) + '\n');
|
|
190
231
|
}
|
|
191
232
|
else {
|
|
@@ -201,6 +242,7 @@ async function main() {
|
|
|
201
242
|
`│ noise root ca serial=${noiseRoot.serial} pub=${bytesToHex(noiseRoot.publicKey).slice(0, 32)}…`,
|
|
202
243
|
`│ peers ${args.peerJids.length === 0 ? '(none)' : args.peerJids.join(', ')}`,
|
|
203
244
|
`│ groups ${parsedGroups.length === 0 ? '(none)' : parsedGroups.map((g) => g.groupJid).join(', ')}`,
|
|
245
|
+
`│ pair mode ${args.pairJid ?? '(off – login only)'}`,
|
|
204
246
|
'├─────────────────────────────────────────────────────────────',
|
|
205
247
|
'│ Wire your WaClient with:',
|
|
206
248
|
'│ chatSocketUrls: [server.url]',
|
|
@@ -224,7 +266,7 @@ async function main() {
|
|
|
224
266
|
process.exit(0);
|
|
225
267
|
}
|
|
226
268
|
catch (error) {
|
|
227
|
-
process.stderr.write(`error during shutdown: ${error
|
|
269
|
+
process.stderr.write(`error during shutdown: ${toError(error).message}\n`);
|
|
228
270
|
process.exit(1);
|
|
229
271
|
}
|
|
230
272
|
};
|
|
@@ -232,15 +274,21 @@ async function main() {
|
|
|
232
274
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
233
275
|
await new Promise(() => undefined);
|
|
234
276
|
}
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
277
|
+
function readStdinLine(prompt) {
|
|
278
|
+
process.stdout.write(prompt);
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const rl = createInterface({ input: process.stdin });
|
|
281
|
+
// resolve before close(): rl.close() emits 'close' synchronously,
|
|
282
|
+
// which would otherwise reject the still-pending promise first.
|
|
283
|
+
rl.once('line', (line) => {
|
|
284
|
+
resolve(line);
|
|
285
|
+
rl.close();
|
|
286
|
+
});
|
|
287
|
+
rl.once('close', () => reject(new Error('stdin closed before a QR payload was provided')));
|
|
288
|
+
});
|
|
242
289
|
}
|
|
243
290
|
main().catch((error) => {
|
|
244
|
-
|
|
291
|
+
const normalized = toError(error);
|
|
292
|
+
process.stderr.write(`fatal: ${normalized.stack ?? normalized.message}\n`);
|
|
245
293
|
process.exit(1);
|
|
246
294
|
});
|
package/dist/esm/index.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
export { createFakeMobilePrimary, seedFakeMobilePrimary } from './api/FakeMobilePrimary.js';
|
|
1
2
|
export { FakePairingDriver } from './api/FakePairingDriver.js';
|
|
2
3
|
export { FakePeer } from './api/FakePeer.js';
|
|
3
|
-
export { FakeWaServer } from './api/FakeWaServer.js';
|
|
4
|
+
export { FakeServerSession, FakeWaServer } from './api/FakeWaServer.js';
|
|
4
5
|
export { IqExpectation, Scenario } from './api/Scenario.js';
|
|
5
6
|
export { WaFakeConnection } from './infra/WaFakeConnection.js';
|
|
7
|
+
export { WaFakeTcpServer } from './infra/WaFakeTcpServer.js';
|
|
8
|
+
export { createTcpSocketAdapter, createWebSocketAdapter } from './infra/socket-adapters.js';
|
|
9
|
+
export { ClientPayloadValidationError, parseClientPayload } from './protocol/auth/client-payload-validate.js';
|
|
10
|
+
export { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from './protocol/iq/companion-host.js';
|
|
11
|
+
export { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from './protocol/iq/link-code.js';
|
|
12
|
+
export { buildAccountSyncDevicesNotification, buildAccountTakeoverNotice, buildRegistrationCodeNotification } from './protocol/push/mobile-notification.js';
|
|
13
|
+
export { FakeCompanionHostState, readCompanionKeyIndex } from './state/fake-companion-host.js';
|
|
6
14
|
export { buildAppStateSyncFullResult, buildAppStateSyncResult, buildServerSyncNotification, parseAppStateSyncRequest } from './protocol/iq/appstate-sync.js';
|
|
7
15
|
export { buildIqError, buildIqResult } from './protocol/iq/router.js';
|
|
8
16
|
export { buildCall, buildFailure } from './protocol/push/call-failure.js';
|
|
@@ -4,7 +4,14 @@ export class WaFakeConnection {
|
|
|
4
4
|
this.currentState = 'open';
|
|
5
5
|
this.id = id;
|
|
6
6
|
this.socket = socket;
|
|
7
|
-
this.
|
|
7
|
+
this.socket.listen({
|
|
8
|
+
onFrame: (frame) => this.handlers.onFrame?.(frame),
|
|
9
|
+
onClose: (info) => {
|
|
10
|
+
this.currentState = 'closed';
|
|
11
|
+
this.handlers.onClose?.(info);
|
|
12
|
+
},
|
|
13
|
+
onError: (error) => this.handlers.onError?.(error)
|
|
14
|
+
});
|
|
8
15
|
}
|
|
9
16
|
get state() {
|
|
10
17
|
return this.currentState;
|
|
@@ -25,25 +32,4 @@ export class WaFakeConnection {
|
|
|
25
32
|
this.currentState = 'closing';
|
|
26
33
|
this.socket.close(code, reason);
|
|
27
34
|
}
|
|
28
|
-
bindSocketEvents() {
|
|
29
|
-
this.socket.on('message', (data, isBinary) => {
|
|
30
|
-
if (!isBinary) {
|
|
31
|
-
this.handlers.onError?.(new Error('received unexpected text frame'));
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const frame = data instanceof Uint8Array
|
|
35
|
-
? data
|
|
36
|
-
: Array.isArray(data)
|
|
37
|
-
? new Uint8Array(Buffer.concat(data))
|
|
38
|
-
: new Uint8Array(data);
|
|
39
|
-
this.handlers.onFrame?.(frame);
|
|
40
|
-
});
|
|
41
|
-
this.socket.on('close', (code, reasonBuf) => {
|
|
42
|
-
this.currentState = 'closed';
|
|
43
|
-
this.handlers.onClose?.({ code, reason: reasonBuf.toString('utf8') });
|
|
44
|
-
});
|
|
45
|
-
this.socket.on('error', (error) => {
|
|
46
|
-
this.handlers.onError?.(error);
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
35
|
}
|
|
@@ -10,16 +10,20 @@ import { WaFakeNoiseHandshake } from './WaFakeNoiseHandshake.js';
|
|
|
10
10
|
import { WaFakeTransport } from './WaFakeTransport.js';
|
|
11
11
|
const NOISE_XX_NAME = new TextEncoder().encode('Noise_XX_25519_AESGCM_SHA256\0\0\0\0');
|
|
12
12
|
const NOISE_IK_NAME = new TextEncoder().encode('Noise_IK_25519_AESGCM_SHA256\0\0\0\0');
|
|
13
|
-
const
|
|
13
|
+
const PROLOGUE_MAGIC_W = 0x57;
|
|
14
|
+
const PROLOGUE_MAGIC_A = 0x41;
|
|
15
|
+
const DEFAULT_PROLOGUE = new Uint8Array([PROLOGUE_MAGIC_W, PROLOGUE_MAGIC_A, 0x06, 0x03]);
|
|
14
16
|
export class WaFakeConnectionPipeline {
|
|
15
17
|
constructor(config) {
|
|
16
18
|
this.events = {};
|
|
17
19
|
this.state = { kind: 'awaiting_prologue' };
|
|
18
20
|
this.chain = Promise.resolve();
|
|
21
|
+
this.authenticatedPayload = null;
|
|
22
|
+
this.clientPrologue = DEFAULT_PROLOGUE;
|
|
19
23
|
this.config = config;
|
|
20
24
|
this.frameSocket = new WaFakeFrameSocket(config.connection);
|
|
21
25
|
this.frameSocket.setHandlers({
|
|
22
|
-
onPrologue: () => this.onPrologue(),
|
|
26
|
+
onPrologue: (prologue) => this.onPrologue(prologue),
|
|
23
27
|
onFrame: (frame) => this.scheduleFrame(frame),
|
|
24
28
|
onClose: (info) => {
|
|
25
29
|
this.state = { kind: 'closed' };
|
|
@@ -34,6 +38,10 @@ export class WaFakeConnectionPipeline {
|
|
|
34
38
|
isAuthenticated() {
|
|
35
39
|
return this.state.kind === 'authenticated';
|
|
36
40
|
}
|
|
41
|
+
/** Parsed ClientPayload of the authenticated connection; null before auth. */
|
|
42
|
+
get clientPayload() {
|
|
43
|
+
return this.authenticatedPayload;
|
|
44
|
+
}
|
|
37
45
|
async sendStanza(node) {
|
|
38
46
|
if (this.state.kind !== 'authenticated') {
|
|
39
47
|
throw new Error(`cannot send stanza while pipeline is in state "${this.state.kind}"`);
|
|
@@ -43,11 +51,20 @@ export class WaFakeConnectionPipeline {
|
|
|
43
51
|
const ciphertext = await this.state.transport.encryptFrame(stanza);
|
|
44
52
|
this.frameSocket.sendFrame(ciphertext);
|
|
45
53
|
}
|
|
46
|
-
onPrologue() {
|
|
54
|
+
onPrologue(prologue) {
|
|
47
55
|
if (this.state.kind !== 'awaiting_prologue') {
|
|
48
56
|
this.events.onError?.(new Error('received second prologue from client'));
|
|
49
57
|
return;
|
|
50
58
|
}
|
|
59
|
+
// Mix back the header the client actually sent instead of a fixed one:
|
|
60
|
+
// it is hashed into the handshake, and a client is free to advertise a
|
|
61
|
+
// protocol/dict version other than the web client's 6/3.
|
|
62
|
+
this.clientPrologue = new Uint8Array([
|
|
63
|
+
PROLOGUE_MAGIC_W,
|
|
64
|
+
PROLOGUE_MAGIC_A,
|
|
65
|
+
prologue.protocolVersion,
|
|
66
|
+
prologue.dictVersion
|
|
67
|
+
]);
|
|
51
68
|
this.state = { kind: 'awaiting_client_hello' };
|
|
52
69
|
}
|
|
53
70
|
scheduleFrame(frame) {
|
|
@@ -89,7 +106,7 @@ export class WaFakeConnectionPipeline {
|
|
|
89
106
|
}
|
|
90
107
|
async handleXxClientHello(clientEphemeralPub) {
|
|
91
108
|
const handshake = new WaFakeNoiseHandshake();
|
|
92
|
-
handshake.start(NOISE_XX_NAME,
|
|
109
|
+
handshake.start(NOISE_XX_NAME, this.clientPrologue);
|
|
93
110
|
handshake.authenticate(clientEphemeralPub);
|
|
94
111
|
const serverEphemeral = await X25519.generateKeyPair();
|
|
95
112
|
handshake.authenticate(serverEphemeral.pubKey);
|
|
@@ -125,7 +142,7 @@ export class WaFakeConnectionPipeline {
|
|
|
125
142
|
const encryptedClientStatic = clientHello.static;
|
|
126
143
|
const encryptedClientPayload = clientHello.payload;
|
|
127
144
|
const handshake = new WaFakeNoiseHandshake();
|
|
128
|
-
handshake.start(NOISE_IK_NAME,
|
|
145
|
+
handshake.start(NOISE_IK_NAME, this.clientPrologue);
|
|
129
146
|
handshake.authenticate(this.config.serverStaticKeyPair.pubKey);
|
|
130
147
|
handshake.authenticate(clientEphemeralPub);
|
|
131
148
|
handshake.mixIntoKey(await X25519.scalarMult(this.config.serverStaticKeyPair.privKey, clientEphemeralPub));
|
|
@@ -153,14 +170,7 @@ export class WaFakeConnectionPipeline {
|
|
|
153
170
|
}
|
|
154
171
|
}).finish();
|
|
155
172
|
this.frameSocket.sendFrame(serverHello);
|
|
156
|
-
|
|
157
|
-
const transport = new WaFakeTransport({
|
|
158
|
-
recvKey: keys.recvKey,
|
|
159
|
-
sendKey: keys.sendKey
|
|
160
|
-
});
|
|
161
|
-
this.state = { kind: 'authenticated', transport };
|
|
162
|
-
await this.sendStanza(buildSuccessNode(this.config.successNodeAttributes));
|
|
163
|
-
this.events.onAuthenticated?.({ clientPayload, clientStaticKey });
|
|
173
|
+
await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
|
|
164
174
|
}
|
|
165
175
|
async handleClientFinish(frame, handshake, serverEphemeralKeyPair) {
|
|
166
176
|
const parsed = proto.HandshakeMessage.decode(frame);
|
|
@@ -172,13 +182,27 @@ export class WaFakeConnectionPipeline {
|
|
|
172
182
|
handshake.mixIntoKey(await X25519.scalarMult(serverEphemeralKeyPair.privKey, clientStaticKey));
|
|
173
183
|
const clientPayloadBytes = handshake.decrypt(clientFinish.payload);
|
|
174
184
|
const clientPayload = parseClientPayload(clientPayloadBytes);
|
|
185
|
+
await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
|
|
186
|
+
}
|
|
187
|
+
async completeAuthentication(handshake, clientPayload, clientStaticKey) {
|
|
175
188
|
const keys = handshake.finish();
|
|
176
189
|
const transport = new WaFakeTransport({
|
|
177
190
|
recvKey: keys.recvKey,
|
|
178
191
|
sendKey: keys.sendKey
|
|
179
192
|
});
|
|
180
193
|
this.state = { kind: 'authenticated', transport };
|
|
194
|
+
this.authenticatedPayload = clientPayload;
|
|
181
195
|
await this.sendStanza(buildSuccessNode(this.config.successNodeAttributes));
|
|
196
|
+
if (clientPayload.kind === 'login') {
|
|
197
|
+
// Real WA drains offline messages after login and closes the
|
|
198
|
+
// drain with this bulletin. Baileys-family clients buffer their
|
|
199
|
+
// events until it arrives, so send it eagerly with count=0.
|
|
200
|
+
await this.sendStanza({
|
|
201
|
+
tag: 'ib',
|
|
202
|
+
attrs: { from: 's.whatsapp.net' },
|
|
203
|
+
content: [{ tag: 'offline', attrs: { count: '0' } }]
|
|
204
|
+
});
|
|
205
|
+
}
|
|
182
206
|
this.events.onAuthenticated?.({ clientPayload, clientStaticKey });
|
|
183
207
|
}
|
|
184
208
|
async handleAuthenticatedFrame(frame, transport) {
|
|
@@ -186,7 +210,7 @@ export class WaFakeConnectionPipeline {
|
|
|
186
210
|
const node = await decodeBinaryNodeStanza(stanzaBytes);
|
|
187
211
|
this.events.onStanza?.(node);
|
|
188
212
|
if (node.tag === 'iq') {
|
|
189
|
-
const response = await this.config.
|
|
213
|
+
const response = await this.config.routeIq(node);
|
|
190
214
|
if (response !== null) {
|
|
191
215
|
await this.sendStanza(response);
|
|
192
216
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { createTcpSocketAdapter } from './socket-adapters.js';
|
|
3
|
+
import { WaFakeConnection } from './WaFakeConnection.js';
|
|
4
|
+
/**
|
|
5
|
+
* Raw-TCP listener for the WhatsApp Mobile transport. The mobile client dials
|
|
6
|
+
* `tcp://host:port` instead of upgrading to a WebSocket, but speaks the exact
|
|
7
|
+
* same prologue + length-prefixed frames on top, so every layer above the
|
|
8
|
+
* carrier is shared with {@link WaFakeWsServer}.
|
|
9
|
+
*/
|
|
10
|
+
export class WaFakeTcpServer {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.server = null;
|
|
13
|
+
this.connectionListener = null;
|
|
14
|
+
this.sockets = new Set();
|
|
15
|
+
this.nextConnectionId = 0;
|
|
16
|
+
this.options = {
|
|
17
|
+
host: options.host ?? '127.0.0.1',
|
|
18
|
+
port: options.port ?? 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
onConnection(listener) {
|
|
22
|
+
this.connectionListener = listener;
|
|
23
|
+
}
|
|
24
|
+
async listen() {
|
|
25
|
+
if (this.server) {
|
|
26
|
+
throw new Error('fake tcp server is already listening');
|
|
27
|
+
}
|
|
28
|
+
const server = createServer((socket) => {
|
|
29
|
+
this.sockets.add(socket);
|
|
30
|
+
socket.on('close', () => this.sockets.delete(socket));
|
|
31
|
+
const id = `t${this.nextConnectionId++}`;
|
|
32
|
+
this.connectionListener?.(new WaFakeConnection(id, createTcpSocketAdapter(socket)));
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const onError = (error) => {
|
|
36
|
+
server.off('listening', onListening);
|
|
37
|
+
reject(error);
|
|
38
|
+
};
|
|
39
|
+
const onListening = () => {
|
|
40
|
+
server.off('error', onError);
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
server.once('error', onError);
|
|
44
|
+
server.once('listening', onListening);
|
|
45
|
+
server.listen(this.options.port, this.options.host);
|
|
46
|
+
});
|
|
47
|
+
this.server = server;
|
|
48
|
+
const address = server.address();
|
|
49
|
+
return {
|
|
50
|
+
host: address.address,
|
|
51
|
+
port: address.port,
|
|
52
|
+
url: `tcp://${address.address}:${address.port}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
const server = this.server;
|
|
57
|
+
if (!server) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const socket of this.sockets) {
|
|
61
|
+
socket.destroy();
|
|
62
|
+
}
|
|
63
|
+
this.sockets.clear();
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
66
|
+
});
|
|
67
|
+
this.server = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { createWebSocketAdapter } from './socket-adapters.js';
|
|
3
4
|
import { WaFakeConnection } from './WaFakeConnection.js';
|
|
4
5
|
export class WaFakeWsServer {
|
|
5
6
|
constructor(options = {}) {
|
|
@@ -49,7 +50,7 @@ export class WaFakeWsServer {
|
|
|
49
50
|
const wsServer = new WebSocketServer({ server: httpServer, path: this.options.path });
|
|
50
51
|
wsServer.on('connection', (socket) => {
|
|
51
52
|
const id = `c${this.nextConnectionId++}`;
|
|
52
|
-
const connection = new WaFakeConnection(id, socket);
|
|
53
|
+
const connection = new WaFakeConnection(id, createWebSocketAdapter(socket));
|
|
53
54
|
this.connectionListener?.(connection);
|
|
54
55
|
});
|
|
55
56
|
await new Promise((resolve, reject) => {
|