Skip to main content

Overview

whatsapp-rust uses a trait-based storage system to persist device state, cryptographic keys, and protocol metadata. The storage layer is split into five domain-specific traits:
  • SignalStore - Signal protocol cryptographic operations (identity keys, sessions, pre-keys, sender keys)
  • AppSyncStore - WhatsApp app state synchronization (sync keys, versions, mutation MACs)
  • ProtocolStore - WhatsApp protocol alignment (SKDM tracking, LID-PN mapping, device registry)
  • MsgSecretStore - messageSecret persistence for poll/edit/bot-reply decryption (added in v0.6)
  • DeviceStore - Device persistence operations
All five traits are combined into the Backend trait for convenience.

The backend trait

Any type implementing all five domain traits automatically implements Backend:
MsgSecretStore became a required member of Backend in v0.6, so a custom backend must implement it (the bundled SqliteStore already does). Its methods have defaults that keep the surface small — see MsgSecretStore for which methods you actually need to write.

SignalStore Trait

Handles Signal protocol cryptographic storage for end-to-end encryption.

Identity Operations

Session Operations

PreKey Operations

Signed PreKey operations

Sender key operations

For group messaging encryption:

AppSyncStore Trait

Handles WhatsApp app state synchronization storage.

Sync key operations

Version Tracking

Mutation MAC Operations

get_mutation_macs was added in v0.6 to collapse the app-state sync’s per-mutation previous-MAC lookups (which were N+1) into a single batched query. It has a default implementation, so custom backends that do not override it keep working without changes — override it with a WHERE index_mac IN (…) query for the performance win. The SQLite store chunks the IN list at 500 entries.An index MAC is always a full HMAC-SHA256 output, so this method’s signature uses inline [u8; 32] arrays (wacore::appstate_sync::IndexMac) instead of Vec<u8> — zero per-MAC heap allocations on either side of the batch lookup. This is a breaking change for custom backends that override get_mutation_macs: update the parameter and return-map key types to [u8; 32]. get_mutation_mac, put_mutation_macs, and delete_mutation_macs are unaffected.

ProtocolStore Trait

Handles WhatsApp protocol alignment and tracking.

Per-device sender key tracking

Tracks sender key distribution status per device in groups, matching WhatsApp Web’s participant.senderKey Map<deviceJid, boolean> model. Each device has a boolean indicating whether it holds a valid sender key (true) or needs a fresh SKDM (false).

LID-PN Mapping

Manages mappings between LID (Locally Indexed Device) and phone numbers:

Base key collision detection

Device Registry

TcToken Storage

Trusted contact privacy tokens:
delete_expired_tc_tokens gained a second sender_cutoff parameter — this is a breaking change for any custom backend that overrides it; update the signature to (&self, token_cutoff: i64, sender_cutoff: i64) -> Result<u32> and prune on both windows independently (see the built-in SqliteStore/InMemoryBackend implementations for the two-filter pattern).touch_tc_token_sender_timestamp and store_received_tc_token are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming privacy_token notification). For store_received_tc_token specifically, a non-atomic override that races two callers can let an older token’s write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write.

Sent message store

Persists sent message payloads for retry handling. Matches WhatsApp Web’s getMessageTable pattern where retry receipts look up the original message from storage.
The take_sent_message method is an atomic read-and-delete operation. Once a message payload is taken for retry, it is removed from storage to prevent double-retry. For status broadcasts where multiple devices may retry, the client re-adds the message after taking it.

MsgSecretStore

The fifth required member of Backend. It persists the 32-byte messageSecret values needed to decrypt later add-ons keyed off an original message: poll votes, poll/event edits, message edits (secret_encrypted_message), and Meta AI / fbid bot replies (<enc type="msmsg">). Secrets are keyed by (chat, sender, msg_id) and carry an absolute expiry so they can be pruned by policy (see messageSecret retention).
get_msg_secret and get_msg_secret_with_ts still return Vec<u8> rather than MessageSecret. Reads don’t carry the same fixed-length guarantee as writes, since the persisted secret BLOB column has no length constraint at the SQL level.
The chat, sender, and msg_id fields are Arc<str> rather than String. This allows buffered batch inserts to clone entries cheaply. The secret field uses the fixed-size MessageSecret array rather than Vec<u8>. This makes an invalid-length secret unrepresentable, so you no longer need a runtime length check. This is a breaking change if you build MsgSecretEntry directly in a custom backend, or if you override the defaulted put_msg_secret method. If you construct entries directly, build the JID/ID fields with Arc::from(...) or .into(), and pass a [u8; 32] for secret. If you override put_msg_secret (most backends don’t — see the Note below), update its signature to take secret: &[u8; 32] instead of secret: &[u8]. The SQLite table itself is unchanged:
A custom backend only needs to implement three methods: put_msg_secrets, get_msg_secret, and delete_expired_msg_secrets. The other two have defaults — put_msg_secret delegates to put_msg_secrets with expires_at = 0, and get_msg_secret_with_ts pairs get_msg_secret with a 0 timestamp. Override get_msg_secret_with_ts only if your store persists message_ts and you want the edit-window enforced.

