@zapo-js/fake-server 1.1.0 → 1.3.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 +89 -30
- package/dist/api/FakeMobilePrimary.d.ts +55 -0
- package/dist/api/FakeMobilePrimary.js +106 -0
- package/dist/api/FakePairingDriver.js +1 -3
- package/dist/api/FakeServerSession.d.ts +35 -2
- package/dist/api/FakeServerSession.js +65 -3
- package/dist/api/FakeWaServer.d.ts +90 -1
- package/dist/api/FakeWaServer.js +231 -5
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +19 -1
- package/dist/api/iq-handlers.js +96 -1
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakeServerSession.js +66 -4
- package/dist/esm/api/FakeWaServer.js +231 -5
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +96 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +17 -5
- package/dist/esm/infra/WaFakeTcpServer.js +69 -0
- package/dist/esm/infra/WaFakeWsServer.js +2 -1
- package/dist/esm/infra/socket-adapters.js +79 -0
- package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
- package/dist/esm/protocol/auth/pair-device.js +16 -1
- package/dist/esm/protocol/iq/companion-host.js +104 -0
- package/dist/esm/protocol/iq/link-code.js +90 -0
- package/dist/esm/protocol/iq/router.js +3 -3
- package/dist/esm/protocol/push/mobile-notification.js +97 -0
- package/dist/esm/state/fake-companion-host.js +158 -0
- package/dist/esm/transport/auth.js +7 -0
- package/dist/esm/transport/crypto.js +1 -1
- package/dist/esm/transport/util.js +1 -0
- package/dist/index.d.ts +23 -2
- package/dist/index.js +28 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +1 -0
- package/dist/infra/WaFakeConnectionPipeline.js +17 -5
- package/dist/infra/WaFakeTcpServer.d.ts +29 -0
- package/dist/infra/WaFakeTcpServer.js +73 -0
- package/dist/infra/WaFakeWsServer.js +2 -1
- package/dist/infra/socket-adapters.d.ts +18 -0
- package/dist/infra/socket-adapters.js +83 -0
- package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
- package/dist/protocol/auth/client-payload-validate.js +77 -1
- package/dist/protocol/auth/pair-device.d.ts +15 -1
- package/dist/protocol/auth/pair-device.js +16 -0
- package/dist/protocol/iq/companion-host.d.ts +52 -0
- package/dist/protocol/iq/companion-host.js +109 -0
- package/dist/protocol/iq/link-code.d.ts +46 -0
- package/dist/protocol/iq/link-code.js +95 -0
- package/dist/protocol/iq/router.d.ts +15 -2
- package/dist/protocol/iq/router.js +3 -3
- package/dist/protocol/push/mobile-notification.d.ts +58 -0
- package/dist/protocol/push/mobile-notification.js +102 -0
- package/dist/state/fake-companion-host.d.ts +73 -0
- package/dist/state/fake-companion-host.js +163 -0
- package/dist/transport/auth.d.ts +8 -0
- package/dist/transport/auth.js +8 -0
- package/dist/transport/crypto.d.ts +1 -1
- package/dist/transport/crypto.js +2 -1
- package/dist/transport/util.d.ts +1 -0
- package/dist/transport/util.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-code (pairing-code) relay stanzas.
|
|
3
|
+
*
|
|
4
|
+
* The handshake runs between two clients with the server in the middle: the
|
|
5
|
+
* companion asks for a code (`companion_hello`), the primary answers with its
|
|
6
|
+
* wrapped ephemeral (`primary_hello`), and the companion closes with the key
|
|
7
|
+
* bundle (`companion_finish`). Each stage arrives as an IQ from one client and
|
|
8
|
+
* leaves as a notification to the other; the server only routes and mints the
|
|
9
|
+
* ref that ties the three together.
|
|
10
|
+
*/
|
|
11
|
+
import { TEXT_DECODER } from '../../transport/util.js';
|
|
12
|
+
const RELAYED_STAGES = new Set([
|
|
13
|
+
'companion_hello',
|
|
14
|
+
'primary_hello',
|
|
15
|
+
'companion_finish'
|
|
16
|
+
]);
|
|
17
|
+
/**
|
|
18
|
+
* Parses a `<link_code_companion_reg>` IQ. Returns `null` for a stage this
|
|
19
|
+
* server does not relay (for example `get_country_code`), letting the router
|
|
20
|
+
* fall through to another handler.
|
|
21
|
+
*/
|
|
22
|
+
export function parseLinkCodeStanza(iq) {
|
|
23
|
+
const node = findChild(iq, 'link_code_companion_reg');
|
|
24
|
+
const stage = node?.attrs.stage;
|
|
25
|
+
if (!node || typeof stage !== 'string' || !RELAYED_STAGES.has(stage)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const jid = node.attrs.jid;
|
|
29
|
+
return {
|
|
30
|
+
stage: stage,
|
|
31
|
+
phoneJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
|
|
32
|
+
ref: readText(findChild(node, 'link_code_pairing_ref')),
|
|
33
|
+
children: Array.isArray(node.content) ? node.content : []
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds the `companion_hello` IQ result carrying the ref the server minted.
|
|
38
|
+
* The companion keys its whole pairing session off it.
|
|
39
|
+
*/
|
|
40
|
+
export function buildCompanionHelloResultContent(ref) {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
tag: 'link_code_companion_reg',
|
|
44
|
+
attrs: { stage: 'companion_hello' },
|
|
45
|
+
content: [{ tag: 'link_code_pairing_ref', attrs: {}, content: ref }]
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wraps a relayed stage as the notification the receiving client handles. The
|
|
51
|
+
* ref is (re)stamped so the receiver can match it against its pairing session,
|
|
52
|
+
* replacing any copy carried by the source stanza.
|
|
53
|
+
*/
|
|
54
|
+
export function buildLinkCodeNotification(input) {
|
|
55
|
+
const children = input.children.filter((child) => child.tag !== 'link_code_pairing_ref');
|
|
56
|
+
return {
|
|
57
|
+
tag: 'notification',
|
|
58
|
+
attrs: {
|
|
59
|
+
id: input.id ?? `link-code-${Math.random().toString(36).slice(2, 10)}`,
|
|
60
|
+
from: 's.whatsapp.net',
|
|
61
|
+
type: 'link_code_companion_reg',
|
|
62
|
+
...(input.to !== undefined ? { to: input.to } : {})
|
|
63
|
+
},
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
tag: 'link_code_companion_reg',
|
|
67
|
+
attrs: { stage: input.stage },
|
|
68
|
+
content: [
|
|
69
|
+
{ tag: 'link_code_pairing_ref', attrs: {}, content: input.ref },
|
|
70
|
+
...children
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function findChild(parent, tag) {
|
|
77
|
+
if (!Array.isArray(parent.content)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return parent.content.find((child) => child.tag === tag) ?? null;
|
|
81
|
+
}
|
|
82
|
+
function readText(node) {
|
|
83
|
+
if (!node) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof node.content === 'string') {
|
|
87
|
+
return node.content;
|
|
88
|
+
}
|
|
89
|
+
return node.content instanceof Uint8Array ? TEXT_DECODER.decode(node.content) : null;
|
|
90
|
+
}
|
|
@@ -19,13 +19,13 @@ export class WaFakeIqRouter {
|
|
|
19
19
|
setEvents(events) {
|
|
20
20
|
this.events = events;
|
|
21
21
|
}
|
|
22
|
-
async route(iq) {
|
|
22
|
+
async route(iq, context) {
|
|
23
23
|
if (iq.tag !== 'iq') {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
for (const handler of this.highPriorityHandlers) {
|
|
27
27
|
if (matches(iq, handler.matcher)) {
|
|
28
|
-
const response = await handler.respond(iq);
|
|
28
|
+
const response = await handler.respond(iq, context);
|
|
29
29
|
if (response !== null) {
|
|
30
30
|
return response;
|
|
31
31
|
}
|
|
@@ -33,7 +33,7 @@ export class WaFakeIqRouter {
|
|
|
33
33
|
}
|
|
34
34
|
for (const handler of this.handlers) {
|
|
35
35
|
if (matches(iq, handler.matcher)) {
|
|
36
|
-
const response = await handler.respond(iq);
|
|
36
|
+
const response = await handler.respond(iq, context);
|
|
37
37
|
if (response !== null) {
|
|
38
38
|
return response;
|
|
39
39
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push notifications only a phone receives.
|
|
3
|
+
*
|
|
4
|
+
* `account_sync` carries the account's live device set: the primary keys its
|
|
5
|
+
* companion bookkeeping off it, allocating a fresh key index for the next link
|
|
6
|
+
* and dropping companions the server no longer lists. The registration
|
|
7
|
+
* notifications carry the login code and the takeover warning that fire while
|
|
8
|
+
* a number is being moved to another phone.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Builds `<notification type="account_sync">` with the account's `<devices>`
|
|
12
|
+
* list. Every device the account still owns must be present: the primary reads
|
|
13
|
+
* this as authoritative and prunes anything missing from it.
|
|
14
|
+
*/
|
|
15
|
+
export function buildAccountSyncDevicesNotification(input) {
|
|
16
|
+
return {
|
|
17
|
+
tag: 'notification',
|
|
18
|
+
attrs: {
|
|
19
|
+
id: input.id ?? `account-sync-${Math.random().toString(36).slice(2, 10)}`,
|
|
20
|
+
from: input.from ?? 's.whatsapp.net',
|
|
21
|
+
type: 'account_sync',
|
|
22
|
+
t: String(input.timestampSeconds ?? Math.floor(Date.now() / 1000))
|
|
23
|
+
},
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
tag: 'devices',
|
|
27
|
+
attrs: {},
|
|
28
|
+
content: input.devices.map((device) => ({
|
|
29
|
+
tag: 'device',
|
|
30
|
+
attrs: {
|
|
31
|
+
jid: device.deviceJid,
|
|
32
|
+
...(device.keyIndex !== undefined
|
|
33
|
+
? { 'key-index': String(device.keyIndex) }
|
|
34
|
+
: {}),
|
|
35
|
+
...(device.deviceLid !== undefined ? { lid: device.deviceLid } : {})
|
|
36
|
+
}
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the `<notification type="registration">` carrying a login code, which
|
|
44
|
+
* the client surfaces as `mobile_registration_code`.
|
|
45
|
+
*/
|
|
46
|
+
export function buildRegistrationCodeNotification(input) {
|
|
47
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
48
|
+
return {
|
|
49
|
+
tag: 'notification',
|
|
50
|
+
attrs: {
|
|
51
|
+
id: input.id ?? `registration-${Math.random().toString(36).slice(2, 10)}`,
|
|
52
|
+
from: input.from ?? 's.whatsapp.net',
|
|
53
|
+
type: 'registration'
|
|
54
|
+
},
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
tag: 'wa_old_registration',
|
|
58
|
+
attrs: {
|
|
59
|
+
code: input.code,
|
|
60
|
+
expiry_t: String(input.expirySeconds ?? nowSeconds + 300),
|
|
61
|
+
device_id: input.fromDeviceId
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Builds the `<notification type="registration">` warning that the number is
|
|
69
|
+
* being registered on another phone, surfaced as
|
|
70
|
+
* `mobile_account_takeover_notice`.
|
|
71
|
+
*/
|
|
72
|
+
export function buildAccountTakeoverNotice(input) {
|
|
73
|
+
return {
|
|
74
|
+
tag: 'notification',
|
|
75
|
+
attrs: {
|
|
76
|
+
id: input.id ?? `takeover-${Math.random().toString(36).slice(2, 10)}`,
|
|
77
|
+
from: input.from ?? 's.whatsapp.net',
|
|
78
|
+
type: 'registration'
|
|
79
|
+
},
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
tag: 'device_logout',
|
|
83
|
+
attrs: {
|
|
84
|
+
id: input.serverToken,
|
|
85
|
+
t: String(input.attemptTimestampSeconds ?? Math.floor(Date.now() / 1000)),
|
|
86
|
+
...(input.newDeviceName !== undefined ? { device: input.newDeviceName } : {}),
|
|
87
|
+
...(input.newDevicePlatform !== undefined
|
|
88
|
+
? { new_device_platform: input.newDevicePlatform }
|
|
89
|
+
: {}),
|
|
90
|
+
...(input.newDeviceAppVersion !== undefined
|
|
91
|
+
? { new_device_app_version: input.newDeviceAppVersion }
|
|
92
|
+
: {})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Per-session account state for a mobile primary hosting companion devices. */
|
|
2
|
+
import { proto } from '../transport/protos.js';
|
|
3
|
+
export class FakeCompanionHostState {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.primaryIdentity = null;
|
|
6
|
+
this.companions = new Map();
|
|
7
|
+
this.reservedDeviceIds = new Set();
|
|
8
|
+
this.keyIndexList = null;
|
|
9
|
+
this.linkListeners = new Set();
|
|
10
|
+
this.revokeListeners = new Set();
|
|
11
|
+
}
|
|
12
|
+
/** The mobile primary bound to this session, or `null` for a web session. */
|
|
13
|
+
get primary() {
|
|
14
|
+
return this.primaryIdentity;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Records the primary at login. The first account to bind owns the session
|
|
18
|
+
* for good: a later login under a different number would otherwise mint
|
|
19
|
+
* device jids for one account while the companions of another are still
|
|
20
|
+
* tracked here.
|
|
21
|
+
*/
|
|
22
|
+
bindPrimary(identity) {
|
|
23
|
+
if (this.primaryIdentity) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.primaryIdentity = identity;
|
|
27
|
+
}
|
|
28
|
+
/** Companions linked from this account, in link order. */
|
|
29
|
+
linkedCompanions() {
|
|
30
|
+
return [...this.companions.values()];
|
|
31
|
+
}
|
|
32
|
+
/** Last key-index list the primary published, or `null` if it never did. */
|
|
33
|
+
publishedKeyIndexList() {
|
|
34
|
+
return this.keyIndexList;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reserves the next free device slot. The primary owns device 0, so
|
|
38
|
+
* companions start at 1 and a revoked slot is reused only once no live
|
|
39
|
+
* companion holds it. The slot is held from here until the link is either
|
|
40
|
+
* recorded or released, because a link spans an await and two overlapping
|
|
41
|
+
* pairings would otherwise be handed the same jid.
|
|
42
|
+
*/
|
|
43
|
+
allocateDeviceId() {
|
|
44
|
+
const taken = new Set([0, ...this.reservedDeviceIds]);
|
|
45
|
+
for (const companion of this.companions.values()) {
|
|
46
|
+
taken.add(companion.deviceId);
|
|
47
|
+
}
|
|
48
|
+
let candidate = 1;
|
|
49
|
+
while (taken.has(candidate)) {
|
|
50
|
+
candidate += 1;
|
|
51
|
+
}
|
|
52
|
+
this.reservedDeviceIds.add(candidate);
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
/** Frees a slot whose link never completed. */
|
|
56
|
+
releaseDeviceId(deviceId) {
|
|
57
|
+
this.reservedDeviceIds.delete(deviceId);
|
|
58
|
+
}
|
|
59
|
+
recordCompanion(companion) {
|
|
60
|
+
this.reservedDeviceIds.delete(companion.deviceId);
|
|
61
|
+
this.companions.set(companion.deviceJid, companion);
|
|
62
|
+
for (const listener of this.linkListeners) {
|
|
63
|
+
try {
|
|
64
|
+
listener(companion);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
void error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Drops companions and notifies listeners. Returns the jids actually
|
|
73
|
+
* removed, so a revoke of an untracked device is observable as a no-op.
|
|
74
|
+
*/
|
|
75
|
+
removeCompanions(deviceJids) {
|
|
76
|
+
const removed = [];
|
|
77
|
+
for (const deviceJid of deviceJids) {
|
|
78
|
+
if (this.companions.delete(deviceJid)) {
|
|
79
|
+
removed.push(deviceJid);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (removed.length > 0) {
|
|
83
|
+
for (const listener of this.revokeListeners) {
|
|
84
|
+
try {
|
|
85
|
+
listener(removed);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
void error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return removed;
|
|
93
|
+
}
|
|
94
|
+
removeAllCompanions() {
|
|
95
|
+
return this.removeCompanions([...this.companions.keys()]);
|
|
96
|
+
}
|
|
97
|
+
recordKeyIndexList(bytes, timestampSeconds) {
|
|
98
|
+
const decoded = decodeKeyIndexList(bytes);
|
|
99
|
+
this.keyIndexList = {
|
|
100
|
+
bytes,
|
|
101
|
+
timestampSeconds,
|
|
102
|
+
validIndexes: decoded.validIndexes,
|
|
103
|
+
currentIndex: decoded.currentIndex
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
onCompanionLinked(listener) {
|
|
107
|
+
this.linkListeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
this.linkListeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
onCompanionsRevoked(listener) {
|
|
113
|
+
this.revokeListeners.add(listener);
|
|
114
|
+
return () => {
|
|
115
|
+
this.revokeListeners.delete(listener);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads the ADV key index out of the `ADVSignedDeviceIdentityHMAC` the primary
|
|
121
|
+
* uploaded. Returns 0 when the payload does not decode, which keeps a
|
|
122
|
+
* malformed upload observable in the recorded companion instead of throwing
|
|
123
|
+
* inside the IQ handler.
|
|
124
|
+
*/
|
|
125
|
+
export function readCompanionKeyIndex(deviceIdentityBytes) {
|
|
126
|
+
try {
|
|
127
|
+
const hmacWrapper = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityBytes);
|
|
128
|
+
if (!hmacWrapper.details) {
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
const signed = proto.ADVSignedDeviceIdentity.decode(hmacWrapper.details);
|
|
132
|
+
if (!signed.details) {
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
return proto.ADVDeviceIdentity.decode(signed.details).keyIndex ?? 0;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
void error;
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function decodeKeyIndexList(bytes) {
|
|
143
|
+
try {
|
|
144
|
+
const signed = proto.ADVSignedKeyIndexList.decode(bytes);
|
|
145
|
+
if (!signed.details) {
|
|
146
|
+
return { validIndexes: [], currentIndex: 0 };
|
|
147
|
+
}
|
|
148
|
+
const details = proto.ADVKeyIndexList.decode(signed.details);
|
|
149
|
+
return {
|
|
150
|
+
validIndexes: details.validIndexes ?? [],
|
|
151
|
+
currentIndex: details.currentIndex ?? 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
void error;
|
|
156
|
+
return { validIndexes: [], currentIndex: 0 };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* Re-exports bit-exact cryptographic primitives from zapo-js. No protocol
|
|
5
5
|
* interpretation, so reusing them does not create a test-vs-impl tautology.
|
|
6
6
|
*/
|
|
7
|
-
export { aesCbcDecrypt, aesCbcEncrypt, aesGcmDecrypt, aesGcmEncrypt, Ed25519, hkdf, hkdfSplit, hmacSha256Sign, hmacSha512Sign, prependVersion, randomBytesAsync, readVersionedContent, sha256, sha512, toRawPubKey, toSerializedPubKey, X25519, xeddsaSign, xeddsaVerify } from 'zapo-js/crypto';
|
|
7
|
+
export { aesCbcDecrypt, aesCbcEncrypt, aesGcmDecrypt, aesGcmEncrypt, Ed25519, hkdf, hkdfSplit, hmacSha256Sign, hmacSha512Sign, prependVersion, randomBytesAsync, randomIntAsync, readVersionedContent, sha256, sha512, toRawPubKey, toSerializedPubKey, X25519, xeddsaSign, xeddsaVerify } from 'zapo-js/crypto';
|
|
8
8
|
export { WaMediaCrypto } from 'zapo-js/media';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
export { createFakeMobilePrimary, seedFakeMobilePrimary } from './api/FakeMobilePrimary';
|
|
2
|
+
export type { FakeMobilePrimary, SeedFakeMobilePrimaryOptions } from './api/FakeMobilePrimary';
|
|
1
3
|
export { FakePairingDriver } from './api/FakePairingDriver';
|
|
2
4
|
export type { CompanionPairingMaterial, FakePairingDriverDeps, FakePairingDriverOptions } from './api/FakePairingDriver';
|
|
3
5
|
export { FakePeer } from './api/FakePeer';
|
|
4
6
|
export type { CreateFakePeerOptions, ExpectGroupMessageOptions, ExpectMessageOptions, ReceivedMessage, SendMessageOptions } from './api/FakePeer';
|
|
5
7
|
export { FakeServerSession, FakeWaServer } from './api/FakeWaServer';
|
|
6
8
|
export type { BinaryNode, ExpectIqOptions, ExpectStanzaOptions, FakeSessionKeyInfo, FakeWaServerNoiseRootCa, FakeWaServerOptions, FakeWaServerPipelineListener, StanzaMatcher, WaFakeAuthenticatedInfo, WaFakeConnectionPipeline } from './api/FakeWaServer';
|
|
9
|
+
/**
|
|
10
|
+
* Signing half of the Noise root, needed only to pin
|
|
11
|
+
* {@link FakeWaServerOptions.noiseRootCa}. Readers of `server.noiseRootCa` get
|
|
12
|
+
* the public-only {@link FakeWaServerNoiseRootCa} instead.
|
|
13
|
+
*/
|
|
14
|
+
export type { FakeNoiseRootCa } from './protocol/auth/cert-chain';
|
|
7
15
|
export { IqExpectation, Scenario } from './api/Scenario';
|
|
8
16
|
export type { AuthenticatedPipelineListener, ScenarioServer } from './api/Scenario';
|
|
9
17
|
export { WaFakeConnection } from './infra/WaFakeConnection';
|
|
10
|
-
export type { WaFakeConnectionHandlers, WaFakeConnectionState } from './infra/WaFakeConnection';
|
|
11
|
-
export
|
|
18
|
+
export type { WaFakeConnectionHandlers, WaFakeConnectionState, WaFakeSocketEvents, WaFakeSocketLike } from './infra/WaFakeConnection';
|
|
19
|
+
export { WaFakeTcpServer } from './infra/WaFakeTcpServer';
|
|
20
|
+
export type { WaFakeTcpServerListenInfo, WaFakeTcpServerOptions } from './infra/WaFakeTcpServer';
|
|
21
|
+
export { createTcpSocketAdapter, createWebSocketAdapter } from './infra/socket-adapters';
|
|
22
|
+
export { ClientPayloadValidationError, parseClientPayload } from './protocol/auth/client-payload-validate';
|
|
23
|
+
export type { ClientPayloadFlavor, LoginPayload, MobileLoginDetails, ParsedClientPayload, RegistrationPayload } from './protocol/auth/client-payload-validate';
|
|
24
|
+
export type { WaFakeIqConnection, WaFakeIqContext, WaFakeIqHandler, WaFakeIqMatcher, WaFakeIqResponder, WaFakeIqRouterEvents, WaFakeIqType } from './protocol/iq/router';
|
|
25
|
+
export { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from './protocol/iq/companion-host';
|
|
26
|
+
export type { ParsedKeyIndexListPublish, ParsedPairDeviceUpload, ParsedRemoveCompanionDevice } from './protocol/iq/companion-host';
|
|
27
|
+
export { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from './protocol/iq/link-code';
|
|
28
|
+
export type { BuildLinkCodeNotificationInput, LinkCodeStage, ParsedLinkCodeStanza } from './protocol/iq/link-code';
|
|
29
|
+
export { buildAccountSyncDevicesNotification, buildAccountTakeoverNotice, buildRegistrationCodeNotification } from './protocol/push/mobile-notification';
|
|
30
|
+
export type { BuildAccountSyncDevicesInput, BuildAccountTakeoverNoticeInput, BuildRegistrationCodeNotificationInput, FakeAccountDevice } from './protocol/push/mobile-notification';
|
|
31
|
+
export { FakeCompanionHostState, readCompanionKeyIndex } from './state/fake-companion-host';
|
|
32
|
+
export type { FakeLinkedCompanion, FakeMobilePrimaryIdentity, FakePublishedKeyIndexList } from './state/fake-companion-host';
|
|
12
33
|
export { buildAppStateSyncFullResult, buildAppStateSyncResult, buildServerSyncNotification, parseAppStateSyncRequest } from './protocol/iq/appstate-sync';
|
|
13
34
|
export type { BuildAppStateSyncFullResultInput, BuildAppStateSyncResultInput, BuildServerSyncNotificationInput, FakeAppStateCollectionName, FakeAppStateCollectionPayload } from './protocol/iq/appstate-sync';
|
|
14
35
|
export { buildIqError, buildIqResult } from './protocol/iq/router';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
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.readCompanionKeyIndex = exports.FakeCompanionHostState = exports.buildRegistrationCodeNotification = exports.buildAccountTakeoverNotice = exports.buildAccountSyncDevicesNotification = exports.parseLinkCodeStanza = exports.buildLinkCodeNotification = exports.buildCompanionHelloResultContent = exports.parseRemoveCompanionDevice = exports.parsePairDeviceUpload = exports.parseKeyIndexListPublish = exports.parseClientPayload = exports.ClientPayloadValidationError = exports.createWebSocketAdapter = exports.createTcpSocketAdapter = exports.WaFakeTcpServer = exports.WaFakeConnection = exports.Scenario = exports.IqExpectation = exports.FakeWaServer = exports.FakeServerSession = exports.FakePeer = exports.FakePairingDriver = exports.seedFakeMobilePrimary = exports.createFakeMobilePrimary = void 0;
|
|
4
|
+
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 = void 0;
|
|
5
|
+
var FakeMobilePrimary_1 = require("./api/FakeMobilePrimary");
|
|
6
|
+
Object.defineProperty(exports, "createFakeMobilePrimary", { enumerable: true, get: function () { return FakeMobilePrimary_1.createFakeMobilePrimary; } });
|
|
7
|
+
Object.defineProperty(exports, "seedFakeMobilePrimary", { enumerable: true, get: function () { return FakeMobilePrimary_1.seedFakeMobilePrimary; } });
|
|
4
8
|
var FakePairingDriver_1 = require("./api/FakePairingDriver");
|
|
5
9
|
Object.defineProperty(exports, "FakePairingDriver", { enumerable: true, get: function () { return FakePairingDriver_1.FakePairingDriver; } });
|
|
6
10
|
var FakePeer_1 = require("./api/FakePeer");
|
|
@@ -13,6 +17,29 @@ Object.defineProperty(exports, "IqExpectation", { enumerable: true, get: functio
|
|
|
13
17
|
Object.defineProperty(exports, "Scenario", { enumerable: true, get: function () { return Scenario_1.Scenario; } });
|
|
14
18
|
var WaFakeConnection_1 = require("./infra/WaFakeConnection");
|
|
15
19
|
Object.defineProperty(exports, "WaFakeConnection", { enumerable: true, get: function () { return WaFakeConnection_1.WaFakeConnection; } });
|
|
20
|
+
var WaFakeTcpServer_1 = require("./infra/WaFakeTcpServer");
|
|
21
|
+
Object.defineProperty(exports, "WaFakeTcpServer", { enumerable: true, get: function () { return WaFakeTcpServer_1.WaFakeTcpServer; } });
|
|
22
|
+
var socket_adapters_1 = require("./infra/socket-adapters");
|
|
23
|
+
Object.defineProperty(exports, "createTcpSocketAdapter", { enumerable: true, get: function () { return socket_adapters_1.createTcpSocketAdapter; } });
|
|
24
|
+
Object.defineProperty(exports, "createWebSocketAdapter", { enumerable: true, get: function () { return socket_adapters_1.createWebSocketAdapter; } });
|
|
25
|
+
var client_payload_validate_1 = require("./protocol/auth/client-payload-validate");
|
|
26
|
+
Object.defineProperty(exports, "ClientPayloadValidationError", { enumerable: true, get: function () { return client_payload_validate_1.ClientPayloadValidationError; } });
|
|
27
|
+
Object.defineProperty(exports, "parseClientPayload", { enumerable: true, get: function () { return client_payload_validate_1.parseClientPayload; } });
|
|
28
|
+
var companion_host_1 = require("./protocol/iq/companion-host");
|
|
29
|
+
Object.defineProperty(exports, "parseKeyIndexListPublish", { enumerable: true, get: function () { return companion_host_1.parseKeyIndexListPublish; } });
|
|
30
|
+
Object.defineProperty(exports, "parsePairDeviceUpload", { enumerable: true, get: function () { return companion_host_1.parsePairDeviceUpload; } });
|
|
31
|
+
Object.defineProperty(exports, "parseRemoveCompanionDevice", { enumerable: true, get: function () { return companion_host_1.parseRemoveCompanionDevice; } });
|
|
32
|
+
var link_code_1 = require("./protocol/iq/link-code");
|
|
33
|
+
Object.defineProperty(exports, "buildCompanionHelloResultContent", { enumerable: true, get: function () { return link_code_1.buildCompanionHelloResultContent; } });
|
|
34
|
+
Object.defineProperty(exports, "buildLinkCodeNotification", { enumerable: true, get: function () { return link_code_1.buildLinkCodeNotification; } });
|
|
35
|
+
Object.defineProperty(exports, "parseLinkCodeStanza", { enumerable: true, get: function () { return link_code_1.parseLinkCodeStanza; } });
|
|
36
|
+
var mobile_notification_1 = require("./protocol/push/mobile-notification");
|
|
37
|
+
Object.defineProperty(exports, "buildAccountSyncDevicesNotification", { enumerable: true, get: function () { return mobile_notification_1.buildAccountSyncDevicesNotification; } });
|
|
38
|
+
Object.defineProperty(exports, "buildAccountTakeoverNotice", { enumerable: true, get: function () { return mobile_notification_1.buildAccountTakeoverNotice; } });
|
|
39
|
+
Object.defineProperty(exports, "buildRegistrationCodeNotification", { enumerable: true, get: function () { return mobile_notification_1.buildRegistrationCodeNotification; } });
|
|
40
|
+
var fake_companion_host_1 = require("./state/fake-companion-host");
|
|
41
|
+
Object.defineProperty(exports, "FakeCompanionHostState", { enumerable: true, get: function () { return fake_companion_host_1.FakeCompanionHostState; } });
|
|
42
|
+
Object.defineProperty(exports, "readCompanionKeyIndex", { enumerable: true, get: function () { return fake_companion_host_1.readCompanionKeyIndex; } });
|
|
16
43
|
var appstate_sync_1 = require("./protocol/iq/appstate-sync");
|
|
17
44
|
Object.defineProperty(exports, "buildAppStateSyncFullResult", { enumerable: true, get: function () { return appstate_sync_1.buildAppStateSyncFullResult; } });
|
|
18
45
|
Object.defineProperty(exports, "buildAppStateSyncResult", { enumerable: true, get: function () { return appstate_sync_1.buildAppStateSyncResult; } });
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { WebSocket } from 'ws';
|
|
2
1
|
export type WaFakeConnectionState = 'open' | 'closing' | 'closed';
|
|
3
2
|
export interface WaFakeConnectionHandlers {
|
|
4
3
|
readonly onFrame?: (frame: Uint8Array) => void;
|
|
@@ -8,15 +7,34 @@ export interface WaFakeConnectionHandlers {
|
|
|
8
7
|
}) => void;
|
|
9
8
|
readonly onError?: (error: Error) => void;
|
|
10
9
|
}
|
|
10
|
+
/** Events a carrier adapter pushes into the connection it backs. */
|
|
11
|
+
export interface WaFakeSocketEvents {
|
|
12
|
+
readonly onFrame: (frame: Uint8Array) => void;
|
|
13
|
+
readonly onClose: (info: {
|
|
14
|
+
readonly code: number;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
}) => void;
|
|
17
|
+
readonly onError: (error: Error) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Carrier a connection speaks over. Each transport adapts its own quirks (the
|
|
21
|
+
* WebSocket fragment array, the absence of close codes on a TCP stream) behind
|
|
22
|
+
* this interface, so every layer above stays carrier-agnostic: the WebSocket
|
|
23
|
+
* listener serves companions, the TCP listener serves the mobile transport.
|
|
24
|
+
*/
|
|
25
|
+
export interface WaFakeSocketLike {
|
|
26
|
+
readonly send: (frame: Uint8Array) => void;
|
|
27
|
+
readonly close: (code: number, reason: string) => void;
|
|
28
|
+
readonly listen: (events: WaFakeSocketEvents) => void;
|
|
29
|
+
}
|
|
11
30
|
export declare class WaFakeConnection {
|
|
12
31
|
readonly id: string;
|
|
13
32
|
private readonly socket;
|
|
14
33
|
private handlers;
|
|
15
34
|
private currentState;
|
|
16
|
-
constructor(id: string, socket:
|
|
35
|
+
constructor(id: string, socket: WaFakeSocketLike);
|
|
17
36
|
get state(): WaFakeConnectionState;
|
|
18
37
|
setHandlers(handlers: WaFakeConnectionHandlers): void;
|
|
19
38
|
sendFrame(frame: Uint8Array): void;
|
|
20
39
|
close(code?: number, reason?: string): void;
|
|
21
|
-
private bindSocketEvents;
|
|
22
40
|
}
|
|
@@ -7,7 +7,14 @@ class WaFakeConnection {
|
|
|
7
7
|
this.currentState = 'open';
|
|
8
8
|
this.id = id;
|
|
9
9
|
this.socket = socket;
|
|
10
|
-
this.
|
|
10
|
+
this.socket.listen({
|
|
11
|
+
onFrame: (frame) => this.handlers.onFrame?.(frame),
|
|
12
|
+
onClose: (info) => {
|
|
13
|
+
this.currentState = 'closed';
|
|
14
|
+
this.handlers.onClose?.(info);
|
|
15
|
+
},
|
|
16
|
+
onError: (error) => this.handlers.onError?.(error)
|
|
17
|
+
});
|
|
11
18
|
}
|
|
12
19
|
get state() {
|
|
13
20
|
return this.currentState;
|
|
@@ -28,26 +35,5 @@ class WaFakeConnection {
|
|
|
28
35
|
this.currentState = 'closing';
|
|
29
36
|
this.socket.close(code, reason);
|
|
30
37
|
}
|
|
31
|
-
bindSocketEvents() {
|
|
32
|
-
this.socket.on('message', (data, isBinary) => {
|
|
33
|
-
if (!isBinary) {
|
|
34
|
-
this.handlers.onError?.(new Error('received unexpected text frame'));
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
const frame = data instanceof Uint8Array
|
|
38
|
-
? data
|
|
39
|
-
: Array.isArray(data)
|
|
40
|
-
? new Uint8Array(Buffer.concat(data))
|
|
41
|
-
: new Uint8Array(data);
|
|
42
|
-
this.handlers.onFrame?.(frame);
|
|
43
|
-
});
|
|
44
|
-
this.socket.on('close', (code, reasonBuf) => {
|
|
45
|
-
this.currentState = 'closed';
|
|
46
|
-
this.handlers.onClose?.({ code, reason: reasonBuf.toString('utf8') });
|
|
47
|
-
});
|
|
48
|
-
this.socket.on('error', (error) => {
|
|
49
|
-
this.handlers.onError?.(error);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
38
|
}
|
|
53
39
|
exports.WaFakeConnection = WaFakeConnection;
|
|
@@ -38,6 +38,7 @@ export declare class WaFakeConnectionPipeline {
|
|
|
38
38
|
private state;
|
|
39
39
|
private chain;
|
|
40
40
|
private authenticatedPayload;
|
|
41
|
+
private clientPrologue;
|
|
41
42
|
constructor(config: WaFakeConnectionPipelineConfig);
|
|
42
43
|
setEvents(events: WaFakeConnectionPipelineEvents): void;
|
|
43
44
|
isAuthenticated(): boolean;
|
|
@@ -13,17 +13,20 @@ const WaFakeNoiseHandshake_1 = require("./WaFakeNoiseHandshake");
|
|
|
13
13
|
const WaFakeTransport_1 = require("./WaFakeTransport");
|
|
14
14
|
const NOISE_XX_NAME = new TextEncoder().encode('Noise_XX_25519_AESGCM_SHA256\0\0\0\0');
|
|
15
15
|
const NOISE_IK_NAME = new TextEncoder().encode('Noise_IK_25519_AESGCM_SHA256\0\0\0\0');
|
|
16
|
-
const
|
|
16
|
+
const PROLOGUE_MAGIC_W = 0x57;
|
|
17
|
+
const PROLOGUE_MAGIC_A = 0x41;
|
|
18
|
+
const DEFAULT_PROLOGUE = new Uint8Array([PROLOGUE_MAGIC_W, PROLOGUE_MAGIC_A, 0x06, 0x03]);
|
|
17
19
|
class WaFakeConnectionPipeline {
|
|
18
20
|
constructor(config) {
|
|
19
21
|
this.events = {};
|
|
20
22
|
this.state = { kind: 'awaiting_prologue' };
|
|
21
23
|
this.chain = Promise.resolve();
|
|
22
24
|
this.authenticatedPayload = null;
|
|
25
|
+
this.clientPrologue = DEFAULT_PROLOGUE;
|
|
23
26
|
this.config = config;
|
|
24
27
|
this.frameSocket = new WaFakeFrameSocket_1.WaFakeFrameSocket(config.connection);
|
|
25
28
|
this.frameSocket.setHandlers({
|
|
26
|
-
onPrologue: () => this.onPrologue(),
|
|
29
|
+
onPrologue: (prologue) => this.onPrologue(prologue),
|
|
27
30
|
onFrame: (frame) => this.scheduleFrame(frame),
|
|
28
31
|
onClose: (info) => {
|
|
29
32
|
this.state = { kind: 'closed' };
|
|
@@ -51,11 +54,20 @@ class WaFakeConnectionPipeline {
|
|
|
51
54
|
const ciphertext = await this.state.transport.encryptFrame(stanza);
|
|
52
55
|
this.frameSocket.sendFrame(ciphertext);
|
|
53
56
|
}
|
|
54
|
-
onPrologue() {
|
|
57
|
+
onPrologue(prologue) {
|
|
55
58
|
if (this.state.kind !== 'awaiting_prologue') {
|
|
56
59
|
this.events.onError?.(new Error('received second prologue from client'));
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
62
|
+
// Mix back the header the client actually sent instead of a fixed one:
|
|
63
|
+
// it is hashed into the handshake, and a client is free to advertise a
|
|
64
|
+
// protocol/dict version other than the web client's 6/3.
|
|
65
|
+
this.clientPrologue = new Uint8Array([
|
|
66
|
+
PROLOGUE_MAGIC_W,
|
|
67
|
+
PROLOGUE_MAGIC_A,
|
|
68
|
+
prologue.protocolVersion,
|
|
69
|
+
prologue.dictVersion
|
|
70
|
+
]);
|
|
59
71
|
this.state = { kind: 'awaiting_client_hello' };
|
|
60
72
|
}
|
|
61
73
|
scheduleFrame(frame) {
|
|
@@ -97,7 +109,7 @@ class WaFakeConnectionPipeline {
|
|
|
97
109
|
}
|
|
98
110
|
async handleXxClientHello(clientEphemeralPub) {
|
|
99
111
|
const handshake = new WaFakeNoiseHandshake_1.WaFakeNoiseHandshake();
|
|
100
|
-
handshake.start(NOISE_XX_NAME,
|
|
112
|
+
handshake.start(NOISE_XX_NAME, this.clientPrologue);
|
|
101
113
|
handshake.authenticate(clientEphemeralPub);
|
|
102
114
|
const serverEphemeral = await crypto_1.X25519.generateKeyPair();
|
|
103
115
|
handshake.authenticate(serverEphemeral.pubKey);
|
|
@@ -133,7 +145,7 @@ class WaFakeConnectionPipeline {
|
|
|
133
145
|
const encryptedClientStatic = clientHello.static;
|
|
134
146
|
const encryptedClientPayload = clientHello.payload;
|
|
135
147
|
const handshake = new WaFakeNoiseHandshake_1.WaFakeNoiseHandshake();
|
|
136
|
-
handshake.start(NOISE_IK_NAME,
|
|
148
|
+
handshake.start(NOISE_IK_NAME, this.clientPrologue);
|
|
137
149
|
handshake.authenticate(this.config.serverStaticKeyPair.pubKey);
|
|
138
150
|
handshake.authenticate(clientEphemeralPub);
|
|
139
151
|
handshake.mixIntoKey(await crypto_1.X25519.scalarMult(this.config.serverStaticKeyPair.privKey, clientEphemeralPub));
|