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 CHANGED
@@ -27,6 +27,8 @@ code, and bring the account into being. Both transports, one API.
27
27
  [![Send messages](https://img.shields.io/badge/Send_messages-text_media_polls-34B7F1?style=for-the-badge)](#sending-messages)
28
28
  [![Handle events](https://img.shields.io/badge/Handle_events-incoming_%26_receipts-34B7F1?style=for-the-badge)](#handling-events)
29
29
 
30
+ [![Device attestation](https://img.shields.io/badge/Device_attestation-Play_Integrity_%2B_App_Attest-8E44AD?style=for-the-badge)](#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
+ };