DeviceStore Trait

Handles device data persistence:

resource_report

Best-effort process-local memory this backend attributes to the session. Defaulted — most custom backends don’t need to implement it. It exists so a backend that can introspect its own memory (like SqliteStore’s SQLite page cache, often the single largest per-session chunk since it lives entirely outside the Client) can report it for Client::resource_report(). Remote/store-backed backends (Redis, etc.) should override it to return memory_bytes: Some(0) — their data isn’t process memory — rather than leaving the default all-None (“not reported”), if they want to make that positive claim explicit.

SqliteStore implementation

The default storage implementation using SQLite with Diesel ORM. SQLite is bundled by default — you don’t need it installed on your system.

Bundled SQLite

The whatsapp-rust-sqlite-storage crate enables the bundled-sqlite feature by default, which compiles SQLite from source and statically links it. To use a system-installed SQLite instead:
Cargo.toml

Creating a store

Sharing the pool with sibling crates

SqliteStore::shared() returns a clonable SharedSqlite handle onto the store’s existing r2d2 connection pool and write-serialization semaphore. A sibling crate uses it to run its own queries and migrations against the same database file — without opening a second connection pool, which would mean two WAL writers contending for the file lock. This is how whatsapp-rust-chat-store attaches its chat/message tables to the same whatsapp.db file as the device store.

Features

  • Connection pooling - Uses Diesel r2d2 with pool size of 2
  • WAL mode - Write-Ahead Logging for better concurrency
  • Automatic migrations - Runs embedded migrations on startup
  • Semaphore-based locking - Prevents concurrent writes
  • Retry logic - Automatic retry with exponential backoff for locked database
  • Multi-device support - Single database can store multiple device sessions

Database Configuration

SqliteStore automatically configures connections with:
SqliteStoreConfig also exposes an opt-in mmap_size: Option<u64> field (default None, current behavior — no PRAGMA mmap_size emitted). Set it with the builder-style with_mmap_size(bytes):
with_mmap_size only sets the field on the config value — the store applies it when the config is passed to SqliteStore::with_config (or with_config_for_device). Building a config and never passing it to one of those constructors leaves mmap_size unset, since SqliteStore::new / new_for_device always use SqliteStoreConfig::default(). When set to a non-zero value, this emits PRAGMA mmap_size = <bytes>;, moving reads of the main database file through a reclaimable, OS-backed memory map instead of the heap page cache — useful for a process holding many small per-session databases, since mapped pages can be reclaimed under memory pressure while heap-cached pages cannot. 0 disables mmap, same as None.
mmap I/O covers reads of the main database file only. In WAL mode (this store’s default), writes still go through the WAL, and a checkpoint briefly falls back to non-mmap I/O. SqliteStore::resource_report() (see DeviceStore::resource_report) does not account for mmap_size — with mmap enabled, some reads bypass the heap page cache it measures, so the reported estimate can overstate actual process-heap residency for that session.

Connection init hook

