@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/README.md +93 -5
- package/dist/api/FakePeer.js +7 -1
- package/dist/api/FakeServerSession.d.ts +67 -0
- package/dist/api/FakeServerSession.js +293 -0
- package/dist/api/FakeWaServer.d.ts +74 -38
- package/dist/api/FakeWaServer.js +125 -276
- package/dist/api/iq-handlers.d.ts +1 -0
- package/dist/api/iq-handlers.js +23 -0
- package/dist/bin.js +69 -21
- package/dist/esm/api/FakePeer.js +7 -1
- package/dist/esm/api/FakeServerSession.js +287 -0
- package/dist/esm/api/FakeWaServer.js +123 -275
- package/dist/esm/api/iq-handlers.js +23 -0
- package/dist/esm/bin.js +67 -19
- package/dist/esm/index.js +1 -1
- package/dist/esm/infra/WaFakeConnectionPipeline.js +21 -9
- package/dist/esm/protocol/auth/cert-chain.js +13 -2
- package/dist/esm/protocol/iq/router.js +8 -2
- package/dist/esm/transport/util.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/infra/WaFakeConnectionPipeline.d.ts +10 -2
- package/dist/infra/WaFakeConnectionPipeline.js +21 -9
- package/dist/protocol/auth/cert-chain.d.ts +4 -0
- package/dist/protocol/auth/cert-chain.js +13 -2
- package/dist/protocol/iq/router.d.ts +7 -1
- package/dist/protocol/iq/router.js +8 -2
- package/dist/transport/util.d.ts +1 -1
- package/dist/transport/util.js +2 -1
- package/package.json +6 -1
package/dist/esm/bin.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Standalone CLI for `@zapo-js/fake-server`. */
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
3
4
|
import { FakeWaServer } from './api/FakeWaServer.js';
|
|
5
|
+
import { parsePairingQrString } from './protocol/auth/pair-device.js';
|
|
6
|
+
import { bytesToHex, toError } from './transport/util.js';
|
|
4
7
|
function parseArgs(argv) {
|
|
5
8
|
let host = '127.0.0.1';
|
|
6
9
|
let port = 0;
|
|
7
10
|
let path = '/ws/chat';
|
|
8
11
|
const peerJids = [];
|
|
9
12
|
const groupSpecs = [];
|
|
13
|
+
let pairJid = null;
|
|
10
14
|
let log = false;
|
|
11
15
|
let quiet = false;
|
|
12
16
|
let json = false;
|
|
@@ -44,6 +48,9 @@ function parseArgs(argv) {
|
|
|
44
48
|
case '--group':
|
|
45
49
|
groupSpecs.push(next());
|
|
46
50
|
break;
|
|
51
|
+
case '--pair':
|
|
52
|
+
pairJid = next();
|
|
53
|
+
break;
|
|
47
54
|
case '--log':
|
|
48
55
|
log = true;
|
|
49
56
|
break;
|
|
@@ -57,7 +64,7 @@ function parseArgs(argv) {
|
|
|
57
64
|
throw new Error(`unknown flag: ${arg}`);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
|
-
return { host, port, path, peerJids, groupSpecs, log, quiet, json, help };
|
|
67
|
+
return { host, port, path, peerJids, groupSpecs, pairJid, log, quiet, json, help };
|
|
61
68
|
}
|
|
62
69
|
function printHelp() {
|
|
63
70
|
process.stdout.write(`@zapo-js/fake-server – standalone fake WhatsApp Web server
|
|
@@ -73,6 +80,12 @@ FLAGS
|
|
|
73
80
|
--group <spec> Pre-create a fake group; spec format
|
|
74
81
|
<group-jid>=<peer-jid>,<peer-jid>,...
|
|
75
82
|
All peers must already be passed via --peer.
|
|
83
|
+
--pair <jid> Drive QR pairing for unregistered clients: the server
|
|
84
|
+
sends pair-device refs, then prompts on stdin for the
|
|
85
|
+
QR payload the client displays
|
|
86
|
+
(ref,noisePubB64,identityPubB64,advSecretB64,platform)
|
|
87
|
+
and answers pair-success assigning <jid> as the device
|
|
88
|
+
jid. Without this flag the CLI is login-only.
|
|
76
89
|
--log Print every captured inbound stanza
|
|
77
90
|
--quiet Suppress the startup info banner
|
|
78
91
|
--json Print connection info as JSON
|
|
@@ -103,7 +116,7 @@ async function main() {
|
|
|
103
116
|
args = parseArgs(process.argv.slice(2));
|
|
104
117
|
}
|
|
105
118
|
catch (error) {
|
|
106
|
-
process.stderr.write(`error: ${error
|
|
119
|
+
process.stderr.write(`error: ${toError(error).message}\n`);
|
|
107
120
|
process.stderr.write('run with --help to see usage.\n');
|
|
108
121
|
process.exit(2);
|
|
109
122
|
}
|
|
@@ -125,11 +138,39 @@ async function main() {
|
|
|
125
138
|
port: args.port,
|
|
126
139
|
path: args.path
|
|
127
140
|
});
|
|
141
|
+
if (args.pairJid !== null) {
|
|
142
|
+
const pairJid = args.pairJid;
|
|
143
|
+
let pairingStarted = false;
|
|
144
|
+
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
145
|
+
if (pairingStarted)
|
|
146
|
+
return;
|
|
147
|
+
if (pipeline.clientPayload?.kind !== 'registration')
|
|
148
|
+
return;
|
|
149
|
+
pairingStarted = true;
|
|
150
|
+
try {
|
|
151
|
+
await server.runPairing(pipeline, { deviceJid: pairJid }, async () => {
|
|
152
|
+
const qr = await readStdinLine('[fake-server] paste the QR payload displayed by the client and press Enter:\n');
|
|
153
|
+
const parsed = parsePairingQrString(qr.trim());
|
|
154
|
+
return {
|
|
155
|
+
advSecretKey: parsed.advSecretKey,
|
|
156
|
+
identityPublicKey: parsed.identityPublicKey
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
process.stdout.write(`[fake-server] pair-success sent for ${pairJid}; the client will now reconnect and log in\n`);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
pairingStarted = false;
|
|
163
|
+
process.stderr.write(`pairing failed: ${toError(error).message}\n`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
128
167
|
let setupComplete = false;
|
|
129
168
|
const setupPromise = new Promise((resolve, reject) => {
|
|
130
169
|
server.onAuthenticatedPipeline(async (pipeline) => {
|
|
131
170
|
if (setupComplete)
|
|
132
171
|
return;
|
|
172
|
+
if (pipeline.clientPayload?.kind !== 'login')
|
|
173
|
+
return;
|
|
133
174
|
setupComplete = true;
|
|
134
175
|
try {
|
|
135
176
|
const peers = new Map();
|
|
@@ -155,7 +196,7 @@ async function main() {
|
|
|
155
196
|
resolve();
|
|
156
197
|
}
|
|
157
198
|
catch (error) {
|
|
158
|
-
reject(
|
|
199
|
+
reject(toError(error));
|
|
159
200
|
}
|
|
160
201
|
});
|
|
161
202
|
});
|
|
@@ -164,12 +205,11 @@ async function main() {
|
|
|
164
205
|
process.exitCode = 1;
|
|
165
206
|
});
|
|
166
207
|
if (args.log) {
|
|
167
|
-
server
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
208
|
+
// onCapturedStanza taps the server's own capture stream; overriding
|
|
209
|
+
// pipeline.setEvents here would replace the server's handlers and
|
|
210
|
+
// silently break --peer setup and stanza capture.
|
|
211
|
+
server.onCapturedStanza((node) => {
|
|
212
|
+
process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
|
|
173
213
|
});
|
|
174
214
|
}
|
|
175
215
|
if (!args.quiet) {
|
|
@@ -185,7 +225,8 @@ async function main() {
|
|
|
185
225
|
publicKeyHex: bytesToHex(noiseRoot.publicKey)
|
|
186
226
|
},
|
|
187
227
|
peers: args.peerJids,
|
|
188
|
-
groups: parsedGroups
|
|
228
|
+
groups: parsedGroups,
|
|
229
|
+
pair: args.pairJid
|
|
189
230
|
}, null, 2) + '\n');
|
|
190
231
|
}
|
|
191
232
|
else {
|
|
@@ -201,6 +242,7 @@ async function main() {
|
|
|
201
242
|
`│ noise root ca serial=${noiseRoot.serial} pub=${bytesToHex(noiseRoot.publicKey).slice(0, 32)}…`,
|
|
202
243
|
`│ peers ${args.peerJids.length === 0 ? '(none)' : args.peerJids.join(', ')}`,
|
|
203
244
|
`│ groups ${parsedGroups.length === 0 ? '(none)' : parsedGroups.map((g) => g.groupJid).join(', ')}`,
|
|
245
|
+
`│ pair mode ${args.pairJid ?? '(off – login only)'}`,
|
|
204
246
|
'├─────────────────────────────────────────────────────────────',
|
|
205
247
|
'│ Wire your WaClient with:',
|
|
206
248
|
'│ chatSocketUrls: [server.url]',
|
|
@@ -224,7 +266,7 @@ async function main() {
|
|
|
224
266
|
process.exit(0);
|
|
225
267
|
}
|
|
226
268
|
catch (error) {
|
|
227
|
-
process.stderr.write(`error during shutdown: ${error
|
|
269
|
+
process.stderr.write(`error during shutdown: ${toError(error).message}\n`);
|
|
228
270
|
process.exit(1);
|
|
229
271
|
}
|
|
230
272
|
};
|
|
@@ -232,15 +274,21 @@ async function main() {
|
|
|
232
274
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
233
275
|
await new Promise(() => undefined);
|
|
234
276
|
}
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
277
|
+
function readStdinLine(prompt) {
|
|
278
|
+
process.stdout.write(prompt);
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const rl = createInterface({ input: process.stdin });
|
|
281
|
+
// resolve before close(): rl.close() emits 'close' synchronously,
|
|
282
|
+
// which would otherwise reject the still-pending promise first.
|
|
283
|
+
rl.once('line', (line) => {
|
|
284
|
+
resolve(line);
|
|
285
|
+
rl.close();
|
|
286
|
+
});
|
|
287
|
+
rl.once('close', () => reject(new Error('stdin closed before a QR payload was provided')));
|
|
288
|
+
});
|
|
242
289
|
}
|
|
243
290
|
main().catch((error) => {
|
|
244
|
-
|
|
291
|
+
const normalized = toError(error);
|
|
292
|
+
process.stderr.write(`fatal: ${normalized.stack ?? normalized.message}\n`);
|
|
245
293
|
process.exit(1);
|
|
246
294
|
});
|
package/dist/esm/index.js
CHANGED
|
@@ -1,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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/transport/util.d.ts
CHANGED
|
@@ -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;
|
package/dist/transport/util.js
CHANGED
|
@@ -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.
|
|
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",
|