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).
MsgSecretEntry is { chat, sender, msg_id, secret, expires_at, message_ts }. The SQLite table is:
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.

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).

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