@zapo-js/fake-server 1.0.0 → 1.1.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/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,6 +1,6 @@
1
1
  export { FakePairingDriver } from './api/FakePairingDriver.js';
2
2
  export { FakePeer } from './api/FakePeer.js';
3
- export { FakeWaServer } from './api/FakeWaServer.js';
3
+ export { FakeServerSession, FakeWaServer } from './api/FakeWaServer.js';
4
4
  export { IqExpectation, Scenario } from './api/Scenario.js';
5
5
  export { WaFakeConnection } from './infra/WaFakeConnection.js';
6
6
  export { buildAppStateSyncFullResult, buildAppStateSyncResult, buildServerSyncNotification, parseAppStateSyncRequest } from './protocol/iq/appstate-sync.js';
@@ -16,6 +16,7 @@ export class WaFakeConnectionPipeline {
16
16
  this.events = {};
17
17
  this.state = { kind: 'awaiting_prologue' };
18
18
  this.chain = Promise.resolve();
19
+ this.authenticatedPayload = null;
19
20
  this.config = config;
20
21
  this.frameSocket = new WaFakeFrameSocket(config.connection);
21
22
  this.frameSocket.setHandlers({
@@ -34,6 +35,10 @@ export class WaFakeConnectionPipeline {
34
35
  isAuthenticated() {
35
36
  return this.state.kind === 'authenticated';
36
37
  }
38
+ /** Parsed ClientPayload of the authenticated connection; null before auth. */
39
+ get clientPayload() {
40
+ return this.authenticatedPayload;
41
+ }
37
42
  async sendStanza(node) {
38
43
  if (this.state.kind !== 'authenticated') {
39
44
  throw new Error(`cannot send stanza while pipeline is in state "${this.state.kind}"`);
@@ -153,14 +158,7 @@ export class WaFakeConnectionPipeline {
153
158
  }
154
159
  }).finish();
155
160
  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 });
161
+ await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
164
162
  }
165
163
  async handleClientFinish(frame, handshake, serverEphemeralKeyPair) {
166
164
  const parsed = proto.HandshakeMessage.decode(frame);
@@ -172,13 +170,27 @@ export class WaFakeConnectionPipeline {
172
170
  handshake.mixIntoKey(await X25519.scalarMult(serverEphemeralKeyPair.privKey, clientStaticKey));
173
171
  const clientPayloadBytes = handshake.decrypt(clientFinish.payload);
174
172
  const clientPayload = parseClientPayload(clientPayloadBytes);
173
+ await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
174
+ }
175
+ async completeAuthentication(handshake, clientPayload, clientStaticKey) {
175
176
  const keys = handshake.finish();
176
177
  const transport = new WaFakeTransport({
177
178
  recvKey: keys.recvKey,
178
179
  sendKey: keys.sendKey
179
180
  });
180
181
  this.state = { kind: 'authenticated', transport };
182
+ this.authenticatedPayload = clientPayload;
181
183
  await this.sendStanza(buildSuccessNode(this.config.successNodeAttributes));
184
+ if (clientPayload.kind === 'login') {
185
+ // Real WA drains offline messages after login and closes the
186
+ // drain with this bulletin. Baileys-family clients buffer their
187
+ // events until it arrives, so send it eagerly with count=0.
188
+ await this.sendStanza({
189
+ tag: 'ib',
190
+ attrs: { from: 's.whatsapp.net' },
191
+ content: [{ tag: 'offline', attrs: { count: '0' } }]
192
+ });
193
+ }
182
194
  this.events.onAuthenticated?.({ clientPayload, clientStaticKey });
183
195
  }
184
196
  async handleAuthenticatedFrame(frame, transport) {
@@ -186,7 +198,7 @@ export class WaFakeConnectionPipeline {
186
198
  const node = await decodeBinaryNodeStanza(stanzaBytes);
187
199
  this.events.onStanza?.(node);
188
200
  if (node.tag === 'iq') {
189
- const response = await this.config.iqRouter.route(node);
201
+ const response = await this.config.routeIq(node);
190
202
  if (response !== null) {
191
203
  await this.sendStanza(response);
192
204
  }
@@ -1,6 +1,8 @@
1
1
  /** Fake noise certificate chain generator for handshake tests. */
2
2
  import { X25519, xeddsaSign } from '../../transport/crypto.js';
3
3
  import { proto } from '../../transport/protos.js';
4
+ const WA_CERT_NOT_BEFORE_BACKDATE_SECONDS = 86400;
5
+ const WA_CERT_NOT_AFTER_LIFETIME_SECONDS = 315360000;
4
6
  export async function generateFakeNoiseRootCa() {
5
7
  const keyPair = await X25519.generateKeyPair();
6
8
  return {
@@ -12,17 +14,26 @@ export async function generateFakeNoiseRootCa() {
12
14
  export async function buildFakeCertChain(input) {
13
15
  const intermediateSerial = input.intermediateSerial ?? 1;
14
16
  const leafSerial = input.leafSerial ?? 2;
17
+ // Strict clients (e.g. whatsmeow) enforce the validity window; leaving
18
+ // notBefore/notAfter unset decodes as 0 (1970) and reads as expired.
19
+ const nowSeconds = Math.floor(Date.now() / 1000);
20
+ const notBefore = input.notBefore ?? nowSeconds - WA_CERT_NOT_BEFORE_BACKDATE_SECONDS;
21
+ const notAfter = input.notAfter ?? nowSeconds + WA_CERT_NOT_AFTER_LIFETIME_SECONDS;
15
22
  const intermediateKeyPair = await X25519.generateKeyPair();
16
23
  const intermediateDetails = proto.CertChain.NoiseCertificate.Details.encode({
17
24
  serial: intermediateSerial,
18
25
  issuerSerial: input.root.serial,
19
- key: intermediateKeyPair.pubKey
26
+ key: intermediateKeyPair.pubKey,
27
+ notBefore,
28
+ notAfter
20
29
  }).finish();
21
30
  const intermediateSignature = await xeddsaSign(input.root.privateKey, intermediateDetails);
22
31
  const leafDetails = proto.CertChain.NoiseCertificate.Details.encode({
23
32
  serial: leafSerial,
24
33
  issuerSerial: intermediateSerial,
25
- key: input.leafKey
34
+ key: input.leafKey,
35
+ notBefore,
36
+ notAfter
26
37
  }).finish();
27
38
  const leafSignature = await xeddsaSign(intermediateKeyPair.privKey, leafDetails);
28
39
  const encoded = proto.CertChain.encode({
@@ -25,12 +25,18 @@ export class WaFakeIqRouter {
25
25
  }
26
26
  for (const handler of this.highPriorityHandlers) {
27
27
  if (matches(iq, handler.matcher)) {
28
- return handler.respond(iq);
28
+ const response = await handler.respond(iq);
29
+ if (response !== null) {
30
+ return response;
31
+ }
29
32
  }
30
33
  }
31
34
  for (const handler of this.handlers) {
32
35
  if (matches(iq, handler.matcher)) {
33
- return handler.respond(iq);
36
+ const response = await handler.respond(iq);
37
+ if (response !== null) {
38
+ return response;
39
+ }
34
40
  }
35
41
  }
36
42
  this.events.onUnhandled?.(iq);
@@ -4,5 +4,5 @@
4
4
  * Re-exports bit-exact byte helpers from zapo-js so Layer 2/3 of the fake
5
5
  * server can use them without importing zapo-js directly.
6
6
  */
7
- export { bytesToBase64, bytesToBase64UrlSafe, bytesToHex, decodeBase64Url } from 'zapo-js/util';
7
+ export { bytesToBase64, bytesToBase64UrlSafe, bytesToHex, decodeBase64Url, toError } from 'zapo-js/util';
8
8
  export const TEXT_ENCODER = new TextEncoder();
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { FakePairingDriver } from './api/FakePairingDriver';
2
2
  export type { CompanionPairingMaterial, FakePairingDriverDeps, FakePairingDriverOptions } from './api/FakePairingDriver';
3
3
  export { FakePeer } from './api/FakePeer';
4
4
  export type { CreateFakePeerOptions, ExpectGroupMessageOptions, ExpectMessageOptions, ReceivedMessage, SendMessageOptions } from './api/FakePeer';
5
- export { FakeWaServer } from './api/FakeWaServer';
6
- export type { BinaryNode, ExpectIqOptions, ExpectStanzaOptions, FakeWaServerNoiseRootCa, FakeWaServerOptions, FakeWaServerPipelineListener, StanzaMatcher, WaFakeAuthenticatedInfo, WaFakeConnectionPipeline } from './api/FakeWaServer';
5
+ export { FakeServerSession, FakeWaServer } from './api/FakeWaServer';
6
+ export type { BinaryNode, ExpectIqOptions, ExpectStanzaOptions, FakeSessionKeyInfo, FakeWaServerNoiseRootCa, FakeWaServerOptions, FakeWaServerPipelineListener, StanzaMatcher, WaFakeAuthenticatedInfo, WaFakeConnectionPipeline } from './api/FakeWaServer';
7
7
  export { IqExpectation, Scenario } from './api/Scenario';
8
8
  export type { AuthenticatedPipelineListener, ScenarioServer } from './api/Scenario';
9
9
  export { WaFakeConnection } from './infra/WaFakeConnection';
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildStreamErrorXmlNotWellFormed = exports.buildStreamErrorReplaced = exports.buildStreamErrorDeviceRemoved = exports.buildStreamErrorCode = exports.buildStreamErrorAck = exports.PreKeyUploadParseError = exports.parsePreKeyUploadIq = exports.parsePairingQrString = exports.buildPairSuccessIq = exports.buildPairDeviceIq = exports.generateFakePrimaryDevice = exports.buildAdvSignedDeviceIdentity = exports.FakeSenderKey = exports.FakePeerDoubleRatchet = exports.generateFakePeerIdentity = exports.buildReceipt = exports.buildIncomingPresence = exports.buildNotification = exports.buildGroupNotification = exports.buildMessage = exports.FakeMediaStore = exports.FakeAppStateCollection = exports.FakeAppStateCrypto = exports.APP_STATE_EMPTY_LT_HASH = exports.encodeHistorySyncPlaintext = exports.buildHistorySyncMessage = exports.buildHistorySyncExternalMessage = exports.buildAppStateSyncKeyShareMessage = exports.buildIncomingErrorStanza = exports.buildChatstate = exports.buildFailure = exports.buildCall = exports.buildIqResult = exports.buildIqError = exports.parseAppStateSyncRequest = exports.buildServerSyncNotification = exports.buildAppStateSyncResult = exports.buildAppStateSyncFullResult = exports.WaFakeConnection = exports.Scenario = exports.IqExpectation = exports.FakeWaServer = exports.FakePeer = exports.FakePairingDriver = void 0;
3
+ exports.buildStreamErrorXmlNotWellFormed = exports.buildStreamErrorReplaced = exports.buildStreamErrorDeviceRemoved = exports.buildStreamErrorCode = exports.buildStreamErrorAck = exports.PreKeyUploadParseError = exports.parsePreKeyUploadIq = exports.parsePairingQrString = exports.buildPairSuccessIq = exports.buildPairDeviceIq = exports.generateFakePrimaryDevice = exports.buildAdvSignedDeviceIdentity = exports.FakeSenderKey = exports.FakePeerDoubleRatchet = exports.generateFakePeerIdentity = exports.buildReceipt = exports.buildIncomingPresence = exports.buildNotification = exports.buildGroupNotification = exports.buildMessage = exports.FakeMediaStore = exports.FakeAppStateCollection = exports.FakeAppStateCrypto = exports.APP_STATE_EMPTY_LT_HASH = exports.encodeHistorySyncPlaintext = exports.buildHistorySyncMessage = exports.buildHistorySyncExternalMessage = exports.buildAppStateSyncKeyShareMessage = exports.buildIncomingErrorStanza = exports.buildChatstate = exports.buildFailure = exports.buildCall = exports.buildIqResult = exports.buildIqError = exports.parseAppStateSyncRequest = exports.buildServerSyncNotification = exports.buildAppStateSyncResult = exports.buildAppStateSyncFullResult = exports.WaFakeConnection = exports.Scenario = exports.IqExpectation = exports.FakeWaServer = exports.FakeServerSession = exports.FakePeer = exports.FakePairingDriver = void 0;
4
4
  var FakePairingDriver_1 = require("./api/FakePairingDriver");
5
5
  Object.defineProperty(exports, "FakePairingDriver", { enumerable: true, get: function () { return FakePairingDriver_1.FakePairingDriver; } });
6
6
  var FakePeer_1 = require("./api/FakePeer");
7
7
  Object.defineProperty(exports, "FakePeer", { enumerable: true, get: function () { return FakePeer_1.FakePeer; } });
8
8
  var FakeWaServer_1 = require("./api/FakeWaServer");
9
+ Object.defineProperty(exports, "FakeServerSession", { enumerable: true, get: function () { return FakeWaServer_1.FakeServerSession; } });
9
10
  Object.defineProperty(exports, "FakeWaServer", { enumerable: true, get: function () { return FakeWaServer_1.FakeWaServer; } });
10
11
  var Scenario_1 = require("./api/Scenario");
11
12
  Object.defineProperty(exports, "IqExpectation", { enumerable: true, get: function () { return Scenario_1.IqExpectation; } });
@@ -2,7 +2,6 @@
2
2
  import { type FakeNoiseRootCa } from '../protocol/auth/cert-chain';
3
3
  import { type ParsedClientPayload } from '../protocol/auth/client-payload-validate';
4
4
  import { buildSuccessNode } from '../protocol/auth/success-node';
5
- import { type WaFakeIqRouter } from '../protocol/iq/router';
6
5
  import { type BinaryNode } from '../transport/codec';
7
6
  import { type SignalKeyPair } from '../transport/crypto';
8
7
  import type { WaFakeConnection } from './WaFakeConnection';
@@ -10,7 +9,12 @@ export interface WaFakeConnectionPipelineConfig {
10
9
  readonly connection: WaFakeConnection;
11
10
  readonly rootCa: FakeNoiseRootCa;
12
11
  readonly serverStaticKeyPair: SignalKeyPair;
13
- readonly iqRouter: WaFakeIqRouter;
12
+ /**
13
+ * Routes an inbound IQ against the session this connection is bound to.
14
+ * The server resolves the session per connection, so the pipeline stays
15
+ * unaware of session identity.
16
+ */
17
+ readonly routeIq: (iq: BinaryNode) => Promise<BinaryNode | null>;
14
18
  readonly successNodeAttributes?: Parameters<typeof buildSuccessNode>[0];
15
19
  }
16
20
  export interface WaFakeConnectionPipelineEvents {
@@ -33,9 +37,12 @@ export declare class WaFakeConnectionPipeline {
33
37
  private events;
34
38
  private state;
35
39
  private chain;
40
+ private authenticatedPayload;
36
41
  constructor(config: WaFakeConnectionPipelineConfig);
37
42
  setEvents(events: WaFakeConnectionPipelineEvents): void;
38
43
  isAuthenticated(): boolean;
44
+ /** Parsed ClientPayload of the authenticated connection; null before auth. */
45
+ get clientPayload(): ParsedClientPayload | null;
39
46
  sendStanza(node: BinaryNode): Promise<void>;
40
47
  private onPrologue;
41
48
  private scheduleFrame;
@@ -45,6 +52,7 @@ export declare class WaFakeConnectionPipeline {
45
52
  /** Server-side IK path used when the client already cached our static key. */
46
53
  private handleIkClientHello;
47
54
  private handleClientFinish;
55
+ private completeAuthentication;
48
56
  private handleAuthenticatedFrame;
49
57
  private fail;
50
58
  }
@@ -19,6 +19,7 @@ class WaFakeConnectionPipeline {
19
19
  this.events = {};
20
20
  this.state = { kind: 'awaiting_prologue' };
21
21
  this.chain = Promise.resolve();
22
+ this.authenticatedPayload = null;
22
23
  this.config = config;
23
24
  this.frameSocket = new WaFakeFrameSocket_1.WaFakeFrameSocket(config.connection);
24
25
  this.frameSocket.setHandlers({
@@ -37,6 +38,10 @@ class WaFakeConnectionPipeline {
37
38
  isAuthenticated() {
38
39
  return this.state.kind === 'authenticated';
39
40
  }
41
+ /** Parsed ClientPayload of the authenticated connection; null before auth. */
42
+ get clientPayload() {
43
+ return this.authenticatedPayload;
44
+ }
40
45
  async sendStanza(node) {
41
46
  if (this.state.kind !== 'authenticated') {
42
47
  throw new Error(`cannot send stanza while pipeline is in state "${this.state.kind}"`);
@@ -156,14 +161,7 @@ class WaFakeConnectionPipeline {
156
161
  }
157
162
  }).finish();
158
163
  this.frameSocket.sendFrame(serverHello);
159
- const keys = handshake.finish();
160
- const transport = new WaFakeTransport_1.WaFakeTransport({
161
- recvKey: keys.recvKey,
162
- sendKey: keys.sendKey
163
- });
164
- this.state = { kind: 'authenticated', transport };
165
- await this.sendStanza((0, success_node_1.buildSuccessNode)(this.config.successNodeAttributes));
166
- this.events.onAuthenticated?.({ clientPayload, clientStaticKey });
164
+ await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
167
165
  }
168
166
  async handleClientFinish(frame, handshake, serverEphemeralKeyPair) {
169
167
  const parsed = protos_1.proto.HandshakeMessage.decode(frame);
@@ -175,13 +173,27 @@ class WaFakeConnectionPipeline {
175
173
  handshake.mixIntoKey(await crypto_1.X25519.scalarMult(serverEphemeralKeyPair.privKey, clientStaticKey));
176
174
  const clientPayloadBytes = handshake.decrypt(clientFinish.payload);
177
175
  const clientPayload = (0, client_payload_validate_1.parseClientPayload)(clientPayloadBytes);
176
+ await this.completeAuthentication(handshake, clientPayload, clientStaticKey);
177
+ }
178
+ async completeAuthentication(handshake, clientPayload, clientStaticKey) {
178
179
  const keys = handshake.finish();
179
180
  const transport = new WaFakeTransport_1.WaFakeTransport({
180
181
  recvKey: keys.recvKey,
181
182
  sendKey: keys.sendKey
182
183
  });
183
184
  this.state = { kind: 'authenticated', transport };
185
+ this.authenticatedPayload = clientPayload;
184
186
  await this.sendStanza((0, success_node_1.buildSuccessNode)(this.config.successNodeAttributes));
187
+ if (clientPayload.kind === 'login') {
188
+ // Real WA drains offline messages after login and closes the
189
+ // drain with this bulletin. Baileys-family clients buffer their
190
+ // events until it arrives, so send it eagerly with count=0.
191
+ await this.sendStanza({
192
+ tag: 'ib',
193
+ attrs: { from: 's.whatsapp.net' },
194
+ content: [{ tag: 'offline', attrs: { count: '0' } }]
195
+ });
196
+ }
185
197
  this.events.onAuthenticated?.({ clientPayload, clientStaticKey });
186
198
  }
187
199
  async handleAuthenticatedFrame(frame, transport) {
@@ -189,7 +201,7 @@ class WaFakeConnectionPipeline {
189
201
  const node = await (0, codec_1.decodeBinaryNodeStanza)(stanzaBytes);
190
202
  this.events.onStanza?.(node);
191
203
  if (node.tag === 'iq') {
192
- const response = await this.config.iqRouter.route(node);
204
+ const response = await this.config.routeIq(node);
193
205
  if (response !== null) {
194
206
  await this.sendStanza(response);
195
207
  }
@@ -12,6 +12,10 @@ export interface BuildFakeCertChainInput {
12
12
  readonly leafKey: Uint8Array;
13
13
  readonly intermediateSerial?: number;
14
14
  readonly leafSerial?: number;
15
+ /** Unix seconds. Defaults to 24h before build time. */
16
+ readonly notBefore?: number;
17
+ /** Unix seconds. Defaults to 10 years after build time. */
18
+ readonly notAfter?: number;
15
19
  }
16
20
  export declare function generateFakeNoiseRootCa(): Promise<FakeNoiseRootCa>;
17
21
  export declare function buildFakeCertChain(input: BuildFakeCertChainInput): Promise<FakeCertChainResult>;
@@ -5,6 +5,8 @@ exports.generateFakeNoiseRootCa = generateFakeNoiseRootCa;
5
5
  exports.buildFakeCertChain = buildFakeCertChain;
6
6
  const crypto_1 = require("../../transport/crypto");
7
7
  const protos_1 = require("../../transport/protos");
8
+ const WA_CERT_NOT_BEFORE_BACKDATE_SECONDS = 86400;
9
+ const WA_CERT_NOT_AFTER_LIFETIME_SECONDS = 315360000;
8
10
  async function generateFakeNoiseRootCa() {
9
11
  const keyPair = await crypto_1.X25519.generateKeyPair();
10
12
  return {
@@ -16,17 +18,26 @@ async function generateFakeNoiseRootCa() {
16
18
  async function buildFakeCertChain(input) {
17
19
  const intermediateSerial = input.intermediateSerial ?? 1;
18
20
  const leafSerial = input.leafSerial ?? 2;
21
+ // Strict clients (e.g. whatsmeow) enforce the validity window; leaving
22
+ // notBefore/notAfter unset decodes as 0 (1970) and reads as expired.
23
+ const nowSeconds = Math.floor(Date.now() / 1000);
24
+ const notBefore = input.notBefore ?? nowSeconds - WA_CERT_NOT_BEFORE_BACKDATE_SECONDS;
25
+ const notAfter = input.notAfter ?? nowSeconds + WA_CERT_NOT_AFTER_LIFETIME_SECONDS;
19
26
  const intermediateKeyPair = await crypto_1.X25519.generateKeyPair();
20
27
  const intermediateDetails = protos_1.proto.CertChain.NoiseCertificate.Details.encode({
21
28
  serial: intermediateSerial,
22
29
  issuerSerial: input.root.serial,
23
- key: intermediateKeyPair.pubKey
30
+ key: intermediateKeyPair.pubKey,
31
+ notBefore,
32
+ notAfter
24
33
  }).finish();
25
34
  const intermediateSignature = await (0, crypto_1.xeddsaSign)(input.root.privateKey, intermediateDetails);
26
35
  const leafDetails = protos_1.proto.CertChain.NoiseCertificate.Details.encode({
27
36
  serial: leafSerial,
28
37
  issuerSerial: intermediateSerial,
29
- key: input.leafKey
38
+ key: input.leafKey,
39
+ notBefore,
40
+ notAfter
30
41
  }).finish();
31
42
  const leafSignature = await (0, crypto_1.xeddsaSign)(intermediateKeyPair.privKey, leafDetails);
32
43
  const encoded = protos_1.proto.CertChain.encode({
@@ -7,7 +7,13 @@ export interface WaFakeIqMatcher {
7
7
  readonly xmlns?: string;
8
8
  readonly childTag?: string;
9
9
  }
10
- export type WaFakeIqResponder = (iq: BinaryNode) => BinaryNode | Promise<BinaryNode>;
10
+ /**
11
+ * Returns the response stanza, or `null` to fall through: the router keeps
12
+ * scanning lower-priority handlers as if this one had not matched. Lets a
13
+ * high-priority handler observe an IQ (capture, log, assert) while the
14
+ * default handler still answers it.
15
+ */
16
+ export type WaFakeIqResponder = (iq: BinaryNode) => BinaryNode | null | Promise<BinaryNode | null>;
11
17
  export interface WaFakeIqHandler {
12
18
  readonly matcher: WaFakeIqMatcher;
13
19
  readonly respond: WaFakeIqResponder;
@@ -30,12 +30,18 @@ class WaFakeIqRouter {
30
30
  }
31
31
  for (const handler of this.highPriorityHandlers) {
32
32
  if (matches(iq, handler.matcher)) {
33
- return handler.respond(iq);
33
+ const response = await handler.respond(iq);
34
+ if (response !== null) {
35
+ return response;
36
+ }
34
37
  }
35
38
  }
36
39
  for (const handler of this.handlers) {
37
40
  if (matches(iq, handler.matcher)) {
38
- return handler.respond(iq);
41
+ const response = await handler.respond(iq);
42
+ if (response !== null) {
43
+ return response;
44
+ }
39
45
  }
40
46
  }
41
47
  this.events.onUnhandled?.(iq);
@@ -4,5 +4,5 @@
4
4
  * Re-exports bit-exact byte helpers from zapo-js so Layer 2/3 of the fake
5
5
  * server can use them without importing zapo-js directly.
6
6
  */
7
- export { bytesToBase64, bytesToBase64UrlSafe, bytesToHex, decodeBase64Url } from 'zapo-js/util';
7
+ export { bytesToBase64, bytesToBase64UrlSafe, bytesToHex, decodeBase64Url, toError } from 'zapo-js/util';
8
8
  export declare const TEXT_ENCODER: import("util").TextEncoder;
@@ -6,10 +6,11 @@
6
6
  * server can use them without importing zapo-js directly.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.TEXT_ENCODER = exports.decodeBase64Url = exports.bytesToHex = exports.bytesToBase64UrlSafe = exports.bytesToBase64 = void 0;
9
+ exports.TEXT_ENCODER = exports.toError = exports.decodeBase64Url = exports.bytesToHex = exports.bytesToBase64UrlSafe = exports.bytesToBase64 = void 0;
10
10
  var util_1 = require("zapo-js/util");
11
11
  Object.defineProperty(exports, "bytesToBase64", { enumerable: true, get: function () { return util_1.bytesToBase64; } });
12
12
  Object.defineProperty(exports, "bytesToBase64UrlSafe", { enumerable: true, get: function () { return util_1.bytesToBase64UrlSafe; } });
13
13
  Object.defineProperty(exports, "bytesToHex", { enumerable: true, get: function () { return util_1.bytesToHex; } });
14
14
  Object.defineProperty(exports, "decodeBase64Url", { enumerable: true, get: function () { return util_1.decodeBase64Url; } });
15
+ Object.defineProperty(exports, "toError", { enumerable: true, get: function () { return util_1.toError; } });
15
16
  exports.TEXT_ENCODER = new TextEncoder();
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "@zapo-js/fake-server",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Fake WhatsApp Web server used to test zapo-js",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/vinikjkkj/zapo.git",
10
+ "directory": "packages/fake-server"
11
+ },
7
12
  "main": "dist/index.js",
8
13
  "module": "dist/esm/index.js",
9
14
  "types": "dist/index.d.ts",