@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.
Files changed (73) hide show
  1. package/README.md +180 -33
  2. package/dist/api/FakeMobilePrimary.d.ts +55 -0
  3. package/dist/api/FakeMobilePrimary.js +106 -0
  4. package/dist/api/FakePairingDriver.js +1 -3
  5. package/dist/api/FakePeer.js +7 -1
  6. package/dist/api/FakeServerSession.d.ts +100 -0
  7. package/dist/api/FakeServerSession.js +355 -0
  8. package/dist/api/FakeWaServer.d.ts +145 -37
  9. package/dist/api/FakeWaServer.js +345 -270
  10. package/dist/api/ServerRegistries.d.ts +7 -0
  11. package/dist/api/ServerRegistries.js +20 -5
  12. package/dist/api/iq-handlers.d.ts +20 -1
  13. package/dist/api/iq-handlers.js +119 -1
  14. package/dist/bin.js +69 -21
  15. package/dist/esm/api/FakeMobilePrimary.js +102 -0
  16. package/dist/esm/api/FakePairingDriver.js +2 -4
  17. package/dist/esm/api/FakePeer.js +7 -1
  18. package/dist/esm/api/FakeServerSession.js +349 -0
  19. package/dist/esm/api/FakeWaServer.js +344 -270
  20. package/dist/esm/api/ServerRegistries.js +20 -5
  21. package/dist/esm/api/iq-handlers.js +119 -1
  22. package/dist/esm/bin.js +67 -19
  23. package/dist/esm/index.js +9 -1
  24. package/dist/esm/infra/WaFakeConnection.js +8 -22
  25. package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
  26. package/dist/esm/infra/WaFakeTcpServer.js +69 -0
  27. package/dist/esm/infra/WaFakeWsServer.js +2 -1
  28. package/dist/esm/infra/socket-adapters.js +79 -0
  29. package/dist/esm/protocol/auth/cert-chain.js +13 -2
  30. package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
  31. package/dist/esm/protocol/auth/pair-device.js +16 -1
  32. package/dist/esm/protocol/iq/companion-host.js +104 -0
  33. package/dist/esm/protocol/iq/link-code.js +90 -0
  34. package/dist/esm/protocol/iq/router.js +9 -3
  35. package/dist/esm/protocol/push/mobile-notification.js +97 -0
  36. package/dist/esm/state/fake-companion-host.js +158 -0
  37. package/dist/esm/transport/auth.js +7 -0
  38. package/dist/esm/transport/crypto.js +1 -1
  39. package/dist/esm/transport/util.js +2 -1
  40. package/dist/index.d.ts +19 -4
  41. package/dist/index.js +29 -1
  42. package/dist/infra/WaFakeConnection.d.ts +21 -3
  43. package/dist/infra/WaFakeConnection.js +8 -22
  44. package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
  45. package/dist/infra/WaFakeConnectionPipeline.js +38 -14
  46. package/dist/infra/WaFakeTcpServer.d.ts +29 -0
  47. package/dist/infra/WaFakeTcpServer.js +73 -0
  48. package/dist/infra/WaFakeWsServer.js +2 -1
  49. package/dist/infra/socket-adapters.d.ts +18 -0
  50. package/dist/infra/socket-adapters.js +83 -0
  51. package/dist/protocol/auth/cert-chain.d.ts +4 -0
  52. package/dist/protocol/auth/cert-chain.js +13 -2
  53. package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
  54. package/dist/protocol/auth/client-payload-validate.js +77 -1
  55. package/dist/protocol/auth/pair-device.d.ts +15 -1
  56. package/dist/protocol/auth/pair-device.js +16 -0
  57. package/dist/protocol/iq/companion-host.d.ts +52 -0
  58. package/dist/protocol/iq/companion-host.js +109 -0
  59. package/dist/protocol/iq/link-code.d.ts +46 -0
  60. package/dist/protocol/iq/link-code.js +95 -0
  61. package/dist/protocol/iq/router.d.ts +21 -2
  62. package/dist/protocol/iq/router.js +9 -3
  63. package/dist/protocol/push/mobile-notification.d.ts +58 -0
  64. package/dist/protocol/push/mobile-notification.js +102 -0
  65. package/dist/state/fake-companion-host.d.ts +73 -0
  66. package/dist/state/fake-companion-host.js +163 -0
  67. package/dist/transport/auth.d.ts +8 -0
  68. package/dist/transport/auth.js +8 -0
  69. package/dist/transport/crypto.d.ts +1 -1
  70. package/dist/transport/crypto.js +2 -1
  71. package/dist/transport/util.d.ts +2 -1
  72. package/dist/transport/util.js +3 -1
  73. 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
- const userJid = toUserJidPart(peer.jid);
190
- const deviceId = toDeviceIdPart(peer.jid);
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
- else {
197
- this.deviceIdsByUser.set(userJid, [deviceId]);
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 instanceof Error ? error.message : String(error)}\n`);
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(error instanceof Error ? error : new Error(String(error)));
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.onPipeline((pipeline) => {
168
- pipeline.setEvents({
169
- onStanza: (node) => {
170
- process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
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 instanceof Error ? error.message : String(error)}\n`);
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 bytesToHex(bytes) {
236
- let out = '';
237
- for (let index = 0; index < bytes.byteLength; index += 1) {
238
- const value = bytes[index];
239
- out += value < 16 ? `0${value.toString(16)}` : value.toString(16);
240
- }
241
- return out;
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
- process.stderr.write(`fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
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.bindSocketEvents();
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 PROLOGUE = new Uint8Array([0x57, 0x41, 0x06, 0x03]);
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, PROLOGUE);
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, PROLOGUE);
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
- const keys = handshake.finish();
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.iqRouter.route(node);
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) => {