whalibmob 5.26.1 → 5.29.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 +204 -0
- package/index.d.ts +156 -2
- package/index.js +7 -0
- package/lib/Client.js +531 -11
- package/lib/CompanionPairing.js +1 -1
- package/lib/HistorySyncHandler.js +2 -2
- package/lib/MediaService.js +2 -2
- package/lib/PairingCode.js +4 -6
- package/lib/Registration.js +1 -1
- package/lib/Store.js +1 -1
- package/lib/appstate/AppStateSync.js +3 -3
- package/lib/appstate/LTHash.js +2 -5
- package/lib/curve.js +148 -0
- package/lib/hkdf.js +65 -0
- package/lib/messages/MediaRetry.js +2 -3
- package/lib/messages/MessageSender.js +1 -1
- package/lib/messages/ReportingToken.js +2 -2
- package/lib/noise.js +3 -3
- package/lib/proto/MessageProto.js +83 -1
- package/lib/signal/SenderKey.js +3 -3
- package/lib/signal/libsignal/curve.js +1 -1
- package/package.json +2 -2
package/lib/Client.js
CHANGED
|
@@ -496,12 +496,30 @@ class WhalibmobClient extends EventEmitter {
|
|
|
496
496
|
// Blue-tick every message as it arrives. Set to false to make read receipts
|
|
497
497
|
// explicit, sent only by a markRead() call.
|
|
498
498
|
this.autoRead = opts.autoRead !== false;
|
|
499
|
+
|
|
500
|
+
// Every incoming call is refused unless this is turned off. Off does not
|
|
501
|
+
// mean calls are answered — nothing here can answer one — it means the
|
|
502
|
+
// refusal is left to the caller, which is the only way to let some calls
|
|
503
|
+
// ring and reject the rest. See rejectCall().
|
|
504
|
+
this._autoRejectCalls = opts.autoRejectCalls !== false;
|
|
499
505
|
// Flipped by setOnline(). Delivery receipts sent while this is false carry
|
|
500
506
|
// type="inactive", which the sender's app records but does not render.
|
|
501
507
|
this._sendActiveReceipts = false;
|
|
502
508
|
// Cached privacy settings. Warmed after connect and refreshed whenever the
|
|
503
509
|
// server reports a change; markRead reads it.
|
|
504
510
|
this._privacySettings = null;
|
|
511
|
+
// The account's AB props, and the hash that turns the next sync into a
|
|
512
|
+
// delta. Both live for the session: a fresh process asks for the full set,
|
|
513
|
+
// which is what a fresh install does.
|
|
514
|
+
this._abProps = null;
|
|
515
|
+
this._abPropsHash = null;
|
|
516
|
+
// Whether to sync AB props on connect. On by default because the app does
|
|
517
|
+
// it on every login and a client that never does is running on guessed
|
|
518
|
+
// defaults, but it is one IQ a caller may not want on a short session.
|
|
519
|
+
this._syncAbPropsOnConnect = opts.syncAbPropsOnConnect !== false;
|
|
520
|
+
// noticeId → accepted. Absent means this session has not asked, which
|
|
521
|
+
// isTosAccepted reports as null rather than as "not accepted".
|
|
522
|
+
this._tosAccepted = new Map();
|
|
505
523
|
if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
|
|
506
524
|
}
|
|
507
525
|
|
|
@@ -1696,6 +1714,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1696
1714
|
this.queryPrivacySettings({ force: true }).catch(err =>
|
|
1697
1715
|
_whaDbg('[DBG] PRIVACY_FETCH_FAILED ' + (err && err.message)));
|
|
1698
1716
|
|
|
1717
|
+
// Sync the account's AB props, as the app does on every login. On a
|
|
1718
|
+
// reconnect the stored hash makes this a delta rather than the whole
|
|
1719
|
+
// table. A failure is logged and nothing else: every reader of these has a
|
|
1720
|
+
// default to fall back on, so a client that could not fetch them still
|
|
1721
|
+
// works — it just works on the defaults.
|
|
1722
|
+
if (this._syncAbPropsOnConnect) {
|
|
1723
|
+
this.queryAbProps({ force: true }).catch(err =>
|
|
1724
|
+
_whaDbg('[DBG] AB_PROPS_FETCH_FAILED ' + (err && err.message)));
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1699
1727
|
// ── Reconnect: flush stale device cache + background re-usync ─────────────
|
|
1700
1728
|
// On first connect _hasConnectedOnce is false — nothing extra to do.
|
|
1701
1729
|
// On every reconnect (network drop / NAT reset / server restart) we flush the
|
|
@@ -3815,6 +3843,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3815
3843
|
// so a change made on the phone never reached the cache.
|
|
3816
3844
|
const privacyNode = findChildDeep(node, 'privacy');
|
|
3817
3845
|
if (privacyNode) this._handlePrivacySettingsNotification(privacyNode);
|
|
3846
|
+
// ...and Terms-of-Service acceptances. Accepting a notice on the phone
|
|
3847
|
+
// arrives here, and it is the only way this side learns about it without
|
|
3848
|
+
// asking.
|
|
3849
|
+
const tosNode = findChildDeep(node, 'tos');
|
|
3850
|
+
if (tosNode) this._handleTosNotification(tosNode);
|
|
3818
3851
|
}
|
|
3819
3852
|
|
|
3820
3853
|
if (type === 'w:gp2') {
|
|
@@ -4162,20 +4195,46 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4162
4195
|
}
|
|
4163
4196
|
}
|
|
4164
4197
|
|
|
4165
|
-
|
|
4166
|
-
//
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4198
|
+
// What the stanza actually says, rather than the raw node alone: which call
|
|
4199
|
+
// it is, and what stage it is at. A caller deciding whether to reject needs
|
|
4200
|
+
// both, and digging them out of the node is not its job.
|
|
4201
|
+
const offer = findChild(node, 'offer');
|
|
4202
|
+
const relay = findChild(node, 'relaylatency');
|
|
4203
|
+
const term = findChild(node, 'terminate');
|
|
4204
|
+
const callId = (offer && offer.attrs && offer.attrs.call_id) ||
|
|
4205
|
+
(term && term.attrs && term.attrs.call_id) || null;
|
|
4206
|
+
const status = offer ? 'offer' : (term ? 'terminate' : (relay ? 'ringing' : 'unknown'));
|
|
4207
|
+
|
|
4208
|
+
this.emit('call', { from: attrs.from || '', id: callId, status, node });
|
|
4209
|
+
|
|
4210
|
+
// Reject every incoming call, unless the caller asked to decide for itself.
|
|
4211
|
+
// Turning this off does not answer calls — nothing here can — it only stops
|
|
4212
|
+
// the automatic refusal, so a caller can reject some and let the rest ring.
|
|
4213
|
+
if (this._autoRejectCalls && callId && status === 'offer') {
|
|
4214
|
+
this.rejectCall(callId, attrs.from || '');
|
|
4176
4215
|
}
|
|
4177
4216
|
}
|
|
4178
4217
|
|
|
4218
|
+
/**
|
|
4219
|
+
* Refuse one incoming call.
|
|
4220
|
+
*
|
|
4221
|
+
* Only needed when the client was built with `autoRejectCalls: false`; with
|
|
4222
|
+
* the default every call is refused before this could be reached. The call id
|
|
4223
|
+
* and the caller both come off the `call` event.
|
|
4224
|
+
*
|
|
4225
|
+
* @param {string} callId the `id` from the call event
|
|
4226
|
+
* @param {string} from the `from` from the call event
|
|
4227
|
+
* @returns {boolean} whether the refusal could be sent
|
|
4228
|
+
*/
|
|
4229
|
+
rejectCall(callId, from) {
|
|
4230
|
+
if (!callId || !this._socket || !this._connected) return false;
|
|
4231
|
+
this._socket.sendNode(new BinaryNode('call', {
|
|
4232
|
+
to: from || '',
|
|
4233
|
+
id: this._genMsgId()
|
|
4234
|
+
}, [new BinaryNode('reject', { call_id: callId, call_creator: from || '' }, null)]));
|
|
4235
|
+
return true;
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4179
4238
|
// ─── Auth failure (server forces logout during active session) ──────────────
|
|
4180
4239
|
|
|
4181
4240
|
// <failure> ends the connection, but not every reason means the session died.
|
|
@@ -6495,6 +6554,467 @@ class WhalibmobClient extends EventEmitter {
|
|
|
6495
6554
|
return this._sender.deleteMessage(origMsgId, chatJid, fromMe, forEveryone, opts);
|
|
6496
6555
|
}
|
|
6497
6556
|
|
|
6557
|
+
// ─── AB props — the server's own feature flags ────────────────────────────
|
|
6558
|
+
//
|
|
6559
|
+
// Every WhatsApp client asks the server which experiments and configuration
|
|
6560
|
+
// values apply to this account and then gates behaviour on the answer: how
|
|
6561
|
+
// many bytes an identity hash is truncated to, whether the LID migration has
|
|
6562
|
+
// started here, what sampling weight each telemetry event carries. A client
|
|
6563
|
+
// that never asks runs on guessed defaults wherever the server has an
|
|
6564
|
+
// opinion — and never asking is itself unlike the app, which asks on every
|
|
6565
|
+
// login.
|
|
6566
|
+
//
|
|
6567
|
+
// <iq xmlns="abt" to="s.whatsapp.net" type="get">
|
|
6568
|
+
// <props protocol="1" [hash="..."] [refresh_id="..."]/>
|
|
6569
|
+
// </iq>
|
|
6570
|
+
//
|
|
6571
|
+
// The reply carries a hash of the set it returned. Sending that hash back on
|
|
6572
|
+
// the next request asks for a delta rather than the whole table, which is
|
|
6573
|
+
// what the app does once it has synced. The hash is kept for the session
|
|
6574
|
+
// only — a fresh process asks for the full set, exactly as a fresh install
|
|
6575
|
+
// does — so nothing here has to be persisted or migrated.
|
|
6576
|
+
//
|
|
6577
|
+
// Values come back as strings because that is what the wire carries. Typing
|
|
6578
|
+
// them per prop would need a table this side does not have, and guessing
|
|
6579
|
+
// would be wrong more often than it is useful; `abPropInt` and `abPropBool`
|
|
6580
|
+
// are there for the two readings that are worth having.
|
|
6581
|
+
|
|
6582
|
+
/**
|
|
6583
|
+
* Sync this account's AB props.
|
|
6584
|
+
*
|
|
6585
|
+
* Served from the session cache unless `force` is set. A reply the server
|
|
6586
|
+
* marks `delta_update` is merged onto what is already held rather than
|
|
6587
|
+
* replacing it, which is the whole point of sending the hash.
|
|
6588
|
+
*
|
|
6589
|
+
* @param {object} [opts]
|
|
6590
|
+
* @param {boolean} [opts.force] re-ask even when a set is cached
|
|
6591
|
+
* @param {string} [opts.refreshId] the emergency-push branch. The server
|
|
6592
|
+
* announces a refresh id when a global update has to propagate at
|
|
6593
|
+
* once; sending it takes the place of the hash.
|
|
6594
|
+
* @returns {Promise<{hash: string|null, abKey: string|null,
|
|
6595
|
+
* refresh: number|null, refreshId: string|null,
|
|
6596
|
+
* delta: boolean, props: Object<string,string>,
|
|
6597
|
+
* sampling: Object<string,number>}>}
|
|
6598
|
+
*/
|
|
6599
|
+
async queryAbProps(opts) {
|
|
6600
|
+
opts = opts || {};
|
|
6601
|
+
if (this._abProps && !opts.force && opts.refreshId == null) return this._abProps;
|
|
6602
|
+
|
|
6603
|
+
const propsAttrs = { protocol: '1' };
|
|
6604
|
+
// One or the other, never both: a refresh id names a specific update to
|
|
6605
|
+
// fetch, and a hash asks for whatever has changed since it. Sending both
|
|
6606
|
+
// asks two different questions in one stanza.
|
|
6607
|
+
if (opts.refreshId != null) propsAttrs.refresh_id = String(opts.refreshId);
|
|
6608
|
+
else if (this._abPropsHash) propsAttrs.hash = this._abPropsHash;
|
|
6609
|
+
|
|
6610
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
6611
|
+
id: this._genMsgId(),
|
|
6612
|
+
to: 's.whatsapp.net',
|
|
6613
|
+
type: 'get',
|
|
6614
|
+
xmlns: 'abt'
|
|
6615
|
+
}, [new BinaryNode('props', propsAttrs, null)]));
|
|
6616
|
+
|
|
6617
|
+
if (!resp) throw new Error('queryAbProps: no reply from server (IQ timed out)');
|
|
6618
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
6619
|
+
const errNode = findChild(resp, 'error');
|
|
6620
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
6621
|
+
throw new Error('queryAbProps: server rejected the request' +
|
|
6622
|
+
(code ? ' (' + code + ')' : ''));
|
|
6623
|
+
}
|
|
6624
|
+
|
|
6625
|
+
const parsed = this._parseAbProps(findChild(resp, 'props'));
|
|
6626
|
+
|
|
6627
|
+
// A delta names only what moved. Replacing the cache with it would drop
|
|
6628
|
+
// every prop the server saw no reason to repeat.
|
|
6629
|
+
if (parsed.delta && this._abProps) {
|
|
6630
|
+
parsed.props = Object.assign({}, this._abProps.props, parsed.props);
|
|
6631
|
+
parsed.sampling = Object.assign({}, this._abProps.sampling, parsed.sampling);
|
|
6632
|
+
}
|
|
6633
|
+
|
|
6634
|
+
if (parsed.hash) this._abPropsHash = parsed.hash;
|
|
6635
|
+
this._abProps = parsed;
|
|
6636
|
+
_whaDbg('[DBG] AB_PROPS ' + (parsed.delta ? 'delta' : 'full') +
|
|
6637
|
+
' props=' + Object.keys(parsed.props).length +
|
|
6638
|
+
' sampling=' + Object.keys(parsed.sampling).length +
|
|
6639
|
+
(parsed.hash ? ' hash=' + parsed.hash : ''));
|
|
6640
|
+
return parsed;
|
|
6641
|
+
}
|
|
6642
|
+
|
|
6643
|
+
// <props hash ab_key refresh refresh_id delta_update>
|
|
6644
|
+
// <prop config_code="..." config_value="..."/> an experiment value
|
|
6645
|
+
// <prop event_code="..." sampling_weight="..."/> a telemetry weight
|
|
6646
|
+
// </props>
|
|
6647
|
+
//
|
|
6648
|
+
// The two shapes share a tag and are told apart by which attributes they
|
|
6649
|
+
// carry, so each child is tried as an experiment first and as a sampling
|
|
6650
|
+
// entry second. A child that is neither is skipped rather than allowed to
|
|
6651
|
+
// fail the whole sync — an unknown shape is a prop this version has not
|
|
6652
|
+
// learned about, not a broken reply.
|
|
6653
|
+
_parseAbProps(propsNode) {
|
|
6654
|
+
const out = {
|
|
6655
|
+
hash: null,
|
|
6656
|
+
abKey: null,
|
|
6657
|
+
refresh: null,
|
|
6658
|
+
refreshId: null,
|
|
6659
|
+
delta: false,
|
|
6660
|
+
props: {},
|
|
6661
|
+
sampling: {}
|
|
6662
|
+
};
|
|
6663
|
+
if (!propsNode || !propsNode.attrs) return out;
|
|
6664
|
+
|
|
6665
|
+
const a = propsNode.attrs;
|
|
6666
|
+
out.hash = a.hash ? String(a.hash) : null;
|
|
6667
|
+
out.abKey = a.ab_key ? String(a.ab_key) : null;
|
|
6668
|
+
out.refreshId = a.refresh_id ? String(a.refresh_id) : null;
|
|
6669
|
+
|
|
6670
|
+
const refresh = Number(a.refresh);
|
|
6671
|
+
if (Number.isFinite(refresh) && refresh > 0) out.refresh = refresh;
|
|
6672
|
+
|
|
6673
|
+
// The server writes this as a string, and "false" is a true string.
|
|
6674
|
+
out.delta = a.delta_update === 'true' || a.delta_update === true || a.delta_update === '1';
|
|
6675
|
+
|
|
6676
|
+
const children = Array.isArray(propsNode.content) ? propsNode.content : [];
|
|
6677
|
+
for (const child of children) {
|
|
6678
|
+
if (!child || child.description !== 'prop' || !child.attrs) continue;
|
|
6679
|
+
const c = child.attrs;
|
|
6680
|
+
|
|
6681
|
+
if (c.config_code != null && c.config_value != null) {
|
|
6682
|
+
out.props[String(c.config_code)] = String(c.config_value);
|
|
6683
|
+
continue;
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
if (c.event_code != null && c.sampling_weight != null) {
|
|
6687
|
+
const code = Number(c.event_code);
|
|
6688
|
+
const weight = Number(c.sampling_weight);
|
|
6689
|
+
// Bounds the app itself applies. A code below 1 names no event, and a
|
|
6690
|
+
// weight outside the range is not a weight — both mean this entry was
|
|
6691
|
+
// misread rather than that the server sent something exotic.
|
|
6692
|
+
if (Number.isFinite(code) && code >= 1 &&
|
|
6693
|
+
Number.isFinite(weight) && weight >= 0 && weight <= 1000000) {
|
|
6694
|
+
out.sampling[String(code)] = weight;
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
}
|
|
6698
|
+
return out;
|
|
6699
|
+
}
|
|
6700
|
+
|
|
6701
|
+
/**
|
|
6702
|
+
* One AB prop as a string, or `fallback` when the server has not set it.
|
|
6703
|
+
*
|
|
6704
|
+
* Props are addressed by their numeric code, which is what the wire uses.
|
|
6705
|
+
* Reading one before `queryAbProps` has run returns the fallback rather than
|
|
6706
|
+
* throwing: a caller gating on a flag wants the default, not an exception.
|
|
6707
|
+
*
|
|
6708
|
+
* @param {number|string} code
|
|
6709
|
+
* @param {string|null} [fallback=null]
|
|
6710
|
+
* @returns {string|null}
|
|
6711
|
+
*/
|
|
6712
|
+
abProp(code, fallback) {
|
|
6713
|
+
const value = this._abProps && this._abProps.props[String(code)];
|
|
6714
|
+
return value === undefined || value === null ? (fallback === undefined ? null : fallback) : value;
|
|
6715
|
+
}
|
|
6716
|
+
|
|
6717
|
+
/** The same, read as an integer. Anything unparseable gives the fallback. */
|
|
6718
|
+
abPropInt(code, fallback) {
|
|
6719
|
+
const raw = this.abProp(code, null);
|
|
6720
|
+
const n = Number(raw);
|
|
6721
|
+
return raw === null || !Number.isFinite(n) ? (fallback === undefined ? null : fallback) : n;
|
|
6722
|
+
}
|
|
6723
|
+
|
|
6724
|
+
/**
|
|
6725
|
+
* The same, read as a flag.
|
|
6726
|
+
*
|
|
6727
|
+
* The wire spells these as "1"/"0" and as "true"/"false" depending on the
|
|
6728
|
+
* prop, so both are understood; anything else gives the fallback rather than
|
|
6729
|
+
* being coerced, since a prop carrying a version string is not a flag.
|
|
6730
|
+
*/
|
|
6731
|
+
abPropBool(code, fallback) {
|
|
6732
|
+
const raw = this.abProp(code, null);
|
|
6733
|
+
if (raw === '1' || raw === 'true') return true;
|
|
6734
|
+
if (raw === '0' || raw === 'false') return false;
|
|
6735
|
+
return fallback === undefined ? null : fallback;
|
|
6736
|
+
}
|
|
6737
|
+
|
|
6738
|
+
// ─── Contact sync — telling the server which numbers are in the book ──────
|
|
6739
|
+
//
|
|
6740
|
+
// The app uploads its address book on first launch and re-syncs deltas
|
|
6741
|
+
// afterwards, which is how it learns who is reachable and how the server
|
|
6742
|
+
// learns who this account knows. whalibmob has had the whole usync machinery
|
|
6743
|
+
// for device discovery all along and never used it for this, so an account
|
|
6744
|
+
// could send to a hundred strangers having never synced a single contact —
|
|
6745
|
+
// a shape nothing on a phone produces.
|
|
6746
|
+
//
|
|
6747
|
+
// This is the same <iq xmlns="usync"> the device path builds, with the
|
|
6748
|
+
// contact protocol on its own and the mode and context that name an address
|
|
6749
|
+
// book upload rather than a lookup.
|
|
6750
|
+
|
|
6751
|
+
/**
|
|
6752
|
+
* Sync contacts, and learn which of them are on WhatsApp.
|
|
6753
|
+
*
|
|
6754
|
+
* @param {string[]} phones E.164 numbers. A leading `+` is added when
|
|
6755
|
+
* missing, since that is the only form the contact protocol takes.
|
|
6756
|
+
* @param {object} [opts]
|
|
6757
|
+
* @param {'full'|'delta'|'query'} [opts.mode] `full` — the whole book, what
|
|
6758
|
+
* the app sends on first launch; `delta` — what changed since;
|
|
6759
|
+
* `query` — a lookup that claims nothing about the book. Defaults to
|
|
6760
|
+
* `full`.
|
|
6761
|
+
* @param {string} [opts.context] defaults to `registration` for a full
|
|
6762
|
+
* sync (the app's own first-launch context) and `interactive`
|
|
6763
|
+
* otherwise.
|
|
6764
|
+
* @param {number} [opts.chunkSize=500] numbers per IQ.
|
|
6765
|
+
* @returns {Promise<{registered: string[], unregistered: string[],
|
|
6766
|
+
* jids: Object<string,string>}>}
|
|
6767
|
+
* `registered` and `unregistered` hold the numbers as passed in;
|
|
6768
|
+
* `jids` maps each registered number to the JID the server gave it.
|
|
6769
|
+
*/
|
|
6770
|
+
async syncContacts(phones, opts) {
|
|
6771
|
+
if (!this._connected) throw new Error('Not connected');
|
|
6772
|
+
// _devMgr is torn down on close and rebuilt on init. Saying so beats the
|
|
6773
|
+
// null dereference a caller would otherwise get from a session that is
|
|
6774
|
+
// connected but has been through a teardown.
|
|
6775
|
+
if (!this._devMgr) throw new Error('syncContacts: no device manager — the session is not initialised');
|
|
6776
|
+
if (!Array.isArray(phones)) throw new Error('syncContacts: phones must be an array');
|
|
6777
|
+
|
|
6778
|
+
opts = opts || {};
|
|
6779
|
+
const mode = opts.mode || 'full';
|
|
6780
|
+
const context = opts.context || (mode === 'full' ? 'registration' : 'interactive');
|
|
6781
|
+
const chunkSize = Number(opts.chunkSize) > 0 ? Number(opts.chunkSize) : 500;
|
|
6782
|
+
|
|
6783
|
+
const { USyncQuery, USyncUser } = require('./WAUSync');
|
|
6784
|
+
|
|
6785
|
+
// Normalise once, and keep the caller's spelling to answer in. Duplicates
|
|
6786
|
+
// are dropped here rather than sent: the server counts what it is asked
|
|
6787
|
+
// about, and asking twice about one number in one book is not what a
|
|
6788
|
+
// phone does.
|
|
6789
|
+
const wanted = [];
|
|
6790
|
+
const seen = new Set();
|
|
6791
|
+
for (const raw of phones) {
|
|
6792
|
+
const digits = String(raw || '').replace(/[^\d]/g, '');
|
|
6793
|
+
if (!digits || seen.has(digits)) continue;
|
|
6794
|
+
seen.add(digits);
|
|
6795
|
+
wanted.push({ input: String(raw), digits });
|
|
6796
|
+
}
|
|
6797
|
+
|
|
6798
|
+
const out = { registered: [], unregistered: [], jids: {} };
|
|
6799
|
+
if (wanted.length === 0) return out;
|
|
6800
|
+
|
|
6801
|
+
for (let i = 0; i < wanted.length; i += chunkSize) {
|
|
6802
|
+
const chunk = wanted.slice(i, i + chunkSize);
|
|
6803
|
+
|
|
6804
|
+
const query = new USyncQuery()
|
|
6805
|
+
.withMode(mode)
|
|
6806
|
+
.withContext(context)
|
|
6807
|
+
.withContactProtocol();
|
|
6808
|
+
for (const c of chunk) query.withUser(new USyncUser().withPhone('+' + c.digits));
|
|
6809
|
+
|
|
6810
|
+
const parsed = await this._devMgr.executeUSyncQuery(query);
|
|
6811
|
+
|
|
6812
|
+
// A chunk the server did not answer leaves its numbers unclassified
|
|
6813
|
+
// rather than marked absent. Reporting "not on WhatsApp" for a timeout
|
|
6814
|
+
// is the one wrong answer here — a caller acts on it.
|
|
6815
|
+
if (!parsed || !Array.isArray(parsed.list)) {
|
|
6816
|
+
_whaDbg('[DBG] CONTACT_SYNC chunk ' + (i / chunkSize) + ' unanswered — ' +
|
|
6817
|
+
chunk.length + ' numbers left unclassified');
|
|
6818
|
+
continue;
|
|
6819
|
+
}
|
|
6820
|
+
|
|
6821
|
+
// The reply is keyed by JID, so match each entry back to the number it
|
|
6822
|
+
// came from by the JID's user part.
|
|
6823
|
+
const byUser = new Map();
|
|
6824
|
+
for (const entry of parsed.list) {
|
|
6825
|
+
if (!entry || !entry.id) continue;
|
|
6826
|
+
const user = String(entry.id).split('@')[0].split(':')[0];
|
|
6827
|
+
byUser.set(user, entry);
|
|
6828
|
+
}
|
|
6829
|
+
|
|
6830
|
+
for (const c of chunk) {
|
|
6831
|
+
const entry = byUser.get(c.digits);
|
|
6832
|
+
if (!entry) continue; // unclassified, as above
|
|
6833
|
+
if (entry.contact === true) {
|
|
6834
|
+
out.registered.push(c.input);
|
|
6835
|
+
out.jids[c.input] = String(entry.id);
|
|
6836
|
+
} else {
|
|
6837
|
+
out.unregistered.push(c.input);
|
|
6838
|
+
}
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
|
|
6842
|
+
_whaDbg('[DBG] CONTACT_SYNC mode=' + mode + ' context=' + context +
|
|
6843
|
+
' asked=' + wanted.length + ' on=' + out.registered.length +
|
|
6844
|
+
' off=' + out.unregistered.length);
|
|
6845
|
+
return out;
|
|
6846
|
+
}
|
|
6847
|
+
|
|
6848
|
+
// ─── Terms-of-Service notices ─────────────────────────────────────────────
|
|
6849
|
+
//
|
|
6850
|
+
// WhatsApp gates some surfaces on the account having accepted a given legal
|
|
6851
|
+
// notice — a Terms update, a privacy-policy change, a regional disclosure.
|
|
6852
|
+
// The app pulls the acceptance state, shows the ones that are outstanding,
|
|
6853
|
+
// and posts back what the user accepted.
|
|
6854
|
+
//
|
|
6855
|
+
// read <iq xmlns="tos" type="get"><request><notice id="..."/></request></iq>
|
|
6856
|
+
// accept <iq xmlns="tos" type="set"><request type="session_update">
|
|
6857
|
+
// <notice id="..."/></request></iq>
|
|
6858
|
+
// clear <iq xmlns="tos" type="set"><delete id="..."/></iq>
|
|
6859
|
+
//
|
|
6860
|
+
// The reply's `state` attribute reads backwards from how it looks: it is
|
|
6861
|
+
// present and "false" for a notice that has NOT been accepted, and absent
|
|
6862
|
+
// for one that has. Treating a missing attribute as "unknown" would report
|
|
6863
|
+
// every accepted notice as outstanding.
|
|
6864
|
+
|
|
6865
|
+
/**
|
|
6866
|
+
* Read the acceptance state of one or more notices.
|
|
6867
|
+
*
|
|
6868
|
+
* @param {string[]} noticeIds
|
|
6869
|
+
* @returns {Promise<{refresh: number|null,
|
|
6870
|
+
* notices: Array<{id: string, accepted: boolean}>}>}
|
|
6871
|
+
* `refresh` is how many seconds the server suggests waiting before
|
|
6872
|
+
* asking again.
|
|
6873
|
+
*/
|
|
6874
|
+
async queryTosNotices(noticeIds) {
|
|
6875
|
+
if (!this._connected) throw new Error('Not connected');
|
|
6876
|
+
if (!Array.isArray(noticeIds)) throw new Error('queryTosNotices: noticeIds must be an array');
|
|
6877
|
+
|
|
6878
|
+
const ids = noticeIds.map(id => String(id)).filter(Boolean);
|
|
6879
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
6880
|
+
id: this._genMsgId(),
|
|
6881
|
+
to: 's.whatsapp.net',
|
|
6882
|
+
type: 'get',
|
|
6883
|
+
xmlns: 'tos'
|
|
6884
|
+
}, [new BinaryNode('request', {},
|
|
6885
|
+
ids.map(id => new BinaryNode('notice', { id }, null)))]));
|
|
6886
|
+
|
|
6887
|
+
if (!resp) throw new Error('queryTosNotices: no reply from server (IQ timed out)');
|
|
6888
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
6889
|
+
const errNode = findChild(resp, 'error');
|
|
6890
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
6891
|
+
throw new Error('queryTosNotices: server rejected the request' +
|
|
6892
|
+
(code ? ' (' + code + ')' : ''));
|
|
6893
|
+
}
|
|
6894
|
+
|
|
6895
|
+
const parsed = this._parseTosNotices(findChild(resp, 'tos'));
|
|
6896
|
+
for (const n of parsed.notices) this._tosAccepted.set(n.id, n.accepted);
|
|
6897
|
+
_whaDbg('[DBG] TOS_QUERY asked=' + ids.length + ' answered=' + parsed.notices.length +
|
|
6898
|
+
' outstanding=' + parsed.notices.filter(n => !n.accepted).length);
|
|
6899
|
+
return parsed;
|
|
6900
|
+
}
|
|
6901
|
+
|
|
6902
|
+
// <tos refresh="86400"><notice id="..." [state="false"]/>...</tos>
|
|
6903
|
+
_parseTosNotices(tosNode) {
|
|
6904
|
+
const out = { refresh: null, notices: [] };
|
|
6905
|
+
if (!tosNode) return out;
|
|
6906
|
+
|
|
6907
|
+
const refresh = Number(tosNode.attrs && tosNode.attrs.refresh);
|
|
6908
|
+
if (Number.isFinite(refresh) && refresh > 0) out.refresh = refresh;
|
|
6909
|
+
|
|
6910
|
+
const children = Array.isArray(tosNode.content) ? tosNode.content : [];
|
|
6911
|
+
for (const child of children) {
|
|
6912
|
+
if (!child || child.description !== 'notice' || !child.attrs || !child.attrs.id) continue;
|
|
6913
|
+
const state = child.attrs.state;
|
|
6914
|
+
// Only an explicit "false" means not accepted; see the note above.
|
|
6915
|
+
out.notices.push({
|
|
6916
|
+
id: String(child.attrs.id),
|
|
6917
|
+
accepted: !(state === 'false' || state === false)
|
|
6918
|
+
});
|
|
6919
|
+
}
|
|
6920
|
+
return out;
|
|
6921
|
+
}
|
|
6922
|
+
|
|
6923
|
+
/**
|
|
6924
|
+
* Accept one or more notices.
|
|
6925
|
+
*
|
|
6926
|
+
* @param {string[]} noticeIds
|
|
6927
|
+
* @returns {Promise<{ok: true, raw: any}>}
|
|
6928
|
+
*/
|
|
6929
|
+
async acceptTosNotices(noticeIds) {
|
|
6930
|
+
if (!this._connected) throw new Error('Not connected');
|
|
6931
|
+
if (!Array.isArray(noticeIds) || noticeIds.length === 0) {
|
|
6932
|
+
throw new Error('acceptTosNotices: noticeIds must be a non-empty array');
|
|
6933
|
+
}
|
|
6934
|
+
|
|
6935
|
+
const ids = noticeIds.map(id => String(id)).filter(Boolean);
|
|
6936
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
6937
|
+
id: this._genMsgId(),
|
|
6938
|
+
to: 's.whatsapp.net',
|
|
6939
|
+
type: 'set',
|
|
6940
|
+
xmlns: 'tos'
|
|
6941
|
+
}, [new BinaryNode('request', { type: 'session_update' },
|
|
6942
|
+
ids.map(id => new BinaryNode('notice', { id }, null)))]));
|
|
6943
|
+
|
|
6944
|
+
if (!resp) throw new Error('acceptTosNotices: no reply from server (IQ timed out)');
|
|
6945
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
6946
|
+
const errNode = findChild(resp, 'error');
|
|
6947
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
6948
|
+
throw new Error('acceptTosNotices: server rejected the request' +
|
|
6949
|
+
(code ? ' (' + code + ')' : ''));
|
|
6950
|
+
}
|
|
6951
|
+
|
|
6952
|
+
for (const id of ids) this._tosAccepted.set(id, true);
|
|
6953
|
+
_whaDbg('[DBG] TOS_ACCEPT ' + ids.join(','));
|
|
6954
|
+
return { ok: true, raw: resp };
|
|
6955
|
+
}
|
|
6956
|
+
|
|
6957
|
+
/**
|
|
6958
|
+
* Clear the accepted state of one notice, so the server asks for it again.
|
|
6959
|
+
*
|
|
6960
|
+
* @param {string} noticeId
|
|
6961
|
+
* @returns {Promise<{ok: true, raw: any}>}
|
|
6962
|
+
*/
|
|
6963
|
+
async clearTosNotice(noticeId) {
|
|
6964
|
+
if (!this._connected) throw new Error('Not connected');
|
|
6965
|
+
const id = String(noticeId || '');
|
|
6966
|
+
if (!id) throw new Error('clearTosNotice: noticeId is required');
|
|
6967
|
+
|
|
6968
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
6969
|
+
id: this._genMsgId(),
|
|
6970
|
+
to: 's.whatsapp.net',
|
|
6971
|
+
type: 'set',
|
|
6972
|
+
xmlns: 'tos'
|
|
6973
|
+
}, [new BinaryNode('delete', { id }, null)]));
|
|
6974
|
+
|
|
6975
|
+
if (!resp) throw new Error('clearTosNotice: no reply from server (IQ timed out)');
|
|
6976
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
6977
|
+
const errNode = findChild(resp, 'error');
|
|
6978
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
6979
|
+
throw new Error('clearTosNotice: server rejected the request' +
|
|
6980
|
+
(code ? ' (' + code + ')' : ''));
|
|
6981
|
+
}
|
|
6982
|
+
|
|
6983
|
+
this._tosAccepted.set(id, false);
|
|
6984
|
+
_whaDbg('[DBG] TOS_CLEAR ' + id);
|
|
6985
|
+
return { ok: true, raw: resp };
|
|
6986
|
+
}
|
|
6987
|
+
|
|
6988
|
+
/**
|
|
6989
|
+
* Whether a notice is known to have been accepted.
|
|
6990
|
+
*
|
|
6991
|
+
* `null` means this session has not asked about it — which is not the same
|
|
6992
|
+
* as "not accepted", and a caller deciding whether to prompt needs to be
|
|
6993
|
+
* able to tell those apart.
|
|
6994
|
+
*
|
|
6995
|
+
* @param {string} noticeId
|
|
6996
|
+
* @returns {boolean|null}
|
|
6997
|
+
*/
|
|
6998
|
+
isTosAccepted(noticeId) {
|
|
6999
|
+
const known = this._tosAccepted.get(String(noticeId));
|
|
7000
|
+
return known === undefined ? null : known;
|
|
7001
|
+
}
|
|
7002
|
+
|
|
7003
|
+
// The server pushing a notice's state at us, rather than us asking:
|
|
7004
|
+
// <notification type="account_sync"><tos><notice id=".." state=".."/></tos>
|
|
7005
|
+
//
|
|
7006
|
+
// This is how an acceptance made on another device reaches this one. It only
|
|
7007
|
+
// ever adds to what is known — a push naming three notices says nothing
|
|
7008
|
+
// about a fourth.
|
|
7009
|
+
_handleTosNotification(tosNode) {
|
|
7010
|
+
const parsed = this._parseTosNotices(tosNode);
|
|
7011
|
+
if (parsed.notices.length === 0) return;
|
|
7012
|
+
for (const n of parsed.notices) this._tosAccepted.set(n.id, n.accepted);
|
|
7013
|
+
_whaDbg('[DBG] TOS_NOTIFICATION ' + parsed.notices
|
|
7014
|
+
.map(n => n.id + '=' + (n.accepted ? 'accepted' : 'outstanding')).join(' '));
|
|
7015
|
+
this.emit('tos_notices', { notices: parsed.notices });
|
|
7016
|
+
}
|
|
7017
|
+
|
|
6498
7018
|
// ─── Status / Stories ─────────────────────────────────────────────────────
|
|
6499
7019
|
|
|
6500
7020
|
// Who your Status posts go to.
|
package/lib/CompanionPairing.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// from that account.
|
|
19
19
|
|
|
20
20
|
const crypto = require('crypto');
|
|
21
|
-
const curveJs = require('
|
|
21
|
+
const curveJs = require('./curve');
|
|
22
22
|
|
|
23
23
|
const WA_ADV_ACCOUNT_SIG_PREFIX = Buffer.from([6, 0]);
|
|
24
24
|
const WA_ADV_DEVICE_SIG_PREFIX = Buffer.from([6, 1]);
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
const crypto = require('crypto');
|
|
29
|
+
const { hkdfSha256 } = require('./hkdf');
|
|
29
30
|
const https = require('https');
|
|
30
31
|
const http = require('http');
|
|
31
32
|
const zlib = require('zlib');
|
|
32
33
|
const fs = require('fs');
|
|
33
34
|
const path = require('path');
|
|
34
35
|
|
|
35
|
-
const { hkdf } = require('@noble/hashes/hkdf');
|
|
36
36
|
const { sha256 } = require('@noble/hashes/sha256');
|
|
37
37
|
|
|
38
38
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -442,7 +442,7 @@ function decodeHistorySync(buf) {
|
|
|
442
442
|
function deriveHistoryKeys(mediaKeyBuf) {
|
|
443
443
|
const ikm = Buffer.isBuffer(mediaKeyBuf) ? mediaKeyBuf : Buffer.from(mediaKeyBuf);
|
|
444
444
|
const info = Buffer.from(HISTORY_HKDF_INFO, 'utf8');
|
|
445
|
-
const expanded =
|
|
445
|
+
const expanded = hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
|
|
446
446
|
return {
|
|
447
447
|
iv: expanded.slice(0, IV_LEN),
|
|
448
448
|
cipherKey: expanded.slice(IV_LEN, IV_LEN + KEY_LEN),
|
package/lib/MediaService.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const { hkdfSha256 } = require('./hkdf');
|
|
4
5
|
const https = require('https');
|
|
5
6
|
const http = require('http');
|
|
6
7
|
const fs = require('fs');
|
|
7
8
|
const path = require('path');
|
|
8
|
-
const { hkdf } = require('@noble/hashes/hkdf');
|
|
9
9
|
const { sha256 } = require('@noble/hashes/sha256');
|
|
10
10
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
11
11
|
|
|
@@ -69,7 +69,7 @@ const MAC_LEN = 10;
|
|
|
69
69
|
function deriveMediaKeyData(mediaKey, keyName) {
|
|
70
70
|
const ikm = Buffer.isBuffer(mediaKey) ? mediaKey : Buffer.from(mediaKey);
|
|
71
71
|
const info = Buffer.from(keyName, 'utf8');
|
|
72
|
-
return
|
|
72
|
+
return hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function encryptMedia(plaintext, mediaKey, keyName) {
|
package/lib/PairingCode.js
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
// in normally.
|
|
26
26
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
|
-
const
|
|
28
|
+
const { hkdfSha256 } = require('./hkdf');
|
|
29
|
+
const curveJs = require('./curve');
|
|
29
30
|
|
|
30
31
|
// Crockford base32 minus the ambiguous characters — no I, O, U, and no 0.
|
|
31
32
|
// Five random bytes become exactly eight characters.
|
|
@@ -85,15 +86,12 @@ function aesEncryptGCM(plaintext, key, iv, aad) {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
function hkdf(input, length, info, salt) {
|
|
88
|
-
|
|
89
|
-
const { sha256 } = require('@noble/hashes/sha256');
|
|
90
|
-
return Buffer.from(nobleHkdf(
|
|
91
|
-
sha256,
|
|
89
|
+
return hkdfSha256(
|
|
92
90
|
input,
|
|
93
91
|
salt || Buffer.alloc(32),
|
|
94
92
|
info ? Buffer.from(info, 'utf8') : Buffer.alloc(0),
|
|
95
93
|
length
|
|
96
|
-
)
|
|
94
|
+
);
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
function stripKeyPrefix(pub) {
|