whalibmob 5.30.0 → 5.32.1
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 +236 -0
- package/index.d.ts +203 -0
- package/index.js +24 -0
- package/lib/store/Backend.js +143 -0
- package/lib/store/FileBackend.js +178 -0
- package/lib/store/MemoryBackend.js +73 -0
- package/lib/store/SqliteBackend.js +306 -0
- package/lib/store/migrate.js +82 -0
- package/llms.txt +167 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ code, and bring the account into being. Both transports, one API.
|
|
|
27
27
|
[](#sending-messages)
|
|
28
28
|
[](#handling-events)
|
|
29
29
|
|
|
30
|
+
[](#device-attestation--play-integrity-and-app-attest)
|
|
31
|
+
|
|
30
32
|
</div>
|
|
31
33
|
|
|
32
34
|
##
|
|
@@ -61,6 +63,71 @@ If you want to talk with me contact me on Telegram my username îs @brtyu545
|
|
|
61
63
|
- **The API is identical in both modes.** Everything below — sending, media, groups, events — reads the same whichever way the session was created.
|
|
62
64
|
- No browser, no Selenium, no external runtime. It talks to WhatsApp directly over a **TCP socket** with the **Noise Protocol** handshake.
|
|
63
65
|
- Signal Protocol encryption is **fully inlined** in pure JavaScript — no native binaries, no node-gyp, runs anywhere Node.js runs.
|
|
66
|
+
- **It can prove it is a real handset.** The on-device Frida scripts in [`frida/`](https://github.com/Kunboruto20/whalibmob/tree/main/frida) mint the hardware attestation WhatsApp's registration server expects — Play Integrity and Keystore on Android, App Attest on iOS — and whalibmob folds the result into the registration requests. See [Device Attestation](#device-attestation--play-integrity-and-app-attest).
|
|
67
|
+
|
|
68
|
+
## Device Attestation — Play Integrity and App Attest
|
|
69
|
+
|
|
70
|
+
This is the part most projects stop at, so it is worth saying up front what is
|
|
71
|
+
here and what it does.
|
|
72
|
+
|
|
73
|
+
When the real WhatsApp app registers a number, it does not just send the number
|
|
74
|
+
and the code. It also proves to the server that it is running on a genuine
|
|
75
|
+
handset, by minting a hardware-backed attestation token — **Play Integrity plus
|
|
76
|
+
a Keystore certificate chain** on Android, **DeviceCheck App Attest** on iOS.
|
|
77
|
+
Those tokens cannot be forged off-device: they are signed by a key that lives in
|
|
78
|
+
the phone's secure hardware.
|
|
79
|
+
|
|
80
|
+
whalibmob ships the on-device scripts that obtain them. They run under
|
|
81
|
+
[Frida](https://frida.re) on a **rooted Android phone** or a **jailbroken
|
|
82
|
+
iPhone**, start a small HTTP server on the device, and whalibmob calls it while
|
|
83
|
+
registering:
|
|
84
|
+
|
|
85
|
+
| Platform | Endpoint | What it feeds into the registration request |
|
|
86
|
+
|----------|-------------|----------------------------------------------|
|
|
87
|
+
| Android | `/info` | APK hashes, signature and secret key — the device fingerprint |
|
|
88
|
+
| Android | `/integrity` | `gpia` and its `_gg _gi _gp _ge _ga` companions — the Play Integrity verdict |
|
|
89
|
+
| Android | `/cert` | the `&H=` body signature and the `Authorization` certificate chain |
|
|
90
|
+
| iOS | `/integrity` | the App Attest assertion and its `Authorization` header |
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
# on your computer — build the bundle
|
|
94
|
+
cd frida/android # or: cd frida/ios
|
|
95
|
+
npm install && npm run build # → server_with_dependencies.js
|
|
96
|
+
|
|
97
|
+
# attach it to WhatsApp on the device, with the Frida server running there
|
|
98
|
+
frida -U "WhatsApp" -l server_with_dependencies.js
|
|
99
|
+
|
|
100
|
+
# on the machine running whalibmob — it listens on 1119 (WhatsApp) / 1120 (Business)
|
|
101
|
+
export WA_FRIDA_HOST=192.168.1.50
|
|
102
|
+
wa registration --request-code 919634847671
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The code, and the per-platform setup:
|
|
106
|
+
|
|
107
|
+
- **[`frida/android/server.js`](https://github.com/Kunboruto20/whalibmob/blob/main/frida/android/server.js)** — Play Integrity + Keystore attestation · [setup](https://github.com/Kunboruto20/whalibmob/blob/main/frida/android/README.md)
|
|
108
|
+
- **[`frida/ios/server.js`](https://github.com/Kunboruto20/whalibmob/blob/main/frida/ios/server.js)** — DeviceCheck App Attest · [setup](https://github.com/Kunboruto20/whalibmob/blob/main/frida/ios/README.md)
|
|
109
|
+
- **[`frida/ios/registration/registration.js`](https://github.com/Kunboruto20/whalibmob/blob/main/frida/ios/registration/registration.js)** — prints the registration public key
|
|
110
|
+
- **[`frida/ios/exchange/index.js`](https://github.com/Kunboruto20/whalibmob/blob/main/frida/ios/exchange/index.js)** — hooks `mbedtls_gcm_update` to read the payload
|
|
111
|
+
- **[`lib/Attestation.js`](https://github.com/Kunboruto20/whalibmob/blob/main/lib/Attestation.js)** — the client that talks to the device
|
|
112
|
+
|
|
113
|
+
> [!NOTE]
|
|
114
|
+
> **None of this is required.** Leave `WA_FRIDA_HOST` unset and whalibmob sends
|
|
115
|
+
> the same empty low-trust attestation fields the native client sends when its
|
|
116
|
+
> own integrity minting fails — which the server tolerates. Registration works
|
|
117
|
+
> without a rooted phone anywhere in sight. Attaching a device raises the trust
|
|
118
|
+
> score, which is what helps when a number keeps hitting `no_routes` or a block
|
|
119
|
+
> screen.
|
|
120
|
+
|
|
121
|
+
> [!IMPORTANT]
|
|
122
|
+
> **The Frida scripts themselves are reference material, not a maintained
|
|
123
|
+
> feature.** They are published so that anyone with a rooted or jailbroken
|
|
124
|
+
> device can reproduce what the native app does, and so that the method is on
|
|
125
|
+
> the record. They also need the official app installed **from the Play Store /
|
|
126
|
+
> App Store** — a sideloaded APK will not attest, because the token is bound to
|
|
127
|
+
> the store-signed build. `lib/Attestation.js`, the client side inside
|
|
128
|
+
> whalibmob, is maintained as part of the library.
|
|
129
|
+
|
|
130
|
+
Full walkthrough, with the device prerequisites: [Device Attestation with Frida](#device-attestation-with-frida-optional).
|
|
64
131
|
|
|
65
132
|
## Install
|
|
66
133
|
|
|
@@ -76,6 +143,7 @@ npm install -g whalibmob
|
|
|
76
143
|
|
|
77
144
|
## Index
|
|
78
145
|
|
|
146
|
+
- [Device Attestation — Play Integrity and App Attest](#device-attestation--play-integrity-and-app-attest)
|
|
79
147
|
- [CLI — Getting Started](#cli--getting-started)
|
|
80
148
|
- [Install the CLI](#install-the-cli)
|
|
81
149
|
- [First-Time Setup: Register a Number](#first-time-setup-register-a-number)
|
|
@@ -194,6 +262,9 @@ npm install -g whalibmob
|
|
|
194
262
|
- [One-time Pre-keys](#one-time-pre-keys)
|
|
195
263
|
- [Where the Folder Comes From](#where-the-folder-comes-from)
|
|
196
264
|
- [Working Out the Paths Yourself](#working-out-the-paths-yourself)
|
|
265
|
+
- [Where a Session Is Kept](#where-a-session-is-kept)
|
|
266
|
+
- [One Database Instead of 834 Files](#one-database-instead-of-834-files)
|
|
267
|
+
- [Moving a Session Between Backends](#moving-a-session-between-backends)
|
|
197
268
|
- [Signal Store Utilities](#signal-store-utilities)
|
|
198
269
|
- [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
|
|
199
270
|
- [addTransactionCapability](#addtransactioncapability)
|
|
@@ -3064,6 +3135,171 @@ migrateSession(base, '919634847671')
|
|
|
3064
3135
|
`SESSION_SUFFIXES` is every per-number file the library writes — the list to
|
|
3065
3136
|
copy or delete against if you are moving an account by hand.
|
|
3066
3137
|
|
|
3138
|
+
### Where a Session Is Kept
|
|
3139
|
+
|
|
3140
|
+
Everything above describes files, because files are what whalibmob writes and
|
|
3141
|
+
what it will go on writing unless you say otherwise. **Nothing in this section
|
|
3142
|
+
is something you have to do.** Leave it alone and sessions stay exactly where
|
|
3143
|
+
they have always been, in the JSON files named above.
|
|
3144
|
+
|
|
3145
|
+
What is new is that the place is now a choice. A **backend** is four
|
|
3146
|
+
synchronous operations over a flat key space:
|
|
3147
|
+
|
|
3148
|
+
```js
|
|
3149
|
+
read(key) // the stored text, or null when the key was never written
|
|
3150
|
+
write(key, value) // put it there, replacing whatever was there before
|
|
3151
|
+
remove(key) // take it away; a key that is not there is not an error
|
|
3152
|
+
list(prefix) // every key present that starts with prefix
|
|
3153
|
+
```
|
|
3154
|
+
|
|
3155
|
+
The keys are logical names rather than file names — `auth`, `signal`,
|
|
3156
|
+
`sender-key`, `tc-token`, `device-cache`, `lid-mapping`,
|
|
3157
|
+
`lid-reverse-mapping`, `history`, `messages`, `app-state`, `app-state-keys`,
|
|
3158
|
+
and `` `pre-key/${id}` `` for each of the 812 one-time pre-keys.
|
|
3159
|
+
|
|
3160
|
+
Three implementations ship with the package:
|
|
3161
|
+
|
|
3162
|
+
| Backend | Where the state goes | Needs |
|
|
3163
|
+
|---|---|---|
|
|
3164
|
+
| `FileBackend` | JSON files — what the library has always written | nothing; this is the default |
|
|
3165
|
+
| `SqliteBackend` | one database file | Node 22.5+, or `better-sqlite3` |
|
|
3166
|
+
| `MemoryBackend` | nowhere; gone when the process ends | nothing |
|
|
3167
|
+
|
|
3168
|
+
```js
|
|
3169
|
+
const { FileBackend } = require('whalibmob')
|
|
3170
|
+
|
|
3171
|
+
const backend = new FileBackend({
|
|
3172
|
+
dir: '/home/you/.waSession/919634847671',
|
|
3173
|
+
phone: '919634847671'
|
|
3174
|
+
})
|
|
3175
|
+
|
|
3176
|
+
backend.write('auth', JSON.stringify(creds))
|
|
3177
|
+
backend.read('auth') // the text, or null
|
|
3178
|
+
backend.list() // ['auth', 'pre-key/1', 'signal', …]
|
|
3179
|
+
backend.list('pre-key/') // just the pre-keys
|
|
3180
|
+
backend.remove('pre-key/42')
|
|
3181
|
+
backend.fileFor('signal') // …/919634847671.signal.json
|
|
3182
|
+
```
|
|
3183
|
+
|
|
3184
|
+
`FileBackend` writes the same names in the same places as every release before
|
|
3185
|
+
it, so a session written by whalibmob 5.30 opens through it untouched and one
|
|
3186
|
+
written through it opens in 5.30. The only change is that writes now go to a
|
|
3187
|
+
temporary file and are renamed into place, so a process that dies mid-write
|
|
3188
|
+
leaves the previous state intact instead of half a file.
|
|
3189
|
+
|
|
3190
|
+
> [!IMPORTANT]
|
|
3191
|
+
> **A number has two halves, and they are separate sessions.** The one
|
|
3192
|
+
> registered over the Mobile API and the companion linked over the Web API
|
|
3193
|
+
> never share state — in particular they have separate pre-key id spaces, and
|
|
3194
|
+
> mixing them hands out two different keys under one id and breaks decryption.
|
|
3195
|
+
> A backend covers **one** half, chosen by `web`:
|
|
3196
|
+
>
|
|
3197
|
+
> ```js
|
|
3198
|
+
> const mobile = new FileBackend({ dir, phone, web: false }) // default
|
|
3199
|
+
> const web = new FileBackend({ dir, phone, web: true })
|
|
3200
|
+
> ```
|
|
3201
|
+
>
|
|
3202
|
+
> Both take the same keys and keep entirely separate values.
|
|
3203
|
+
|
|
3204
|
+
> [!NOTE]
|
|
3205
|
+
> **The client does not accept a backend yet.** `new WhalibmobClient({ … })`
|
|
3206
|
+
> takes `sessionDir` and writes files, as it always has; the modules that hold
|
|
3207
|
+
> session state still do their own file I/O. The backends are usable on their
|
|
3208
|
+
> own — for reading, inspecting, copying or moving a session — and wiring them
|
|
3209
|
+
> through the client is the next step. Nothing here changes how a session is
|
|
3210
|
+
> created or connected today.
|
|
3211
|
+
|
|
3212
|
+
### One Database Instead of 834 Files
|
|
3213
|
+
|
|
3214
|
+
A number's state is 22 named files plus a file for each of its 812 one-time
|
|
3215
|
+
pre-keys. On a laptop nobody notices. On a phone under Termux, on a container
|
|
3216
|
+
with a small inode budget, or with fifty numbers in one folder, it is 40 000
|
|
3217
|
+
files whose directory has to be read every time the pre-key pool is counted.
|
|
3218
|
+
|
|
3219
|
+
`SqliteBackend` is the same state as a handful of rows:
|
|
3220
|
+
|
|
3221
|
+
```js
|
|
3222
|
+
const { SqliteBackend } = require('whalibmob')
|
|
3223
|
+
|
|
3224
|
+
const db = new SqliteBackend({
|
|
3225
|
+
path: '/home/you/.waSession/sessions.sqlite',
|
|
3226
|
+
phone: '919634847671'
|
|
3227
|
+
})
|
|
3228
|
+
|
|
3229
|
+
db.write('auth', JSON.stringify(creds))
|
|
3230
|
+
db.read('auth')
|
|
3231
|
+
db.list('pre-key/')
|
|
3232
|
+
db.driver // 'node:sqlite' or 'better-sqlite3'
|
|
3233
|
+
db.close() // let go of the file
|
|
3234
|
+
```
|
|
3235
|
+
|
|
3236
|
+
**What it needs.** Node ships SQLite of its own from **22.5.0** as
|
|
3237
|
+
`node:sqlite`, and that is what this uses when it is there — nothing to
|
|
3238
|
+
install, nothing for node-gyp to fail at, and Termux stays a place whalibmob
|
|
3239
|
+
runs. On older Node it falls back to `better-sqlite3` if that is installed:
|
|
3240
|
+
|
|
3241
|
+
```sh
|
|
3242
|
+
npm install better-sqlite3 # only on Node older than 22.5
|
|
3243
|
+
```
|
|
3244
|
+
|
|
3245
|
+
Neither is a dependency of this package. On a runtime with neither, the
|
|
3246
|
+
constructor throws and says which of the two to reach for — and `FileBackend`
|
|
3247
|
+
goes on needing nothing at all.
|
|
3248
|
+
|
|
3249
|
+
One file holds as many numbers and halves as you like, while each backend sees
|
|
3250
|
+
only its own slice:
|
|
3251
|
+
|
|
3252
|
+
```js
|
|
3253
|
+
const mobile = new SqliteBackend({ path: file, phone: '919634847671' })
|
|
3254
|
+
const web = new SqliteBackend({ path: file, phone: '919634847671', web: true })
|
|
3255
|
+
const other = new SqliteBackend({ path: file, phone: '40712345678' })
|
|
3256
|
+
|
|
3257
|
+
SqliteBackend.sessionsIn(file)
|
|
3258
|
+
// [ { phone: '40712345678', half: 'mobile', web: false },
|
|
3259
|
+
// { phone: '919634847671', half: 'mobile', web: false },
|
|
3260
|
+
// { phone: '919634847671', half: 'web', web: true } ]
|
|
3261
|
+
```
|
|
3262
|
+
|
|
3263
|
+
The file handle is shared between the backends opened on it and closed once
|
|
3264
|
+
the last of them calls `close()`, so closing one does not pull the file out
|
|
3265
|
+
from under the others. Calling `close()` twice is harmless.
|
|
3266
|
+
|
|
3267
|
+
The database runs in WAL mode, so a client flushing its Signal store and
|
|
3268
|
+
another reading the pre-key pool do not block each other. Values are stored as
|
|
3269
|
+
blobs rather than text: a credential blob carrying a NUL byte is truncated by
|
|
3270
|
+
a text binding and the session then loads with keys that are wrong from that
|
|
3271
|
+
byte on, which is the worst way for a bug to present.
|
|
3272
|
+
|
|
3273
|
+
### Moving a Session Between Backends
|
|
3274
|
+
|
|
3275
|
+
Nothing is removed and the source is never modified, so if the result is not
|
|
3276
|
+
what you wanted the old files are still there to go back to:
|
|
3277
|
+
|
|
3278
|
+
```js
|
|
3279
|
+
const { FileBackend, SqliteBackend, copySession, compareSessions } = require('whalibmob')
|
|
3280
|
+
|
|
3281
|
+
const files = new FileBackend({ dir: '/home/you/.waSession/919634847671',
|
|
3282
|
+
phone: '919634847671' })
|
|
3283
|
+
const db = new SqliteBackend({ path: '/home/you/.waSession/sessions.sqlite',
|
|
3284
|
+
phone: '919634847671' })
|
|
3285
|
+
|
|
3286
|
+
const moved = copySession(files, db)
|
|
3287
|
+
// { copied: ['auth', 'pre-key/1', …], skipped: [], bytes: 1284 }
|
|
3288
|
+
|
|
3289
|
+
const check = compareSessions(files, db)
|
|
3290
|
+
// { ok: true, missing: [], differing: [], extra: [] }
|
|
3291
|
+
|
|
3292
|
+
db.close()
|
|
3293
|
+
```
|
|
3294
|
+
|
|
3295
|
+
`compareSessions` reads both sides back rather than trusting that the copy
|
|
3296
|
+
said so — run it before deleting anything. A key the destination already holds
|
|
3297
|
+
is left alone, so an interrupted copy is safe to run again; pass
|
|
3298
|
+
`{ overwrite: true }` when you do mean to replace what is there.
|
|
3299
|
+
|
|
3300
|
+
Do both halves of a number separately — `web: false` and `web: true` are two
|
|
3301
|
+
sessions and a copy of one is not a copy of the other.
|
|
3302
|
+
|
|
3067
3303
|
## Signal Store Utilities
|
|
3068
3304
|
|
|
3069
3305
|
`auth-utils` is a collection of optional helpers for power users who manage their own `SignalStore` instances directly (e.g. custom storage backends, multi-account servers).
|
package/index.d.ts
CHANGED
|
@@ -1297,6 +1297,180 @@ export declare const SessionPaths: {
|
|
|
1297
1297
|
SHARED_FILES: string[];
|
|
1298
1298
|
};
|
|
1299
1299
|
|
|
1300
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1301
|
+
// Session storage
|
|
1302
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* A key under which a piece of session state is kept — one of the fixed names
|
|
1306
|
+
* (`'auth'`, `'signal'`, …) or `` `pre-key/${id}` `` for one one-time pre-key.
|
|
1307
|
+
*/
|
|
1308
|
+
export type StorageKey = string;
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Where a session's state is kept.
|
|
1312
|
+
*
|
|
1313
|
+
* Four synchronous operations over a flat key space. `FileBackend` is the
|
|
1314
|
+
* implementation the library has always used and remains the default; anything
|
|
1315
|
+
* satisfying this interface can take its place.
|
|
1316
|
+
*
|
|
1317
|
+
* Synchronous on purpose: the Signal store flushes from `exit` and `SIGTERM`
|
|
1318
|
+
* handlers, where an awaited write does not land.
|
|
1319
|
+
*/
|
|
1320
|
+
export interface StorageBackend {
|
|
1321
|
+
/** The stored text, or `null` when the key has never been written. */
|
|
1322
|
+
read(key: StorageKey): string | null;
|
|
1323
|
+
/** Store `value` under `key`, replacing whatever was there. */
|
|
1324
|
+
write(key: StorageKey, value: string): void;
|
|
1325
|
+
/** Remove `key`. A key that is not there is not an error. */
|
|
1326
|
+
remove(key: StorageKey): void;
|
|
1327
|
+
/** Every key present that starts with `prefix` (all of them when omitted). */
|
|
1328
|
+
list(prefix?: string): StorageKey[];
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Options for {@link FileBackend}. */
|
|
1332
|
+
export interface FileBackendOptions {
|
|
1333
|
+
/** The directory this session's files live in. */
|
|
1334
|
+
dir: string;
|
|
1335
|
+
/** The number. Non-digits are stripped. */
|
|
1336
|
+
phone: string;
|
|
1337
|
+
/** The companion (Web API) half rather than the mobile one. Default `false`. */
|
|
1338
|
+
web?: boolean;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* The session on disk as JSON files — what whalibmob has always written, under
|
|
1343
|
+
* the same names, in the same place.
|
|
1344
|
+
*/
|
|
1345
|
+
export declare class FileBackend implements StorageBackend {
|
|
1346
|
+
constructor(opts: FileBackendOptions);
|
|
1347
|
+
readonly dir: string;
|
|
1348
|
+
readonly phone: string;
|
|
1349
|
+
readonly web: boolean;
|
|
1350
|
+
/** The absolute path a key is kept at, whether or not it exists yet. */
|
|
1351
|
+
fileFor(key: StorageKey): string;
|
|
1352
|
+
read(key: StorageKey): string | null;
|
|
1353
|
+
write(key: StorageKey, value: string): void;
|
|
1354
|
+
remove(key: StorageKey): void;
|
|
1355
|
+
list(prefix?: string): StorageKey[];
|
|
1356
|
+
/** key → the part of the file name that follows the number. */
|
|
1357
|
+
static KEY_SUFFIX: Record<string, string>;
|
|
1358
|
+
/** What sits between the stem and a pre-key's id. */
|
|
1359
|
+
static PRE_KEY_INFIX: string;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Options for {@link SqliteBackend}. */
|
|
1363
|
+
export interface SqliteBackendOptions {
|
|
1364
|
+
/** The database file. Created, with its directory, if missing. */
|
|
1365
|
+
path: string;
|
|
1366
|
+
/** The number. Non-digits are stripped. */
|
|
1367
|
+
phone: string;
|
|
1368
|
+
/** The companion (Web API) half rather than the mobile one. Default `false`. */
|
|
1369
|
+
web?: boolean;
|
|
1370
|
+
/** Demand one driver rather than taking whichever is available. */
|
|
1371
|
+
driver?: 'node' | 'better-sqlite3';
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** One number and half of it, as held in a database file. */
|
|
1375
|
+
export interface SqliteSessionRow {
|
|
1376
|
+
phone: string;
|
|
1377
|
+
half: 'mobile' | 'web';
|
|
1378
|
+
web: boolean;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* The session in one database instead of 834 files.
|
|
1383
|
+
*
|
|
1384
|
+
* Uses Node's built-in `node:sqlite` (22.5.0 and later) when it is there, and
|
|
1385
|
+
* `better-sqlite3` when it is not. Neither is a dependency of this package —
|
|
1386
|
+
* on a runtime with neither, the constructor throws saying what to install,
|
|
1387
|
+
* and {@link FileBackend} goes on needing nothing.
|
|
1388
|
+
*
|
|
1389
|
+
* One file holds any number of sessions; a backend addresses one number's one
|
|
1390
|
+
* half of it. The file handle is shared between the backends opened on it and
|
|
1391
|
+
* released when the last of them calls `close()`.
|
|
1392
|
+
*/
|
|
1393
|
+
export declare class SqliteBackend implements StorageBackend {
|
|
1394
|
+
constructor(opts: SqliteBackendOptions);
|
|
1395
|
+
readonly path: string;
|
|
1396
|
+
readonly phone: string;
|
|
1397
|
+
readonly web: boolean;
|
|
1398
|
+
/** Which driver this backend actually opened the file with. */
|
|
1399
|
+
readonly driver: 'node:sqlite' | 'better-sqlite3';
|
|
1400
|
+
read(key: StorageKey): string | null;
|
|
1401
|
+
write(key: StorageKey, value: string): void;
|
|
1402
|
+
remove(key: StorageKey): void;
|
|
1403
|
+
list(prefix?: string): StorageKey[];
|
|
1404
|
+
/** Let go of the database; the file closes once nobody else holds it. */
|
|
1405
|
+
close(): void;
|
|
1406
|
+
/** Every number and half the database file holds. */
|
|
1407
|
+
static sessionsIn(file: string, opts?: { driver?: 'node' | 'better-sqlite3' }): SqliteSessionRow[];
|
|
1408
|
+
/** The schema version this release writes and understands. */
|
|
1409
|
+
static SCHEMA_VERSION: number;
|
|
1410
|
+
/** The table the state lives in. */
|
|
1411
|
+
static TABLE: string;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** What {@link copySession} did. */
|
|
1415
|
+
export interface CopySessionResult {
|
|
1416
|
+
/** Keys written to the destination. */
|
|
1417
|
+
copied: StorageKey[];
|
|
1418
|
+
/** Keys left alone — already present, or gone from the source mid-copy. */
|
|
1419
|
+
skipped: StorageKey[];
|
|
1420
|
+
/** How much was copied, in UTF-8 bytes. */
|
|
1421
|
+
bytes: number;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** What {@link compareSessions} found. */
|
|
1425
|
+
export interface CompareSessionsResult {
|
|
1426
|
+
/** True when both hold the same keys with the same values. */
|
|
1427
|
+
ok: boolean;
|
|
1428
|
+
/** Keys the first has and the second does not. */
|
|
1429
|
+
missing: StorageKey[];
|
|
1430
|
+
/** Keys both have, holding different values. */
|
|
1431
|
+
differing: StorageKey[];
|
|
1432
|
+
/** Keys the second has and the first does not. */
|
|
1433
|
+
extra: StorageKey[];
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Copy every key from one backend to another. The source is not modified, and
|
|
1438
|
+
* a key the destination already holds is left alone unless `overwrite` is set —
|
|
1439
|
+
* so an interrupted copy is safe to run again.
|
|
1440
|
+
*/
|
|
1441
|
+
export declare function copySession(
|
|
1442
|
+
from: StorageBackend,
|
|
1443
|
+
to: StorageBackend,
|
|
1444
|
+
opts?: { overwrite?: boolean }
|
|
1445
|
+
): CopySessionResult;
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Check that two backends hold the same state, reading both sides rather than
|
|
1449
|
+
* trusting that a copy said so. Worth running before deleting the original.
|
|
1450
|
+
*/
|
|
1451
|
+
export declare function compareSessions(
|
|
1452
|
+
a: StorageBackend,
|
|
1453
|
+
b: StorageBackend
|
|
1454
|
+
): CompareSessionsResult;
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* A session held only for the life of the process.
|
|
1458
|
+
*
|
|
1459
|
+
* Nothing survives the run: a number registered against this backend cannot be
|
|
1460
|
+
* recovered, because the keys that proved the registration are gone with it.
|
|
1461
|
+
*/
|
|
1462
|
+
export declare class MemoryBackend implements StorageBackend {
|
|
1463
|
+
constructor();
|
|
1464
|
+
read(key: StorageKey): string | null;
|
|
1465
|
+
write(key: StorageKey, value: string): void;
|
|
1466
|
+
remove(key: StorageKey): void;
|
|
1467
|
+
list(prefix?: string): StorageKey[];
|
|
1468
|
+
/** How many keys are held. Not part of the contract. */
|
|
1469
|
+
readonly size: number;
|
|
1470
|
+
/** Drop everything. Not part of the contract. */
|
|
1471
|
+
clear(): void;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1300
1474
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1301
1475
|
// Device
|
|
1302
1476
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -1432,6 +1606,35 @@ export interface LibModule {
|
|
|
1432
1606
|
[name: string]: any;
|
|
1433
1607
|
}
|
|
1434
1608
|
|
|
1609
|
+
/**
|
|
1610
|
+
* `whalibmob/lib/store/Backend` — the storage contract itself: the key names a
|
|
1611
|
+
* backend has to honour, and the checks that go with them. The implementations
|
|
1612
|
+
* are {@link FileBackend} and {@link MemoryBackend}, declared above.
|
|
1613
|
+
*/
|
|
1614
|
+
export declare const StoreBackend: {
|
|
1615
|
+
/** Every key that is a fixed name rather than one generated per record. */
|
|
1616
|
+
KEYS: readonly string[];
|
|
1617
|
+
/** The prefix the per-record pre-key keys are built on: `'pre-key/'`. */
|
|
1618
|
+
PRE_KEY_PREFIX: string;
|
|
1619
|
+
/** The key one pre-key id is stored under. Throws on a non-integer id. */
|
|
1620
|
+
preKeyKey(id: number): StorageKey;
|
|
1621
|
+
/** The id back out of a pre-key key, or `null` when the key is not one. */
|
|
1622
|
+
preKeyId(key: StorageKey): number | null;
|
|
1623
|
+
/** Whether a string is a key every backend must accept. */
|
|
1624
|
+
isValidKey(key: unknown): boolean;
|
|
1625
|
+
/** Throw unless `backend` implements the contract. Returns it when it does. */
|
|
1626
|
+
assertBackend<T>(backend: T, what?: string): T;
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* `whalibmob/lib/store/migrate` — moving a session from one backend to another
|
|
1631
|
+
* and checking that it landed. Both members are also exported flat, above.
|
|
1632
|
+
*/
|
|
1633
|
+
export declare const StoreMigrate: {
|
|
1634
|
+
copySession: typeof copySession;
|
|
1635
|
+
compareSessions: typeof compareSessions;
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1435
1638
|
/**
|
|
1436
1639
|
* `whalibmob/lib/MediaService` — the encryption, upload and download beneath
|
|
1437
1640
|
* `client.downloadMedia()`. Everything it exports is typed.
|
package/index.js
CHANGED
|
@@ -102,6 +102,16 @@ const {
|
|
|
102
102
|
|
|
103
103
|
const { encodeWAM, BinaryInfo, WEB_EVENTS, WEB_GLOBALS } = WAM;
|
|
104
104
|
|
|
105
|
+
// Where a session's state goes. FileBackend is what the library has always
|
|
106
|
+
// done — JSON files, same names — and is the default; anything implementing
|
|
107
|
+
// the same four methods can take its place. See lib/store/Backend.js.
|
|
108
|
+
const StoreBackend = require('./lib/store/Backend');
|
|
109
|
+
const { FileBackend } = require('./lib/store/FileBackend');
|
|
110
|
+
const { MemoryBackend } = require('./lib/store/MemoryBackend');
|
|
111
|
+
const { SqliteBackend } = require('./lib/store/SqliteBackend');
|
|
112
|
+
const StoreMigrate = require('./lib/store/migrate');
|
|
113
|
+
const { copySession, compareSessions } = StoreMigrate;
|
|
114
|
+
|
|
105
115
|
// ─── The namespaces ──────────────────────────────────────────────────────────
|
|
106
116
|
//
|
|
107
117
|
// Every module of lib/, whole, under a name of its own — see the note at the
|
|
@@ -229,6 +239,20 @@ module.exports = {
|
|
|
229
239
|
webStoreFileFor,
|
|
230
240
|
listSessions,
|
|
231
241
|
migrateSession,
|
|
242
|
+
// Where a session's state is kept. FileBackend is the default and writes the
|
|
243
|
+
// JSON files whalibmob has always written; MemoryBackend keeps a session only
|
|
244
|
+
// for the life of the process. Both satisfy the contract in StoreBackend.
|
|
245
|
+
StoreBackend,
|
|
246
|
+
FileBackend,
|
|
247
|
+
MemoryBackend,
|
|
248
|
+
// One database instead of 834 files. Needs Node 22.5+ for its built-in
|
|
249
|
+
// node:sqlite, or better-sqlite3 installed; neither is a dependency, and
|
|
250
|
+
// FileBackend stays the default that needs nothing.
|
|
251
|
+
SqliteBackend,
|
|
252
|
+
// Moving a session from one backend to another, and checking that it landed.
|
|
253
|
+
StoreMigrate,
|
|
254
|
+
copySession,
|
|
255
|
+
compareSessions,
|
|
232
256
|
// Device config — reads WA_OS / WA_DEVICE / WA_DEVICE_* from process.env
|
|
233
257
|
getDeviceConfig,
|
|
234
258
|
// Store helpers
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Where a session's state is kept.
|
|
4
|
+
//
|
|
5
|
+
// Every piece of state a number owns — the credentials, the Signal sessions,
|
|
6
|
+
// the sender keys, the app state, the pre-keys — is a named blob of text. Until
|
|
7
|
+
// now each of them knew it was a file and wrote itself to disk with its own
|
|
8
|
+
// fs.writeFileSync. That works, and it is what whalibmob still does; what it
|
|
9
|
+
// cannot do is be anything other than a file. A session that would rather live
|
|
10
|
+
// in one SQLite database than in 834 files has nowhere to say so.
|
|
11
|
+
//
|
|
12
|
+
// A backend is the one thing standing between the session and wherever its
|
|
13
|
+
// state actually goes. It handles four operations on a flat key space:
|
|
14
|
+
//
|
|
15
|
+
// read(key) the blob, or null when there is none
|
|
16
|
+
// write(key, text) put it there, replacing whatever was there before
|
|
17
|
+
// remove(key) take it away; a key that is not there is not an error
|
|
18
|
+
// list(prefix) every key that starts with prefix
|
|
19
|
+
//
|
|
20
|
+
// That is the whole contract. Anything that can do those four can hold a
|
|
21
|
+
// session, and the session never learns which one it got.
|
|
22
|
+
//
|
|
23
|
+
// ─── Keys ───────────────────────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Keys are logical names, not file names. `signal` is the Signal snapshot
|
|
26
|
+
// whether it ends up as 919634847671.signal.json, a row in a table, or a value
|
|
27
|
+
// under a Redis hash — the backend decides. A key naming a file would put the
|
|
28
|
+
// file back into the contract and leave every other backend translating paths
|
|
29
|
+
// it has no use for.
|
|
30
|
+
//
|
|
31
|
+
// The names are fixed, because a session written by one backend has to be
|
|
32
|
+
// readable by the next:
|
|
33
|
+
//
|
|
34
|
+
// auth the credentials, the store itself
|
|
35
|
+
// signal Signal sessions, identities, signed pre-keys
|
|
36
|
+
// sender-key group sender keys
|
|
37
|
+
// tc-token trusted-contact tokens
|
|
38
|
+
// device-cache the device list per contact
|
|
39
|
+
// lid-mapping phone → LID
|
|
40
|
+
// lid-reverse-mapping LID → phone
|
|
41
|
+
// history the history-sync backlog
|
|
42
|
+
// messages the message archive
|
|
43
|
+
// app-state app-state collections
|
|
44
|
+
// app-state-keys app-state sync keys
|
|
45
|
+
// pre-key/<id> one one-time pre-key, by id
|
|
46
|
+
//
|
|
47
|
+
// pre-key/<id> is the only key that is generated rather than named, and the
|
|
48
|
+
// only reason list() exists: there are 812 of them and they are asked for as a
|
|
49
|
+
// group. Hence the slash — a backend that wants to put them somewhere of their
|
|
50
|
+
// own has the prefix to key on, and `list('pre-key/')` is the way to find them
|
|
51
|
+
// all again.
|
|
52
|
+
//
|
|
53
|
+
// ─── Sync, not async ────────────────────────────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// read/write/remove/list return values, not promises. Everything that writes
|
|
56
|
+
// session state in whalibmob writes it synchronously today, including the exit
|
|
57
|
+
// and SIGTERM handlers that flush the Signal store on the way out of the
|
|
58
|
+
// process — a handler that awaits is a handler whose write does not land. An
|
|
59
|
+
// async contract would mean rewriting all of that, and for what SQLite offers
|
|
60
|
+
// it buys nothing: better-sqlite3 is synchronous by design.
|
|
61
|
+
//
|
|
62
|
+
// It does rule out a backend that is a network round trip, Redis among them.
|
|
63
|
+
// That is a real limit and it is the price of not touching the exit path. When
|
|
64
|
+
// a network-backed store is worth having, it comes with an async contract
|
|
65
|
+
// alongside this one and a major version to go with it.
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} StorageBackend
|
|
69
|
+
* @property {(key: string) => (string|null)} read
|
|
70
|
+
* @property {(key: string, value: string) => void} write
|
|
71
|
+
* @property {(key: string) => void} remove
|
|
72
|
+
* @property {(prefix?: string) => string[]} list
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Every key that is a fixed name rather than one generated per record. */
|
|
76
|
+
const KEYS = Object.freeze([
|
|
77
|
+
'auth',
|
|
78
|
+
'signal',
|
|
79
|
+
'sender-key',
|
|
80
|
+
'tc-token',
|
|
81
|
+
'device-cache',
|
|
82
|
+
'lid-mapping',
|
|
83
|
+
'lid-reverse-mapping',
|
|
84
|
+
'history',
|
|
85
|
+
'messages',
|
|
86
|
+
'app-state',
|
|
87
|
+
'app-state-keys'
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** The prefix the per-record pre-key keys are built on. */
|
|
91
|
+
const PRE_KEY_PREFIX = 'pre-key/';
|
|
92
|
+
|
|
93
|
+
/** The key one pre-key id is stored under. */
|
|
94
|
+
function preKeyKey(id) {
|
|
95
|
+
const n = Number(id);
|
|
96
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
97
|
+
throw new Error('preKeyKey: id must be a non-negative integer, got ' + id);
|
|
98
|
+
}
|
|
99
|
+
return PRE_KEY_PREFIX + n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The id back out of a pre-key key, or null when the key is not one. */
|
|
103
|
+
function preKeyId(key) {
|
|
104
|
+
if (typeof key !== 'string' || !key.startsWith(PRE_KEY_PREFIX)) return null;
|
|
105
|
+
const rest = key.slice(PRE_KEY_PREFIX.length);
|
|
106
|
+
if (!/^\d+$/.test(rest)) return null;
|
|
107
|
+
return Number(rest);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether a string is a key any backend is required to accept. */
|
|
111
|
+
function isValidKey(key) {
|
|
112
|
+
if (typeof key !== 'string' || key.length === 0) return false;
|
|
113
|
+
return KEYS.includes(key) || preKeyId(key) !== null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Throw unless `backend` implements the contract.
|
|
118
|
+
*
|
|
119
|
+
* Called where a backend is accepted from outside, so that a missing method is
|
|
120
|
+
* reported at the point it was handed over rather than hours later, from
|
|
121
|
+
* inside a write, with a session half saved.
|
|
122
|
+
*/
|
|
123
|
+
function assertBackend(backend, what) {
|
|
124
|
+
const label = what || 'backend';
|
|
125
|
+
if (!backend || typeof backend !== 'object') {
|
|
126
|
+
throw new TypeError(label + ' must be an object implementing the storage contract');
|
|
127
|
+
}
|
|
128
|
+
for (const method of ['read', 'write', 'remove', 'list']) {
|
|
129
|
+
if (typeof backend[method] !== 'function') {
|
|
130
|
+
throw new TypeError(label + ' is missing ' + method + '()');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return backend;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
KEYS,
|
|
138
|
+
PRE_KEY_PREFIX,
|
|
139
|
+
preKeyKey,
|
|
140
|
+
preKeyId,
|
|
141
|
+
isValidKey,
|
|
142
|
+
assertBackend
|
|
143
|
+
};
|