SqliteStoreConfig also exposes an optional connection_init: Option<ConnectionInitHook> field, set via the builder-style with_connection_init(hook). The hook runs first in r2d2’s on_acquire customizer on every pooled connection — before the store’s own pragmas, and (because WAL setup and migrations also run on a pooled connection) before those too:
The canonical use is SQLCipher-style keying, where PRAGMA key must be the first statement executed on a fresh connection, ideally followed by a verification query:
Linking a SQLCipher-enabled SQLite is the caller’s responsibility: disable this crate’s default bundled-sqlite feature and depend on libsqlite3-sys with a SQLCipher build (e.g. its bundled-sqlcipher feature) instead. The crate itself gains no SQLCipher, key-type, or zeroization coupling — the hook is a generic per-connection seam that equally serves loading extensions or custom per-connection pragmas. If the hook returns Err, the connection is rejected — this surfaces as a pool/build error at store construction (e.g. SqliteStore::with_config fails outright) rather than on a later query, which matters for a wrong-key error: it should fail fast, not after migrations already ran against an unreadable database.
The hook must be idempotent per connection and cheap: r2d2 calls it once for every connection it opens, including replacements after errors. VACUUM INTO snapshots run on a pooled (already-keyed) connection, so backups of an encrypted database stay encrypted with no extra work.

Usage Example

CacheStore Trait

The CacheStore trait enables pluggable cache backends for the client’s data caches. By default, caches use the in-process PortableCache; implementing this trait lets you use Redis, Memcached, or any other external cache. Location: wacore/src/store/cache.rs

Namespaces

Each logical cache uses a unique namespace string. Implementations should partition keys by namespace (e.g., prefix as {namespace}:{key} in Redis).

Error handling

Cache operations are best-effort. The client treats read failures as cache misses and logs warnings on write failures. Implementations should still return errors for observability.

CacheStores configuration

Set individual caches or use CacheStores::all(store) to route all pluggable caches to the same backend:
See Custom backends — cache store for a full implementation example.

TypedCache

TypedCache<K, V> is a generic wrapper that dispatches to either the in-process PortableCache or a custom CacheStore backend. Location: src/cache_store.rs
invalidate_all() on custom CacheStore backends requires the tokio-runtime feature. Without it, the clear is silently skipped. Use the async clear() method as an alternative.

Implementing custom storage

To implement a custom storage backend:
  1. Implement all four domain traits
  2. The Backend trait is automatically implemented
  3. All methods must be async and thread-safe (Send + Sync)

Example: Redis store

Best Practices

  1. Thread Safety - Use Arc for shared state, Mutex for mutable state
  2. Error Handling - Convert backend errors to StoreError variants
  3. Transactions - Use database transactions for atomic operations
  4. Retries - Implement retry logic for transient failures
  5. Connection Pooling - Reuse connections when possible
  6. Blocking Operations - Wrap blocking I/O in tokio::task::spawn_blocking

Data Structures

AppStateSyncKey

LidPnMappingEntry

TcTokenEntry

An entry with an empty token and only sender_timestamp set is a byte-less placeholder written by touch_tc_token_sender_timestamp — it records that a post-send issuance IQ succeeded before any real token had been received from the contact.

DeviceListRecord

The raw_id field stores the ADV (Account Device Verification) key index list raw_id from device notifications. When this value changes for a user, it indicates an identity change (e.g., the user reinstalled WhatsApp). The client uses this to detect identity changes and clear Signal sessions for that user’s non-primary devices. Per-device sender key tracking is not wiped globally on identity change — that would empty the tracker too aggressively and feed the no-distribution path on the next group send. SKDM redistribution is instead driven per-group/per-device by retry receipts (matching WhatsApp Web’s WAWebUpdateLocalSignalSession/markForgetSenderKey behavior).
Breaking change: DeviceInfo gained the is_hosted field (marks whether the device belongs to WhatsApp’s hosted PN/LID address space, populated from usync device-list results). This breaks both construction and exhaustive pattern matching. Struct-literal construction (DeviceInfo { device_id, key_index }) no longer compiles — use DeviceInfo::new(device_id, key_index).with_hosting(is_hosted) instead. An exhaustive destructuring pattern (let DeviceInfo { device_id, key_index } = info;) also no longer compiles — add a .. to the pattern or match on is_hosted as well. Persisted JSON without is_hosted still deserializes correctly (it defaults to false); only Rust construction and pattern-matching call sites are affected. See USync for how is_hosted is used with Jid::with_device_hosting.

Error Handling

All storage operations return Result<T> from wacore::store::error. Each variant preserves the underlying typed error as its source() so callers can downcast to the original backend error when needed:
StoreError exposes a helper is_database_busy_or_locked() that walks the source chain looking for SQLite BUSY/LOCKED markers. Retry layers use it to decide whether a database error is transient without depending on a specific backend crate.

See also