Internals¶
This page explains how WaWarden works inside: the WhatsApp engine and its
adapter to the protocol library (go.mau.fi/whatsmeow), how messages are
ingested and delivered, history sync, and what the protocol library sends on
its own. It also holds the archive's schema, the byte layouts behind the
audit chain's HMACs, the memory bounds, and the reasons behind some design
choices. The operational reference is in configuration.md,
cli.md, api.md and observability.md;
what to do when something goes wrong is in the runbook. Terms
such as LID and re-key are explained in the glossary.
Contents¶
WhatsApp engine¶
The engine links the service to WhatsApp as a companion device, keeps the
connection, and writes what arrives into the message archive.
It can also send a text message and ask the owner's phone for older history,
as What a send does on the wire describes.
It talks to WhatsApp through an adapter over the WhatsApp protocol library,
go.mau.fi/whatsmeow, which keeps the linked device in the
device store.
Without a paired device the engine makes no connection of any kind, not even
the version fetch below: it stays unpaired and reports the
notification event unpaired once, until
wawarden admin pair requests pairing. It reports unpaired again
whenever a paired device is lost, for example when WhatsApp logs it out or a
rejected device is logged out.
A disconnection never stops the process, and /healthz does not depend on the
engine: an unpaired or disconnected engine still answers 200.
Engine states¶
| State | Meaning |
|---|---|
unpaired |
No device is linked; the engine waits for pairing. |
connecting |
The engine is fetching the protocol version, connecting, or waiting before it reconnects. |
connected |
The engine is connected and receiving. Only in this state, with a paired device and a connection that the protocol library reports as logged in, does it send a message or a backfill's history request. |
disconnected |
The engine is not connected and does not reconnect on its own. The reason is one of outdated, replaced, logged_out, temporary_ban, cat_refresh, connect_failure, restart_budget, owner_mismatch or shutdown. |
Every change is logged as engine_state; entering disconnected for any reason
but shutdown also reports the notification event disconnected. The gauges
wawarden_paired and wawarden_connected follow the state.
Disconnect reason codes¶
A disconnected engine names its reason in engine_state, in the
disconnected event and in admin status. The runbook's
Disconnect reasons is the one list of every
reason, what it means and what to do. The behaviour behind some of them is
below:
restart_budget: Restart budget.outdated: Protocol version.replaced,temporary_ban,cat_refresh,connect_failureandlogged_out: Stops that wait for the operator.owner_mismatch: Pairing.
Restart budget¶
When the archive has recorded more than 5 starts in the
last ten minutes, this one included (recent_starts in archive_opened), the
engine starts in disconnected with reason restart_budget and makes no
connection, so that a crash loop does not become a reconnect storm; a stored
device that fails the owner check, which comes first, starts it in
owner_mismatch instead. A start
recorded at a later time than the current one, as after the system clock was
set back, is forgotten, so it cannot hold the engine in restart_budget
until the clock catches up.
Protocol version¶
When a device is stored, passes the owner check and is within the
restart budget, the engine first fetches the current WhatsApp Web
version from https://web.whatsapp.com, at most four times: once, then after 1, 2 and 4 seconds. It
takes a newer version, keeps its own when the fetched one is equal, never takes
an older one, and changes the version only while disconnected. When WhatsApp
reports the client as outdated, the engine fetches again the same way and then
needs a strictly newer version. When no fetch succeeds, it closes the
protocol library's connection, so that nothing reconnects behind it, and
stays in disconnected with reason outdated; an explicit reconnect fetches again
before it connects, with the same need: a strictly newer version after
WhatsApp reported the client as outdated, an equal one too after the fetches
at the start failed, for example because the network was not up yet.
Reconnection¶
After an ordinary drop or a failed connection attempt, the
engine reconnects after a delay that starts at 2 seconds, doubles after each
failed attempt up to 5 minutes, and is randomised to between half and all of
that. A connection that stays up for at least a minute resets it, and so does
the owner's pairing, so the reconnection that follows a pairing waits no
longer than 2 seconds. A connection that
drops within a minute counts as another failed attempt, so that a server that accepts
connections and drops them at once is dialled ever more slowly, down to once
every 2.5 to 5 minutes. Only the connection being opened
counts: a late report that the dropped connection came up changes nothing, and
a drop reported while a connection is being opened is followed by another
attempt. An explicit reconnect while the engine is connecting closes the
connection being opened and opens a new one without waiting. A version fetch
or a connection attempt that panics fails like any other. The protocol
library's own reconnection is switched off, so these delays are the only
ones: when WhatsApp asks a device to log in again, as it does right after
pairing, the adapter closes the connection instead of letting the protocol library
re-dial, and that counts as a drop. A connection whose keepalives have failed
for 3 minutes is closed and counts as a drop too.
Stops that wait for the operator¶
After replaced, temporary_ban, cat_refresh or connect_failure, the
engine stays disconnected until an explicit reconnect
(POST /admin/v1/reconnect). After logged_out, it waits for pairing
(POST /admin/v1/pair). A connection event that arrives late, after one of
these, changes nothing.
Pairing¶
Pairing links a new device by a code that the owner enters on the phone.
wawarden admin pair requests it through the admin route
POST /admin/v1/pair. It is refused while a device is paired
(already_paired), while WAWARDEN_OWNER_PHONE is unset
(owner_phone_missing), and after three attempts within the last hour
(rate_limited); a refused or failed request counts as an attempt once it
passed the first two checks. The engine connects, waits up to 30 seconds for
WhatsApp to offer a login session, and asks for a code for the number in
WAWARDEN_OWNER_PHONE and no other, as a Chrome (Linux) device with a
notification on the phone. The route gives the engine 25 seconds in all, so
that its answer leaves before the listener's 30-second write timeout; a code
that does not arrive in time fails the request with pair_failed. WhatsApp
also sends login QR codes, which the adapter drops at once without keeping
them. The pairing code goes to the caller only: it is never logged, counted,
kept or put into an event. Before the protocol library saves anything, the
adapter refuses an account whose number is not exactly the one in
WAWARDEN_OWNER_PHONE: the protocol library sends WhatsApp a pairing error and stores
nothing, and the engine reports pair_rejected with stage before_save; it
takes that report only while no device is stored, so one that arrives late
cannot unpair the owner's device. When pairing fails in the protocol library for another
reason, such as an answer from WhatsApp that fails its checks, a device that
cannot be saved or a confirmation that cannot be sent, the protocol library closes the
connection and the engine returns to unpaired; the next pairing request
connects again with a fresh device. When pairing completes, the linked account must have
the number in WAWARDEN_OWNER_PHONE; otherwise the engine reports
pair_rejected with stage after_pairing, stays
unpaired and logs the new device out. A logout that fails is reported as
logout_failed and tried again after the same delays as a
reconnection, until it succeeds or the device is gone. Until
then the engine never connects the device, an explicit reconnect included, and
drops what its connection delivers before it is written (see
Ingest); pairing stays refused while it is stored.
Before every connection the engine also checks that the stored device's number
is exactly the one in WAWARDEN_OWNER_PHONE: at the start before the restart
budget and before it fetches the protocol version, so that neither the budget
nor a failed fetch can hide a mismatch behind restart_budget or outdated; on
an explicit reconnect, also one from outdated or
restart_budget; and after a drop. The stored number is compared and never
logged. When it is not, as for a device whose logout failed before the process
stopped, or when WAWARDEN_OWNER_PHONE names another number than the one that
was paired, the engine stays disconnected with reason owner_mismatch and
reports disconnected. It refuses an explicit reconnect (owner_mismatch), drops what
a connection would deliver, and does not log the device out, because the cause
can be a mistake in the configuration as well as another account: correct
WAWARDEN_OWNER_PHONE or remove the stored device, then restart the service.
With WAWARDEN_OWNER_PHONE unset this check is skipped and a stored device is
connected as it is, because pairing could link it only while the number was
set. A report that pairing completed for the owner's number changes nothing
while the engine is connected.
A logout asks WhatsApp to remove the device, which needs a connection; when that
fails, the adapter closes the connection and deletes the device from
session.db itself. After a logout, or after WhatsApp logged the device out, the
next connection starts with a new, unpaired device. The protocol library keeps
the identity mappings and privacy tokens it learned in session.db after a
device is deleted.
Ingest¶
Each message that WhatsApp delivers is first written to the archive's inbox, in the same file as the archive, and only then acknowledged. Each group change is written to the inbox too, but the protocol library acknowledges it as it arrives, before the engine has written it (see Delivery). So is each change to the names the owner saved for contacts.
The engine refuses to write a message, a group change or a saved name when:
- the inbox already holds 5,000 rows that wait to be applied;
- ingest is paused;
- the write fails.
Each case counts in wawarden_ingest_refused_total, and a refused message,
group change or saved name is lost (see Delivery).
A chat is a phone-number user (<digits>@s.whatsapp.net), a LID user
(<digits>@lid) or a group (<id>@g.us). A LID is the identifier WhatsApp
gives an account in place of its phone number; see the
glossary. These are acknowledged and dropped before they reach
the inbox:
- traffic of a chat of any other kind: status updates, broadcast lists, newsletters and every other kind;
- every message, group change, saved contact name and history-sync
notification that arrives while the engine is
unpaired, while no device is stored (such as after WhatsApp logged the device out), while a device that pairing rejected is still stored, or while the stored device is not the owner's (owner_mismatch). All of these count asnot_paired; - every message whose identifiers, push name, text and quoted text add up to
more than 512 KiB (
too_large). Memory bounds gives the memory this cap implies.
One worker applies the inbox in order. It records each attempt in the archive
before applying the row, then applies the row and removes it from the inbox in
one transaction, so that a row is applied once even when the process stops in
between. A failed attempt, an attempt that panics included, is retried after 1
and then 2 seconds; after three failed attempts, attempts cut short by a crash
included, the row is quarantined:
its content is removed, the row stays as a record, and the notification event
quarantine is reported with queue inbox. A row whose content the archive refuses,
such as a message identifier with spaces, is dropped at once.
The rules the worker applies, each counted in wawarden_ingest_dropped_total
when it drops an event:
| Rule | Drop reason |
|---|---|
| The chat must be a phone-number user, a LID user or a group; a saved contact name must belong to a phone-number or LID user. | chat_rejected |
| The sender must be a phone-number or LID user. | sender_rejected |
| An event of no known kind is dropped; in particular it never counts as a revocation. | unknown_kind |
| An edit, revocation, reaction or poll vote names its target by a key. A key without a message identifier, or a group key without the target's sender, is dropped. | no_target |
| A key, or the reference of a reply, that names a chat is followed only when it names the chat the event arrived in, or, in a direct chat, the owner's own number, which is how the other side names that chat. Any other chat is never followed: the event is dropped, and a reply is stored without its reference. | foreign_reference |
| The target is looked up only inside the event's chat, by its identifier and its sender; in a direct chat, a key that does not name the event's sender as the author names the other side. | target_unknown, and owner_unknown when the other side is the owner and WAWARDEN_OWNER_PHONE is unset |
| An edit or revocation must come from the target's sender. In a group, a revocation by someone else is applied only when the archive records that member as an admin of the group; a member it does not record is refused. | not_original_sender, not_admin, admin_unknown |
| An edit needs new text and a target that is neither revoked, a reaction nor a poll vote. | invalid, target_kind |
| An edit must be newer than the last edit applied to its target, so that an edit delivered late or again never brings back text the sender has since replaced. | stale_edit |
A revocation or edit whose target is not in the archive yet is dropped
(target_unknown) and not kept for later. When history sync stores the target
afterwards, as it can during the initial sync, because live traffic is applied
while blobs wait for their download, the target keeps its original text: a
revoked message its text, an edited one its earlier wording (see the
threat model's residual risks).
Further, the worker:
- stores a reply's reference to the message it quotes when that message is found in the same chat, and marks the quote unverified when the quoted text differs from the stored original; the quoted text itself is never stored;
- stores reactions and poll votes as rows of their kind that point at their target, without their content;
- stores the push name of a sender other than the owner for that contact, and as the name of a direct chat, and a group's subject as its name, each with its source;
- stores the full and first name the owner saved for a contact when the owner's phone syncs a change to its contact list to this device, under the contact's canonical identifier, and nowhere else: a saved name names no chat and replaces no push name. The latest change replaces the stored name, and a change that carries no name clears it. The protocol library passes on only the changes it fetches one by one, such as a contact added or renamed after the device has synced the contact list, never a list it fetches whole, as it does the first time after pairing, and no deletion, so a contact deleted on the phone keeps its last saved name. Tests cover this with synthetic events only. Whether the phone sends these changes to a linked device, and under which identifier, is unverified against a live account;
- stores
text_display, the text without control, bidirectional, zero-width and tag characters, beside the text; - stores whether a message came live or from history sync, and whether the owner sent it;
- sets the expiry of a message sent with a disappearing timer; every minute, also between the rows and batches it applies, the engine removes the text of expired messages from the archive as described under Message archive;
- learns which LID belongs to which phone number only from the alternate identifiers WhatsApp's servers attach to live messages and from history sync, and re-keys the direct chat (see Re-keys below).
Re-keys¶
A re-key moves a direct chat from the phone number to the LID. In the same transaction it moves every client's read and write chats, and the contact's push name and saved name, from the number to the LID. Where both identities have a saved name, the newer is kept.
The engine refuses a mapping that:
- contradicts one already learned;
- would merge two chats that both hold messages;
- would widen what a client reads: a client that names only one of the two identities while the other holds messages, or a merge that would remove a chat a client names.
Revoked clients do not count for the last rule, but an expired client counts
until it is revoked: revoke an expired client whose chats hold a mapping
back. A refused mapping is counted in wawarden_rekey_conflicts_total every
time. It is reported as the notification event
rekey_conflict the first time the engine refuses it since the service
started. The engine remembers up to 1,024 refused mappings, and forgets them
all when that many are reached.
Delivery¶
A message refused or interrupted before it is stored is lost, not redelivered. Once written to the inbox, a message is applied to the archive, after a restart if need be, or quarantined when applying it keeps failing; before that, WaWarden gets one chance at it.
The protocol library decrypts a message, which advances and saves its session
keys in session.db, and then hands it to the engine. Only once the engine has
written the message to the inbox does the protocol library acknowledge it to WhatsApp and
send the delivery receipt. This gives:
- A message the engine wrote reaches the archive even when the process stops before applying it: the inbox is applied again at the next start.
- A message the engine refuses (a full inbox, a pause or a failed write), or whose handling panics in the adapter or the engine, is not acknowledged and gets no delivery receipt. WhatsApp sends it again later, but the protocol library can no longer decrypt that copy, because its keys have moved on: it drops it without passing it on, then acknowledges it and sends the delivery receipt. A refused message is therefore lost to the archive. So is a history-sync notification refused during a pause, whose blob is then never downloaded.
- A message is lost the same way when the process stops after the protocol library decrypted it and before the engine wrote it.
- The protocol library acknowledges a group change as it arrives, whatever the engine answers, so a refused group change is lost as well. The protocol library never delivers a change to the owner's contact list again either, so a refused saved name is lost until the owner changes that contact again.
- The protocol library can keep each decrypted message in
session.dbuntil the engine confirms it, which would let a refused or interrupted message be decrypted again. WaWarden leaves that off, so thatsession.dbholds no message plaintext (see Design rationale), and an architecture test refuses the call that would.
What a loss costs, and what limits it:
- Only the archive misses the message: the owner's phone and the account's other linked devices receive their own copies.
- The inbox is written before the acknowledgement, in the same file as the archive, so a crash or a stop after that write does not lose the message.
- The free-space floor pauses ingest before a full disk makes
every write fail, and reports
ingest_pausedso that the operator can free space while little is lost; ingest resumes on its own. - Every refusal counts in
wawarden_ingest_refused_totalby reason (backlog_full,paused,store_error), andadmin statusshows the inbox backlog that leads tobacklog_fullat 5,000 rows, so a loss can be alerted on.
History sync¶
The owner's phone sends the chat history in blobs, each announced by a
notification. The engine accepts a notification only from the owner's own
primary device (device 0) and drops any other (history_not_primary). It
records the notification in the archive and acknowledges it. Then, while
connected, it downloads the blob into history/<id>.part in the data directory
through a writer that stops at WAWARDEN_HISTORY_MAX_BYTES, refusing a blob
announced as larger without downloading it, flushes the file to disk, renames it
to history/<id>.bin, flushes the directory and only then sends WhatsApp the
history receipt. A small first blob can arrive inside the notification; it is
kept in the archive's record, needs no download, is refused without being
decompressed when it is larger than WAWARDEN_HISTORY_MAX_BYTES, and gets its
receipt when the engine takes it up. After a restart, the receipt of a blob that is still waiting
is sent again.
The owner's phone answers the history request of a backfill with a notification of the same kind, which the engine accepts only from the primary device and takes up the same way. The archive's record of every notification keeps its sync type and the request id it names, if any. When the engine marks an on-demand blob, the answer to a backfill, processed, it marks the backfill with that request id complete in the same transaction; a blob of another sync type, or one that names no request, completes none.
The download goes through the protocol library, which asks WhatsApp for the
media hosts, fetches the encrypted blob over an HTTP client of the adapter's,
with timeouts, no proxy, no redirect and a response cap of
WAWARDEN_HISTORY_MAX_BYTES plus 32 bytes for the encryption's padding and
tag, checks its hashes and decrypts it. The adapter holds it in memory, at most
WAWARDEN_HISTORY_MAX_BYTES plus 32 bytes, through a buffer that refuses every
write past that cap, before the engine writes it to history/<id>.part. The
history receipt goes out through the protocol library only when the engine asks for it;
the protocol library's own receipt on arrival is switched off.
For each blob the engine records an attempt, decompresses it, refusing more
than WAWARDEN_HISTORY_MAX_BYTES of output, decodes it, and applies it with
the rules above in transactions of 100 messages, with origin history; group
subjects and members, push names and LID mappings in the blob are stored too,
except where a live event has already recorded a newer value: history never
replaces a group subject, chat name or push name that a live event set, never
changes a member that a live group change named, and leaves the member list of
a group whose whole list arrived live as it is.
It then marks the blob processed, which also removes its record's download
reference and inline content, deletes its file and flushes the directory. Blobs
not yet processed are taken up again at the next start, and each start of the
engine first deletes every history/<id>.bin and history/<id>.part whose blob
is not waiting to be processed, such as a file that a crash left behind after
its blob was marked processed or quarantined; the engine repeats this once an
hour while it runs, so a file whose deletion failed does not wait for a restart.
A processed blob's row, which holds only its identifier, two times and its
attempt count, is deleted 30 days after it was processed by the nightly prune of
the backup scheduler, which runs with or without a backup recipient;
a quarantined blob's row is kept. An attempt that panics while it
downloads, decodes or applies a blob fails like any other. After three failed attempts, attempts cut
short by a crash included, a blob is quarantined: its reference and
files are deleted and the notification event quarantine is
reported with queue history. A graceful stop that interrupts a blob gives its attempt back, like
a pause: the blob stays waiting and is applied from its start at
the next start, keeping the batches it had already applied.
The engine processes one blob at a time, within the memory that Memory bounds gives. The adapter decodes a blob with a nesting limit of 28 levels: 24 for each message and its quotes, below the blob's own four. It reads each conversation's rows under the conversation's own chat, never under the chat a row's key names; it keeps an edit row's own identifier, so that it is applied as an edit of its target; and it passes the blob's phone-number-to-LID mappings and push names to the engine, which applies them by the rules above. A message that the owner's phone sends again on request, which the protocol library builds the same way, keeps an edit's own identifier too.
Traffic the protocol library sends by itself¶
While connected, the protocol library sends WhatsApp, without being asked and without a setting to stop it:
- an acknowledgement of every stanza it receives;
- a delivery receipt for every message it decrypts, in its inactive form, which a sender's phone does not show as delivered while the device has never announced itself as available, and WaWarden never does; receipts for the owner's own messages go to the owner's devices;
- retry receipts, at most 4 per message while the process runs, when a message cannot be decrypted, and a request to the owner's phone to send again a message that WhatsApp marks as unavailable;
- an announcement that the device is active, at every connection;
- uploads of new pre-keys when the server holds too few;
- fetches of the application state (contacts, chat settings) when the server or the owner's phone says it changed;
- session telemetry once, after pairing.
Around a send it also sends, by itself:
- before the message, queries for the recipient's devices or the group's members, a query for the account behind a phone number whose LID it does not know, and fetches of pre-keys for the devices it has no session with;
- with a direct message, the contact's stored privacy token or a token it derives, and after it, when a new seven-day period has begun since its last update for that contact, a privacy-token update naming the contact;
- answers to retry receipts from a recipient's device that cannot decrypt a sent message: it encrypts the message again from a memory cache of the last 256 sent messages, whose text stays in the process's memory until it leaves the cache; a message older than that, or sent before a restart, is not sent again.
It never sends read receipts, presence, typing indicators or status updates on its own, and WaWarden never asks it to: an architecture test refuses those calls.
What a send does on the wire¶
A send carries one text message. The adapter builds the recipient from the
chat's canonical identifier, a phone number (<digits>@s.whatsapp.net), a LID
(<digits>@lid) or a group (<id>@g.us) without a device part, and refuses
any other form before the protocol library sees it. The protocol library may still
address a phone number by the LID it knows for it, from its own store or by
asking WhatsApp; the adapter reports the sent message under the requested
chat whatever the protocol library chose. Plain text goes out as a plain message. A
reply, or a message to a chat with a disappearing timer, goes out as an
extended text message whose context names the quoted message's id and sender
and carries the quoted text only, never media, and the timer in whole seconds;
it never names another chat.
A direct message is also encrypted for the owner's other devices.
The protocol library waits at most 12 seconds for WhatsApp's acknowledgement, and the adapter gives the whole call a deadline of 20 seconds. The caller's cancellation never reaches the protocol library, so a client that goes away cannot cut a send short. When the connection drops before the acknowledgement and comes back within 5 seconds, the protocol library writes the message again under the same id; the engine itself never sends a message twice.
Apart from the busy refusal below, a send ends in one of five outcomes:
| Outcome | When |
|---|---|
| sent | WhatsApp acknowledged the message; its time is the server's, or the service's clock when the server named none. |
| not connected | Nothing was written: the engine was not connected or not paired, the connection was not logged in, or it was gone before the message was written. |
| recipient unavailable | The account is not a member of the group, the group does not exist, or WhatsApp found no account for the phone number; nothing was written. |
| rejected | WhatsApp answered with an error, whose number is kept, or the request could not be built: a malformed chat, quote or timer, an empty id or text, or no encryption session with a device. |
| unknown | The adapter cannot rule out that WhatsApp received the message: no acknowledgement came after it was written (a timeout, a drop, the deadline, or a panic), or an error the adapter cannot place ended the call. |
The protocol library's own error text never leaves the adapter, so no outcome
carries a chat identifier. The adapter reports the owner's LID as the sender
of a message to a chat keyed by LID and the owner's phone number otherwise,
never with a device part and never the sender the protocol library names: the forms
the protocol library gives the owner's messages when it reads them from
history. When a connection opens with both known, the engine records the
owner's phone number and LID as a pair, with source self, so that the owner's
messages in either form belong to one sender; a pair that contradicts the
archive is refused with the rekey_conflict event like any other. An identity
that is not the configured owner's is dropped and counted as owner_unknown,
a malformed one as invalid.
The engine sends one message at a time and queues none: while a send or a history request runs, another is refused at once as busy. A running call holds its place for at most the 20-second deadline.
A backfill (wawarden admin backfill, POST /admin/v1/backfill) goes out as
a history request: a protocol message to the owner's primary device that asks
for up to 50 messages of a chat that come before a given message. It runs
under the same deadline and has the same outcomes as a send. The phone answers
with an on-demand history-sync blob. WhatsApp's
acknowledgement means only that the request was accepted for delivery: the
phone answers when it processes the request, with a
history notification.
Fake engine¶
A development build, one built with the dev build tag, replaces the protocol
adapter with a scripted fake when WAWARDEN_DEV_FAKE_ENGINE is 1 or
wrong_account, to try pairing, ingest, the admin commands, the events and the
metrics without a WhatsApp account. Everything around the fake is the real
engine: the same states, pairing guards,
ingest rules, inbox, history worker and caps. The
fake opens no network connection and no device store, so it
takes no backup and logs backup_disabled even when
WAWARDEN_BACKUP_AGE_RECIPIENT is set. It starts its goroutines through the
same panic-recovering helper as the rest of the service, and makes serve log
the warning fake_engine, at every log level, with wrong_account set to
whether it pairs the wrong account.
- Pairing. The fake keeps its pairing in memory only, so every start begins
unpaired, and pairing needsWAWARDEN_OWNER_PHONEas it does with WhatsApp.wawarden admin pairprints the fixed codeFAKE-C0DE; a tenth of a second later the fake pairs the owner's number and connects. Withwrong_accountit pairs+15550100029instead, which the engine rejects withpair_rejected(stageafter_pairing) and logs out, and nothing is ingested. - Live events. Once connected, the fake delivers one event every 100 milliseconds: a group with its subject and admins; direct and group texts, one from a sender addressed by LID; a quote that matches the quoted text, a forged one and one naming another chat; an edit by the sender and one by another member; the owner's revocation, a group admin's and a plain member's; a revocation naming another chat; a reaction; a poll and a vote; a disappearing message; a photo with a caption; a status broadcast and a newsletter post; a revocation without a target; and a member joining the group. After the first ten events it drops the connection once, and the engine reconnects with its usual backoff. An event the engine leaves unacknowledged is offered again at the next step, as WhatsApp would deliver it again.
- History sync. Among the live events, five history notifications: an
inline bootstrap blob with a LID mapping and a contact's name, a blob to
download, one from the owner's device 5 (dropped, as it is not from the
primary device), a blob that inflates past
WAWARDEN_HISTORY_MAX_BYTES, and a poison blob that the fake's decoder refuses. The last two fail three times and are quarantined, each with aquarantineevent. - Identity. The script opens with the owner's identity: the owner's number
paired with the synthetic LID
100000000000020@lid. - Sends and history requests. While connected for the owner, a send to
+15550100021or+15550100022, by phone number or by their synthetic LIDs, is acknowledged with the owner's identity as sender; a send to+15550100023ends with an unknown outcome; a send to the synthetic group or to any other chat ends with recipient unavailable; and while not connected, nothing is sent. A history request for a synthetic chat returns a request id, and a tenth of a second later the fake delivers an on-demand history notification naming it, whose blob holds as many older synthetic messages as requested, offered again until the engine accepts it. The fake keeps the sends that reached its wire in memory, for tests of development builds.
When the script has played, within about 15 seconds of pairing, the fake stays
connected and sends nothing more: wawarden admin status reports connected,
3 chats, 17 messages and 2 history blobs quarantined. Every identity the fake
uses is synthetic: its contacts are +15550100021, +15550100022 and
+15550100023, its LIDs are 100000000000020 to 100000000000022, and an
owner number among the contacts, or +15550100029, stops the start with
startup_failed.
Release builds do not contain the fake: its package compiles only with the
dev tag; the dev-only-fake-engine architecture rule refuses any file of the
package, and any file that imports it, that the go tool could build without
that tag; a release build refuses WAWARDEN_DEV_FAKE_ENGINE, like every
WAWARDEN_DEV_* name, with dev_variable_in_release; and the reproducible
build fails when a release binary holds the fake's marker string or any symbol
of its package.
Archive schema¶
The schema is at version 4. Besides chats, messages and their full-text index, identity mappings, push names, group members, the inbox and the history-sync queue, it holds:
- a change number per message, which a trigger sets to one more than the largest stored when a message is stored, edited or revoked, or loses its text at expiry; a duplicate of a stored message and a re-key change none;
- a reference per chat, 32 random lower-case hexadecimal digits that a trigger assigns when the chat is first stored. A re-key from a phone number to a LID keeps it. A re-key that merges a chat without messages into the other chat of the same person removes the merged chat and its reference with it;
- names that the owner saved for a contact, keyed by the contact's canonical identifier (see Ingest);
- on each message,
sent_by: the 8-character id of the client that sent it through the gateway, empty on every other message; only the send path writes it; selfas the source of an identity mapping, for the pair of the owner's own phone number and LID;- the idempotency ledger: per client and idempotency key, the send's state
(
pending,doneorfailed), a 32-byte SHA-256 fingerprint of the request, the message id, when it was reserved, the earliest time the next send may follow it, when it was settled, whether it counts against the send budget, a result that holds only a status, an error code with the server's error number when it gave one, and an ack time, never message text or a chat identifier, and an expiry 24 hours after the reservation. The rows that count are also the send budget's ledger, so the budget survives a restart; its windows are measured with the service's clock, never SQLite's; - backfill requests: a request id, the chat, a count of 1 to 50, when the request was made and when it completed;
- the audit anchor, at most one row: the id, time, key id and HMAC of the last audit row removed, when it was removed, and an HMAC over them with the id of its key. Triggers refuse to move it back or to remove it;
- read clients and the chats each one may read and write. A client row has
room for a 32-byte digest of its token, not for the token; its expiry must be
at most 366 days after its creation, and triggers refuse a write chat for a
client that reads every chat, and an
INSERT OR REPLACEorREPLACEthat would swap a client row under its chats. A client's chats are canonical identifiers, not references to stored chats, so they may name a chat the archive has not seen yet, and a re-key moves them from the phone number to the LID (see Ingest); - the audit table, whose rows triggers refuse to update or replace, and to delete unless the audit anchor covers the row and it is not the newest row. Only the nightly audit retention moves the anchor and deletes rows.
The admin routes create and revoke clients, and each change
appends a row to the audit chain. The engine writes saved
names from changes to the owner's contact list (see Ingest), and a
read shows one only to a client whose scope includes that contact's direct
chat (see Client API). The service never runs ANALYZE or PRAGMA optimize on the archive: planner
statistics could make SQLite read a whole table where its read queries now
search an index.
Full-text index rewrites¶
The message archive removes revoked, edited and expired text from its full-text index too. The index stores every three-character run of the text and finds them through page keys, which copy the start of the first run on each of its pages. Deleting a run keeps its key. When a deletion leaves a key that is a whole run of the old text and no other message holds that run, the service rewrites the entire index right after the deletion commits, in a transaction of its own. A key that holds less than a whole run is not rewritten.
The rewrite holds the archive while it runs, so other calls wait and may reach
their own deadlines. It writes the new index before it frees the old one, so
it needs free space of about the index's size. When the rewrite does not
commit, because the process stopped or the rewrite failed, the next start
rewrites the index before it logs archive_opened.
Memory bounds¶
One message¶
Ingest drops every message whose identifiers, push name, text and
quoted text add up to more than 512 KiB (too_large). Accepting and applying
one message therefore allocates at most 48 MiB of Go memory, besides SQLite's
own. Only text made of control characters comes near that, because the inbox
writes each of them as six bytes; ordinary text needs about a seventh of it.
One history blob¶
The engine processes one history blob at a time. Reading a
downloaded blob from history/ and decompressing it, which it does in two
passes so that it can size the output exactly, allocates at most 2 times
WAWARDEN_HISTORY_MAX_BYTES plus 1 MiB: about 65 MiB at the default and
513 MiB at the maximum of 256 MiB. A blob that decompresses to more than the
cap is refused after the first pass, which allocates at most 1 MiB. Decoding
the blob and holding its conversations while they are applied come on top of
that and grow with the blob's content. Leave the process's memory limit well
above this figure when raising the cap.
Byte layouts¶
Audit row HMAC¶
An audit row's HMAC is HMAC-SHA256 under the audit-chain
key derived from the master key, over, in this order:
- the domain string
wawarden/audit-row/v1and a zero byte; - the id and the time, as 8-byte big-endian integers;
- each other field of the row, with a presence byte and its length;
- the previous row's HMAC, or 32 zero bytes before the first row.
Audit anchor HMAC¶
The audit anchor's HMAC is HMAC-SHA256 under the
audit-chain key of the master key the anchor names, over, in this order:
- the domain string
wawarden/audit-anchor/v1and a zero byte; - the row's id, the anchor's time and the removal time, as 8-byte big-endian integers;
- the key id, with a presence byte and its length;
- the row's HMAC.
Design rationale¶
Write chats must be readable¶
A client's write chats must also be among its read chats. A client that could write to a chat it cannot read could learn from the answer to a reply whether a message exists.
The failed-authentication budget does not slow guessing¶
The failure budget changes only the answer to a failure: authentication still runs for every request, and a valid credential is accepted whether or not the budget is empty, so legitimate callers cannot be locked out. For the same reason the budget does not slow down guessing. What protects the admin and client tokens against guessing are their 256 random bits.
The device store holds no message plaintext¶
The protocol library can keep each decrypted message in session.db until the
engine confirms it, which would let a refused or interrupted message be
decrypted again. WaWarden leaves that off, so that session.db holds no
message plaintext, at the cost described under Delivery.