@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
@@ -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
- const userJid = toUserJidPart(peer.jid);
195
- const deviceId = toDeviceIdPart(peer.jid);
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
- else {
202
- this.deviceIdsByUser.set(userJid, [deviceId]);
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[];
@@ -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: ${error instanceof Error ? error.message : String(error)}\n`);
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(error instanceof Error ? error : new Error(String(error)));
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.onPipeline((pipeline) => {
170
- pipeline.setEvents({
171
- onStanza: (node) => {
172
- process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
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: ${error instanceof Error ? error.message : String(error)}\n`);
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 bytesToHex(bytes) {
238
- let out = '';
239
- for (let index = 0; index < bytes.byteLength; index += 1) {
240
- const value = bytes[index];
241
- out += value < 16 ? `0${value.toString(16)}` : value.toString(16);
242
- }
243
- return out;
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
- process.stderr.write(`fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
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 refs = await Promise.all(Array.from({ length: 6 }, () => randomBytesAsync(16)));
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]);
@@ -547,7 +547,13 @@ export class FakePeer {
547
547
  }
548
548
  nextId() {
549
549
  this.nextMessageCounter += 1;
550
- return `${this.jid}-${Date.now()}-${this.nextMessageCounter}`;
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) {