whalibmob 5.10.9 → 5.11.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 +63 -24
- package/lib/Client.js +118 -18
- package/lib/auth-utils.js +357 -330
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -928,55 +928,65 @@ const {
|
|
|
928
928
|
|
|
929
929
|
### `makeCacheableSignalKeyStore`
|
|
930
930
|
|
|
931
|
-
Wraps a `SignalStore` with an in-memory
|
|
931
|
+
Wraps a `SignalStore` with an in-memory cache (5-minute TTL). Reads for sessions, pre-keys, signed pre-keys and identity keys are served from cache on subsequent accesses; `store*` calls write through to both, and `remove*` / `delete*` calls drop the entry.
|
|
932
|
+
|
|
933
|
+
A lookup that finds nothing is **not** cached. Absence is the state most likely to change from underneath — a session about to be built, a pre-key about to be uploaded — so a remembered miss is the one that would hurt.
|
|
932
934
|
|
|
933
935
|
`useClones` is set to `false` so that `SessionRecord` objects — which carry internal state and methods — are returned by reference and never deep-cloned.
|
|
934
936
|
|
|
935
|
-
|
|
937
|
+
Every method the underlying store has is forwarded, including `transaction()` and `isInTransaction()` when present, so the wrapper is a drop-in replacement and safe to stack with `addTransactionCapability`.
|
|
936
938
|
|
|
937
939
|
```js
|
|
938
|
-
const { SignalStore } = require('whalibmob')
|
|
939
|
-
const { makeCacheableSignalKeyStore } = require('whalibmob')
|
|
940
|
+
const { SignalStore, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
940
941
|
|
|
941
|
-
const store = new SignalStore(
|
|
942
|
+
const store = new SignalStore()
|
|
942
943
|
const cached = makeCacheableSignalKeyStore(store)
|
|
943
944
|
|
|
944
945
|
// reads hit cache after first access
|
|
945
|
-
const session = await cached.
|
|
946
|
+
const session = await cached.loadSession('919634847671.0')
|
|
946
947
|
```
|
|
947
948
|
|
|
949
|
+
Call `await cached.flushCache()` to drop everything — after a key rotation, or when another process may have written to the same session file.
|
|
950
|
+
|
|
948
951
|
**When to use:** whenever your `SignalStore` is backed by a remote or disk-based store (database, Redis, file system) and you want to reduce repeated lookups for sessions that haven't changed between sends.
|
|
949
952
|
|
|
950
953
|
### `addTransactionCapability`
|
|
951
954
|
|
|
952
|
-
Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory
|
|
955
|
+
Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory and flushed to the underlying store in one shot when the callback returns — there is no `commit()` to call. Reads check the buffer first, so a transaction sees its own writes. If the callback throws, nothing is written at all and the error reaches the caller.
|
|
953
956
|
|
|
954
|
-
Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a
|
|
957
|
+
Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a `Mutex` with reference-counting per transaction key.
|
|
955
958
|
|
|
956
959
|
```js
|
|
957
|
-
const { addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
960
|
+
const { SignalStore, addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
958
961
|
|
|
959
962
|
// recommended: cache first, then transactions on top
|
|
960
|
-
const base
|
|
961
|
-
const cached
|
|
962
|
-
const txnStore
|
|
963
|
+
const base = new SignalStore()
|
|
964
|
+
const cached = makeCacheableSignalKeyStore(base)
|
|
965
|
+
const txnStore = addTransactionCapability(cached)
|
|
963
966
|
|
|
964
967
|
// inside a send flow
|
|
965
968
|
await txnStore.transaction(async () => {
|
|
966
|
-
// all writes are buffered
|
|
967
|
-
await txnStore.
|
|
968
|
-
await txnStore.
|
|
969
|
-
|
|
970
|
-
})
|
|
969
|
+
// all writes are buffered until this callback returns
|
|
970
|
+
await txnStore.storeSession('919634847671.0', sessionRecord)
|
|
971
|
+
await txnStore.storePreKey(1, preKeyPair)
|
|
972
|
+
}, 'send')
|
|
971
973
|
```
|
|
972
974
|
|
|
973
|
-
|
|
975
|
+
The second argument is a scope: two transactions with different keys run concurrently, two with the same key run one after the other. It defaults to `'default'`. Any key is safe — the transaction locks are kept separate from the locks reads take, so no choice of key can make a transaction wait on itself.
|
|
976
|
+
|
|
977
|
+
Nested `transaction()` calls reuse the enclosing context instead of opening a second one, so an inner transaction does not commit on its own.
|
|
978
|
+
|
|
979
|
+
Stacking order matters: put `makeCacheableSignalKeyStore` below `addTransactionCapability`, so that a transaction which rolls back never reaches the cache. The other order works too — a failed transaction flushes the cache rather than leave it holding writes the store never took — but it throws away good entries to do it.
|
|
974
980
|
|
|
975
981
|
**When to use:** for high-throughput servers that send to many recipients concurrently and need to batch Signal key writes into a single atomic flush per message.
|
|
976
982
|
|
|
977
983
|
### `assertMeId`
|
|
978
984
|
|
|
979
|
-
|
|
985
|
+
Returns the account's JID, or throws an `Error` describing what is missing.
|
|
986
|
+
|
|
987
|
+
Works with both kinds of store — the SMS store from `initAuthCreds` / `createNewStore`, and the companion store from `createNewWebStore`. Only the wording of the error differs, since the way out of "not registered yet" is SMS verification in one case and pairing in the other.
|
|
988
|
+
|
|
989
|
+
Once registered, the JID the server assigned is preferred (`store.me.id`) — on a companion it carries the device suffix, and rebuilding it from the phone number drops that silently. Before registration it throws, including during the pairing window: requesting a pairing code writes a placeholder `me` with no suffix, and that is not treated as being linked.
|
|
980
990
|
|
|
981
991
|
```js
|
|
982
992
|
const { assertMeId } = require('whalibmob')
|
|
@@ -985,7 +995,8 @@ const store = loadStore(sessFile)
|
|
|
985
995
|
|
|
986
996
|
try {
|
|
987
997
|
const jid = assertMeId(store)
|
|
988
|
-
//
|
|
998
|
+
// '919634847671:12@s.whatsapp.net' once connected,
|
|
999
|
+
// '919634847671@s.whatsapp.net' before that
|
|
989
1000
|
console.log('account JID:', jid)
|
|
990
1001
|
} catch (err) {
|
|
991
1002
|
console.error('store is not registered:', err.message)
|
|
@@ -996,7 +1007,9 @@ try {
|
|
|
996
1007
|
|
|
997
1008
|
### `initAuthCreds`
|
|
998
1009
|
|
|
999
|
-
Creates a fresh credential store for the given phone number
|
|
1010
|
+
Creates a fresh credential store for the given phone number — the key pairs, registration ID and device identifiers a number needs before it can even ask for an SMS code. This is what `/reg code` calls.
|
|
1011
|
+
|
|
1012
|
+
It returns everything `createNewStore` does, plus a few fields kept for application code that expects them: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, `processedHistoryMessages` and `advSecretKey`. Those extras live on the object only — `saveStore` does not write them, so they are not there again after a reload. Nothing in the library reads them; treat them as a convenience, not as state.
|
|
1000
1013
|
|
|
1001
1014
|
```js
|
|
1002
1015
|
const { initAuthCreds, saveStore } = require('whalibmob')
|
|
@@ -1013,10 +1026,35 @@ const store = initAuthCreds(phone)
|
|
|
1013
1026
|
saveStore(store, sessFile)
|
|
1014
1027
|
```
|
|
1015
1028
|
|
|
1016
|
-
This is the function
|
|
1029
|
+
This is the function the CLI uses for every new SMS session. Prefer it over `createNewStore` for forward compatibility.
|
|
1017
1030
|
|
|
1018
1031
|
> [!NOTE]
|
|
1019
|
-
> `initAuthCreds` and `createNewStore` produce equivalent stores for
|
|
1032
|
+
> `initAuthCreds` and `createNewStore` produce equivalent stores for every current library operation, and identical files on disk. Use `createNewWebStore` instead when the device will be linked as a companion rather than registered by SMS — that one adds the pairing fields, and its own serialiser persists them.
|
|
1033
|
+
|
|
1034
|
+
The file it writes has exactly these 16 keys:
|
|
1035
|
+
|
|
1036
|
+
```json
|
|
1037
|
+
{
|
|
1038
|
+
"phoneNumber": "40712345678",
|
|
1039
|
+
"noiseKeyPair": { "private": "…", "public": "…" },
|
|
1040
|
+
"identityKeyPair": { "private": "…", "public": "…" },
|
|
1041
|
+
"signedPreKey": { "id": 3649616, "private": "…", "public": "…", "signature": "…" },
|
|
1042
|
+
"registrationId": 2183,
|
|
1043
|
+
"fdid": "2f701f8b-d693-4728-…",
|
|
1044
|
+
"deviceId": "CBIyyJRAokOdYoTYEjTbng==",
|
|
1045
|
+
"identityId": "SE8Q785oful7dGdBgNpwjg==",
|
|
1046
|
+
"advertisingId": "5c97eea9-783f-4fcc-…",
|
|
1047
|
+
"backupToken": "…",
|
|
1048
|
+
"registered": true,
|
|
1049
|
+
"codePending": false,
|
|
1050
|
+
"name": "Boss",
|
|
1051
|
+
"version": "2.26.9.75",
|
|
1052
|
+
"device": { "os": "ios", "platform": 1, "model": "iPhone 15 Pro", "…": "…" },
|
|
1053
|
+
"advIdentity": "…"
|
|
1054
|
+
}
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
`registered` and `codePending` are the two that move: both `false` when the store is created, `codePending` flips to `true` once a code has been requested, and `verifyCode` sets `registered` to `true` and `codePending` back to `false`. `advIdentity` stays `null` until the server sends the signed device identity in `<success>`.
|
|
1020
1058
|
|
|
1021
1059
|
### Recommended Stacking Pattern
|
|
1022
1060
|
|
|
@@ -1036,7 +1074,8 @@ const {
|
|
|
1036
1074
|
let store = loadStore(sessFile) || initAuthCreds(phone)
|
|
1037
1075
|
|
|
1038
1076
|
// 2. build the layered Signal key store
|
|
1039
|
-
const signalStore = new SignalStore(
|
|
1077
|
+
const signalStore = new SignalStore()
|
|
1078
|
+
signalStore.attachFile(sessFile)
|
|
1040
1079
|
const cachedStore = makeCacheableSignalKeyStore(signalStore)
|
|
1041
1080
|
const txnStore = addTransactionCapability(cachedStore)
|
|
1042
1081
|
|
package/lib/Client.js
CHANGED
|
@@ -1968,17 +1968,31 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1968
1968
|
const id = String(attrs.id || '');
|
|
1969
1969
|
const ts = parseInt(attrs.t || '0', 10);
|
|
1970
1970
|
const participant = String(attrs.participant || from);
|
|
1971
|
-
const senderPn
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1971
|
+
const senderPn = attrs.sender_pn ? String(attrs.sender_pn) : null;
|
|
1972
|
+
const senderLid = attrs.sender_lid ? String(attrs.sender_lid) : null;
|
|
1973
|
+
|
|
1974
|
+
// Learn the LID ↔ phone pairing from whichever side the stanza names.
|
|
1975
|
+
//
|
|
1976
|
+
// Decryption needs it: a message addressed by LID has to be able to find a
|
|
1977
|
+
// session filed under the phone JID, and the other way round. The pairing
|
|
1978
|
+
// is only ever visible on a stanza that carries both names, so every such
|
|
1979
|
+
// stanza is worth reading — sender_pn against a LID `from`, and sender_lid
|
|
1980
|
+
// against a phone one.
|
|
1981
|
+
const fromUser = from.split('@')[0].split(':')[0];
|
|
1982
|
+
let learnedLid = null, learnedPn = null;
|
|
1983
|
+
if (senderPn && from.endsWith('@lid')) {
|
|
1984
|
+
learnedLid = fromUser;
|
|
1985
|
+
learnedPn = senderPn.split('@')[0].split(':')[0];
|
|
1986
|
+
} else if (senderLid && !from.endsWith('@lid')) {
|
|
1987
|
+
learnedPn = fromUser;
|
|
1988
|
+
learnedLid = senderLid.split('@')[0].split(':')[0];
|
|
1989
|
+
}
|
|
1990
|
+
if (learnedLid && learnedPn && learnedLid !== learnedPn) {
|
|
1991
|
+
this._lidToPn.set(learnedLid, learnedPn);
|
|
1992
|
+
this._pnToLid.set(learnedPn, learnedLid);
|
|
1993
|
+
// Persist so the mapping survives reconnects / process restarts
|
|
1994
|
+
if (this._signal && this._signal.store && this._signal.store.setLidMapping) {
|
|
1995
|
+
this._signal.store.setLidMapping(learnedPn, learnedLid);
|
|
1982
1996
|
}
|
|
1983
1997
|
}
|
|
1984
1998
|
_whaDbg('[DBG] _handleMessage from=' + from + ' participant=' + participant + ' senderPn=' + senderPn + ' id=' + id);
|
|
@@ -2050,21 +2064,107 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2050
2064
|
this._sendReadReceipt(id, fromRaw, partRaw);
|
|
2051
2065
|
}
|
|
2052
2066
|
|
|
2067
|
+
// The other name this contact answers to, with the device kept.
|
|
2068
|
+
//
|
|
2069
|
+
// A Signal address is user plus device, so the device has to survive the
|
|
2070
|
+
// translation — 250139683860650:5@lid and 40752248147:5@s.whatsapp.net are
|
|
2071
|
+
// the same device, 40752248147:0 is a different one.
|
|
2072
|
+
//
|
|
2073
|
+
// Returns null when there is no mapping for this user yet.
|
|
2074
|
+
_counterpartJid(jid) {
|
|
2075
|
+
if (!jid) return null;
|
|
2076
|
+
const str = String(jid);
|
|
2077
|
+
const user = str.split('@')[0].split(':')[0];
|
|
2078
|
+
if (!user) return null;
|
|
2079
|
+
const colon = str.split('@')[0].indexOf(':');
|
|
2080
|
+
const device = colon >= 0 ? str.split('@')[0].slice(colon) : '';
|
|
2081
|
+
|
|
2082
|
+
if (str.endsWith('@lid')) {
|
|
2083
|
+
const pn = this._lidToPn && this._lidToPn.get(user);
|
|
2084
|
+
return pn ? pn + device + '@s.whatsapp.net' : null;
|
|
2085
|
+
}
|
|
2086
|
+
const lid = this._pnToLid && this._pnToLid.get(user);
|
|
2087
|
+
return lid ? lid + device + '@lid' : null;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Every address this message's session might be filed under, best first.
|
|
2091
|
+
_signalJidCandidates(from, participant, senderPn) {
|
|
2092
|
+
const out = [];
|
|
2093
|
+
const add = (jid) => {
|
|
2094
|
+
if (!jid) return;
|
|
2095
|
+
const str = String(jid);
|
|
2096
|
+
if (str && !out.includes(str)) out.push(str);
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// Unchanged from before: the phone JID the stanza named, or the sender.
|
|
2100
|
+
add(senderPn || ((participant && participant !== from) ? participant : from));
|
|
2101
|
+
// Then the other name for each of them.
|
|
2102
|
+
add(this._counterpartJid(out[0]));
|
|
2103
|
+
add(participant && participant !== from ? participant : null);
|
|
2104
|
+
add(from);
|
|
2105
|
+
add(this._counterpartJid(participant && participant !== from ? participant : from));
|
|
2106
|
+
|
|
2107
|
+
return out;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Try each address in turn and return the first plaintext.
|
|
2111
|
+
//
|
|
2112
|
+
// A failed attempt leaves nothing behind — libsignal either returns the
|
|
2113
|
+
// plaintext or throws, and it only commits the advanced session on success —
|
|
2114
|
+
// so trying the second address after the first misses is safe. The error
|
|
2115
|
+
// reported on total failure is the first one, which is the one about the
|
|
2116
|
+
// address the message actually named.
|
|
2117
|
+
async _decryptWithCandidates(candidates, encType, cipherBuf, id) {
|
|
2118
|
+
let firstErr = null;
|
|
2119
|
+
|
|
2120
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2121
|
+
try {
|
|
2122
|
+
const plaintext = await this._signal.decrypt(candidates[i], encType, cipherBuf);
|
|
2123
|
+
if (i > 0) {
|
|
2124
|
+
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' recovered under ' + candidates[i] +
|
|
2125
|
+
' after ' + candidates[0] + ' had no session');
|
|
2126
|
+
}
|
|
2127
|
+
return plaintext;
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (!firstErr) firstErr = err;
|
|
2130
|
+
// Only a missing session is worth asking a different address about.
|
|
2131
|
+
// A session that exists and refuses the ciphertext is a different
|
|
2132
|
+
// problem, and the answer is a retry receipt, not another lookup.
|
|
2133
|
+
if (!/no matching sessions|no session/i.test(String(err && err.message))) break;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
throw firstErr || new Error('decrypt: no address to try');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2053
2140
|
_decryptDMMessage({ node, from, participant, fromRaw, partRaw, senderPn, id, ts, dmEncNode }) {
|
|
2054
2141
|
const encType = dmEncNode.attrs && dmEncNode.attrs.type;
|
|
2055
2142
|
const cipherBuf = Buffer.isBuffer(dmEncNode.content)
|
|
2056
2143
|
? dmEncNode.content
|
|
2057
2144
|
: Buffer.from(dmEncNode.content || '');
|
|
2058
2145
|
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
|
|
2063
|
-
|
|
2146
|
+
// The same contact is reachable under two names — a phone JID and a LID —
|
|
2147
|
+
// and a Signal address keeps only the user and the device, never the
|
|
2148
|
+
// domain. So 250139683860650:5@lid and 40752248147:5@s.whatsapp.net are
|
|
2149
|
+
// two different addresses for one person, and a session filed under either
|
|
2150
|
+
// one is invisible from the other.
|
|
2151
|
+
//
|
|
2152
|
+
// Which name a message arrives under is the server's choice and it varies
|
|
2153
|
+
// between messages from the same device. When the session was built under
|
|
2154
|
+
// the phone JID and the next message comes addressed by LID, the lookup
|
|
2155
|
+
// finds nothing and decryption fails with "No matching sessions found for
|
|
2156
|
+
// message" — while the very next message from the same contact decrypts.
|
|
2157
|
+
//
|
|
2158
|
+
// The stanza's own name is tried first, so nothing that already works
|
|
2159
|
+
// changes. The counterpart follows, from the LID↔phone mapping we keep.
|
|
2160
|
+
const candidates = this._signalJidCandidates(from, participant, senderPn);
|
|
2161
|
+
const sigJid = candidates[0];
|
|
2064
2162
|
|
|
2065
|
-
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid +
|
|
2163
|
+
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid +
|
|
2164
|
+
(candidates.length > 1 ? ' alts=' + candidates.slice(1).join(',') : '') +
|
|
2165
|
+
' cipherLen=' + cipherBuf.length);
|
|
2066
2166
|
|
|
2067
|
-
this.
|
|
2167
|
+
this._decryptWithCandidates(candidates, encType, cipherBuf, id)
|
|
2068
2168
|
.then(async plaintext => {
|
|
2069
2169
|
this._resolveRetry(id);
|
|
2070
2170
|
const decoded = this._decodeMsg(plaintext);
|