WaWarden threat model¶
This document describes WaWarden's security design and the controls that enforce it, in the code at the commit or tag you are reading. The hardening guide turns it into deployment steps, configuration.md documents the behaviour in detail, and agents.md says how an agent should treat what it reads. Each control under Secure by construction and Secure defaults names the release that introduced it; CHANGELOG.md holds the rest of the version history. Terms such as LID, re-key, chain head and grant are defined in the glossary.
Contents¶
- System summary
- Trust boundaries
- Secure by construction
- Startup refusals
- Pairing refusals
- Secure defaults
- Reporting
System summary¶
WaWarden is one process that links to one personal WhatsApp account as a companion device and exposes that account to the owner's own agents and applications. Each client holds a bearer token that grants read access, write access or both, limited to an allowlist of chats.
The process opens at most three listeners, all plain HTTP, and nothing else:
| Listener | Address | Serves |
|---|---|---|
| Client | WAWARDEN_LISTEN, default 127.0.0.1:8080 |
The REST read API under /v1 and the MCP tools at POST /mcp. A client with a write grant can also send, through POST /v1/chats/{ref}/messages and the MCP tool send_message. Every route needs a client token. |
| Admin | WAWARDEN_ADMIN_LISTEN, default 127.0.0.1:8082; opened only when an admin token hash is configured |
Metrics, status, pairing, reconnection, client management and history backfill (POST /admin/v1/backfill); see The admin token. |
| Health | WAWARDEN_HEALTH_LISTEN, default 127.0.0.1:8081; loopback addresses only |
/healthz, without authentication |
Outbound traffic is limited to the WhatsApp websocket, the protocol version fetch and history downloads from WhatsApp's hosts, and an optional notification webhook. Secure defaults says when the service first connects to WhatsApp.
WaWarden does not restrict who can reach its listeners and does not terminate TLS. Reaching the listeners from other hosts, encrypting that path and deciding who may use it belong to the deployer: a VPN or mesh network forwarding to loopback, a private subnet, or a reverse proxy that terminates TLS. The service must never face the public internet.
Loopback is not an access boundary. Every process in the service's network namespace can reach all three listeners: processes of other users on the same host, and other containers in the same pod or task, including a forwarding sidecar. Those processes must be trusted, or the tokens alone must be enough protection against them.
Assets¶
Most sensitive first.
| Asset | Why it matters |
|---|---|
Device store (session.db) |
The linked device's keys and the protocol library's state: Signal sessions, sender keys, message secrets, identity mappings and contact names. Whoever holds it can act as the account until the device is unlinked on the phone. |
Archive (archive.db) and history blobs |
Every archived chat, in plaintext. serve creates and locks the archive and runs the engine. Once a device is paired, the engine writes WhatsApp traffic into the archive. It keeps each history blob under history/ until the blob is processed or quarantined. |
| Admin credential | Gates the admin listener; The admin token lists what it guards. It does not expire. The service holds only its SHA-256. |
| Client tokens | Bearer credentials: whoever holds one and can reach the client listener has its access. The service stores only their SHA-256 digests. A token is shown once, in the answer that creates its client. |
Master key (keys/master in the data directory) |
Every purpose key is derived from it. The master key says what its holder, and the holder of a retired key, can do. |
Audit chain (the audit table, and its chain heads on standard output) |
The record of every client change and of every client request the read budget admits, REST and MCP alike. Whoever can write archive.db and holds the master key can rewrite it; the chain heads shipped on standard output reveal that. Rows older than WAWARDEN_AUDIT_RETENTION_DAYS (90 by default) are removed each night after the backup, behind an audit anchor signed under the master key. |
Webhook signing secret (WAWARDEN_NOTIFY_SECRET_FILE) |
Shared with the receiver of notification events. Whoever holds it can forge events that the receiver accepts. The service reads it once, at start, and never writes it out. |
| Outbound integrity | Messages sent through WaWarden appear to their recipients as sent by the account owner. The archive stores each one with the sending client's id, shown to readers of the chat as origin gateway:<client id>. The audit chain records every send request, also one whose client left before the answer. The exception is a send whose row the archive could not take in time (see Sends). |
| Account standing | WhatsApp can restrict or ban an account that links an unofficial client. |
Backups (backups/ in the data directory) |
One age-encrypted file per backup holds copies of the archive and the device store. Whoever holds a backup and the age identity it was encrypted to holds both databases, the linked device's keys included, and can act as the account. The service holds only the recipient, a public key, and cannot read a backup. While a backup runs, backups/tmp/ holds a plaintext copy of one database at a time. The newest WAWARDEN_BACKUP_KEEP backups are kept. |
| Release artifacts | The binaries and images deployers run. |
The master key¶
These keys are derived from keys/master with HKDF-SHA256:
- the key of the log pseudonyms;
- the audit chain's key, and the key of the chat HMACs in audit lines;
- the keys that seal the read API's cursors and message references.
Whoever holds the master key can:
- test a guessed identifier against the pseudonyms in the logs and the chat HMACs in audit lines;
- recompute the audit chain and sign its audit anchor;
- open and forge cursors and message references, which still never widen a client's scope.
wawarden keys rotate replaces the master key with a new one and keeps the old
one in keys/retired/. The service uses a retired key only to open cursors and
references sealed under it, and to check the audit rows and audit anchor made
under it until the retention removes them. audit verify uses it to check rows
and audit anchors made under it.
A retired key gives its holder the same power over what was made under it.
Whoever also can write archive.db can, with a retired key:
- remove the audit rows of any key but the current one from the start of the chain;
- append rows under the retired key until a row is written under the current key;
- once every row written under the current key is older than the audit retention's cutoff, replace all of them with rows under the retired key, which the next removal signs.
Audit chain limits gives the details.
Actors¶
In scope¶
- Any WhatsApp peer or group member. Sends floods, crafted protocol messages, edits, revokes, reactions and poll updates aimed at other chats, and text written to manipulate the agents that read it (prompt injection).
- A malicious or prompt-injected client with a valid scoped token. Tries to read or write outside its scope, to learn whether out-of-scope chats or messages exist, or to exhaust shared send budgets.
- Anyone with network reach to a listener but no token. This includes other processes in the service's network namespace, and web pages open in a browser on a host that can reach a listener.
- A thief of one token. The token is contained by its chat scope, expiry, revocation and rate limits, not by where it is used from.
- Readers of logs: anyone who can read the process's standard output or the log pipeline it feeds.
- Readers of backups.
- A compromised dependency or a malicious pull request.
- A well-meaning contributor who adds a route, a goroutine, a listener or a query without knowing the rules. The construction checks below exist for this actor.
- An operator running defaults. Defaults must be safe without tuning.
Out of scope¶
These are stated so that nobody relies on WaWarden for them:
- Network access control and transport encryption. The service listens on loopback by default and speaks plain HTTP. Who can reach it, and whether the path is encrypted, is the deployer's decision.
- Root or same-user-ID access on the host that runs WaWarden.
- Processes running as the same user on a client host. They can read each other's tokens.
- A compromised phone, or a compromised other device linked to the same account.
- Whoever holds both a backup and the age identity it was encrypted to. Generating the identity away from the service's host, and keeping it there, is the operator's task. There is no unencrypted backup.
- Delivery receipts and the other traffic the protocol library sends by itself (see Protocol library).
- Deletion residuals in backups, in filesystem blocks the service cannot scrub, in full-text index page keys shorter than a trigram, in other stored text that holds the same text, and in messages that history sync stores after their revocation or edit arrived (see Deletion and storage). Other stored text means names, identifiers and messages waiting in the inbox.
- Agents that pass message text into tools. WaWarden marks the text as untrusted; what an agent does with it is the agent's responsibility.
Trust boundaries¶
-
Network to client listener.
- A request carrying an
OriginorSec-Fetch-Siteheader is refused with403before anything else, so a web page cannot use a browser as a client. AnOPTIONSrequest is refused with405. - Every other request is authenticated before any route handler runs and before any byte of its body is read. A refused request that announces a body gets its answer at once, and the connection is closed.
- The request carries a client token, checked as Client tokens describes. Failed authentications are counted and throttled (see Failed authentication).
- An authenticated request spends one from the client's read budget before routing (row 22), and then reaches the routes.
- The peer address is recorded in the audit row for forensics only, and is never used for a decision. The service reads no forwarding headers.
- A request carrying an
-
Network to admin listener. The admin listener is separate, so that the deployer can restrict it independently of the client listener.
- The same browser and
OPTIONSrefusals apply. - The only check inside the service is the admin token. Without a configured hash, the listener is not opened at all. The admin token says what the token must satisfy, what it guards and how its misuse is detected.
- Behind the token, the admin routes answer fixed-shape data. The status carries the engine's state and reason, counts, a time and the build version, never an identifier. Pairing returns its code to the caller and nowhere else.
- The client routes and the chat lookup answer chat identifiers and names, to the administrator only and by design. They answer a client token only in the answer that creates its client.
internal/apireaches the engine and the archive only through interfaces thatinternal/appimplements.
- The same browser and
-
Loopback health. The health listener has no authentication. The service refuses to start when its address is not loopback, and checks again before opening it. It answers only
GET /healthz: whether the process is up and serving, and whether it holds the archive and the device store open and locked. -
WhatsApp to engine. Everything that arrives from WhatsApp is untrusted, including payloads attributed to the account's own other devices, except where the server asserts a field.
- Protocol messages. An edit, revocation, reaction or poll vote is applied only to a message found in the chat it arrived in, by identifier and sender. An edit or revocation is applied only from the message's sender. In a group, a revocation is also applied from a member the archive records as an admin. An edit is applied only when it is newer than the last edit applied to that message.
- Chats named in a message. A chat named in a message key or a reply reference is never followed unless it is the event's own chat. In a direct chat, the owner's number also counts, because that is how the other side names the chat.
- Identity mappings. A LID is the identifier on WhatsApp's
lidserver that stands in for a phone number. Mappings from a LID to a phone number are learned only from server-asserted alternates of live messages and from history sync. A re-key then moves a chat from the phone number to the LID (see the glossary). - Unpaired state. Messages, group changes and history-sync
notifications that arrive while the engine is
unpairedare dropped before they are written. The engine also enters that state when pairing rejects an account. - History sync. History-sync notifications are accepted only from the owner's primary device (device 0). Blobs are downloaded through a size cap, persisted before the receipt and before parsing, and decompressed under the same cap. A blob is quarantined after three failed attempts, an attempt that panics included, so a poison payload is set aside rather than replayed forever. Inbox rows are quarantined the same way. A blob's plaintext file is deleted once the blob is processed or quarantined, and each start of the engine deletes any such file that a crash left behind.
- Live traffic. Live traffic waits in a durable inbox and is acknowledged only once written. A message the engine refuses (a full inbox, a paused ingest, a failed write) is left unacknowledged, which loses it (see Protocol library).
- Size. A message whose identifiers, push name, text and quoted text add up to more than 512 KiB is dropped before it is written. This bounds the memory one message takes.
-
The protocol adapter translates the protocol library's events into plain data:
- it reads a protocol message as a revocation or an edit only when its type is set, because the type's zero value means revoke;
- it passes identifiers with their device parts for the engine to strip;
- it reads history rows under their conversation's chat, never under a row's own key. It keeps an edit row's identifier there, and in the messages the owner's phone sends again on request;
- it decodes blobs with a protobuf recursion limit;
- before the protocol library saves a pairing, it refuses an account other
than
WAWARDEN_OWNER_PHONE, so nothing of a foreign account is stored; - it drops QR codes, which carry the device's pairing secret, where they arrive.
-
Tests. The engine core's rules are tested with plain-data events. The adapter is tested with a protobuf fixture for each case that research on the protocol library listed, round-tripped through the wire format, fed through a real engine and archive, and fuzzed (
internal/engine/wa).
-
Service to clients. Message text, contact names and group subjects are written by third parties.
- Every message object carries
untrusted: trueand itsorigin.originisgateway:<client id>for a message a client sent through WaWarden,ownerfor any other message the owner's account sent, andpeerotherwise. None of these makes the text trustworthy, since the owner can paste third-party text. - Every message object also carries a
text_displayfield. It removes control, bidirectional-override, zero-width, tag and every other format character, keeps newline and tab, and replaces invalid UTF-8. The archive stores the field beside a message's text when it writes the message and when it applies an edit. The read API and the MCP tools return both. - MCP tool names, descriptions and schemas are built from constants and Go types, so no chat-controlled string reaches a tool definition (row 11).
- The names the owner saved for contacts come from the contact list the owner's phone syncs, not from the contacts themselves. A read returns one only when that contact's direct chat is in the client's scope, and returns it as the phone sent it.
- How an agent should treat all of this is in agents.md.
- Every message object carries
-
Service to logs, metrics and notifications. Chat identities leave the process only as keyed HMACs. Message text, query text, tokens and protocol messages never leave it.
- No request is logged. Failed authentications are counted, not logged.
- A startup refusal names the variable involved, never its value.
- A recovered panic is logged with its type and stack, never its value.
- Every line
servewrites to standard output passes through one log writer. Row 13 is the canonical statement of its rules: token redaction, identifier pseudonyms, dropped XML lines and how values are rendered. Identifiers in other forms, such as a phone number on its own, pass unchanged, so code must not log them. - The protocol library's logger and the Signal library's logs reach that writer through an adapter. The MCP library is given no logger, so its log output is discarded (row 24).
- The engine core's log events and metrics carry states, counts, fixed reason codes and the Go types of errors. They never carry an identifier, a name, message text or a pairing code, which a test checks with canary content.
- Operational events go through one notifier (row 19), which writes each one with a fixed set of fields.
- Metrics carry build information, states, counts, fixed reason codes and the names of goroutines that panicked. Metrics in embedded metric format (EMF) are written through the log writer.
-
Service to data directory and backups.
- The data directory must be owned by the service's user with mode
0700, and the service creates it that way. - The service creates
keys/and the master key in it. It refuses to start unlesskeys/is a directory with mode exactly0700and the master key a regular file of 32 bytes with no permission for group or others. Both must be owned by the service's user, and neither may be a symbolic link. archive.dband its journal must be regular files owned by the service's user with no permission for group or others.session.dband its journal are checked the same way, and opened with the archive's settings and lock.history/andbackups/, when present, must be directories with mode exactly0700owned by that user.- Backups are encrypted to an operator-supplied age recipient, so the
service can write them but not read them. Before each backup,
backups/and its staging directorybackups/tmp/must be directories, not symbolic links, of mode exactly0700owned by the service's user. - Startup refusals and row 20 give the rest.
- The data directory must be owned by the service's user with mode
-
Source to release artifacts. Releases are built only from
mainby a manually dispatched workflow that waits for the maintainer's approval. Each is rebuilt independently and compared bit for bit, attested and signed. The approval gate and the immutability of published releases come from repository settings: a protectedreleaseenvironment, immutable releases and a tag ruleset. The workflow checks the environment before building, and fails after publishing a release that is not immutable. It cannot check the tag ruleset. See RELEASING.md. -
Service to the webhook receiver. This is the canonical statement of the webhook's rules.
- When
WAWARDEN_NOTIFY_URLis set, each notification event is posted to it with the same fields as its log line, an event id and a time. - Each post is signed with HMAC-SHA256 over the id, a timestamp and the body, under an operator-supplied secret. A receiver can therefore refuse forged, altered and replayed deliveries.
- The URL must be
https, without credentials. The client takes no proxy, follows no redirect, and uses TLS 1.2 or later and timeouts. It reads at most 4 KiB of an answer, which it never logs. - Its dialer checks every resolved address, on every connection. It always refuses link-local, metadata, multicast, reserved and unspecified addresses, every IPv6 address with a zone, and the local-use NAT64 prefix. It checks an address in the well-known NAT64 prefix as the IPv4 address it embeds. It refuses loopback, private and shared addresses unless the operator allows them. So a misconfigured URL, or a name that resolves inward, cannot make the service post to the cloud metadata endpoint.
- The configuration is the operator's. The check guards against mistakes, not against whoever can set the service's environment.
- A bounded queue and one sender keep a slow or failing receiver from
holding up the engine, the admin routes or shutdown. Events it cannot
deliver are counted and dropped. Those dropped when the shutdown's grace
period runs out are also reported in one line, and make
serveexit1.
- When
-
Admin CLI to the admin listener. This covers
wawarden admin status,pair,reconnect,clients,chats listandbackfill.- They take the token from a file, standard input or a command, never from an argument or the environment, and check its form before sending it.
- They take no proxy, follow no redirect, warn when plain HTTP leaves loopback, and cap what they read.
- Everything they print from an answer passes through the terminal sanitiser.
- A flag they cannot parse is named, never repeated as typed, so a token typed as a flag's value does not reach standard error.
- The token command runs without a shell, with its standard input and
error closed, its output capped and a timeout. It runs in its own process
group, is killed after it finishes, and gets no
WAWARDEN_variable in its environment. admin clients createprints the new client token once, on standard output, and nothing shows it again.wawarden audit verifycontacts no service. It reads a copy of the archive, read-only, with a copy of the master key and of the retired keys.wawarden keys rotatecontacts no service either. It readsarchive.dbonce, read-only, to refuse while a service holds it, and writes only underkeys/. It prints key ids, never a key.
Bearer tokens without network binding¶
A token is a pure bearer credential. It is not bound to a host, a network address or a network identity, and the service contains no network gate. This section is the canonical statement of the trade-offs that follow:
- A stolen token works from anywhere with network reach to the service. Nothing in the service ties it to a place.
- A mistake in the deployer's network setup is not caught by the service. The service warns at start only for a client or admin listener bound to a non-loopback address. When a forwarder, sidecar or reverse proxy in front of loopback listeners exposes them too widely, the service binds loopback and logs nothing. The hardening guide describes deployments that avoid this.
- Plain HTTP exposes bearer tokens on any network segment the deployer leaves unencrypted.
- The admin surface is guarded by its token alone (see The admin token).
What limits a client token instead:
- its chat scope;
- a mandatory expiry: 90 days by default and 365 days at most. A token that never expires cannot be created;
- revocation, checked on every request;
- per-client read and search budgets (row 22);
- for write tokens, the send budgets and the first-contact rule;
- the failed-authentication throttle, which counts but does not stop guessing (below).
Failed authentication¶
The client and admin listeners each count failed authentications, in
wawarden_auth_failures_total and wawarden_admin_auth_failures_total. Each
listener has one failure budget shared by all callers: 30 failures, refilled
at one per second. While it is empty, failures are answered 429 instead of
401.
This throttle is not a defence against guessing:
- Authentication still runs for every request, and a valid credential is always accepted. Legitimate callers cannot be locked out, and a correct guess succeeds whether or not the budget is empty.
- Anyone who can reach a listener can keep its budget empty. Callers with a
wrong token then see
429instead of401.
The entropy of the tokens is what defeats guessing. The counters exist to alert on.
Client tokens¶
-
Form and storage. The admin creates a client through the admin listener only, with a name, a read scope (chosen chats, or all chats) and an expiry.
- The service draws a token of 64 characters:
ww_, the client's identifier from 5 random bytes, 32 random bytes in base64url, and a CRC-32 that lets secret scanners recognise it. Only its SHA-256 is stored. - The token is answered once, under the key
credentialof the create answer. That is the only response type that can carry that key, which a reflection test checks. The CLI prints it once on standard output. - No log line, event, audit line or other answer carries it. Tests of the store, the service and the CLI check this with the generated token as a canary.
- The service draws a token of 64 characters:
-
One path for every outcome. Authentication runs the same steps for every token:
- It parses the token and looks its identifier up in an in-memory copy of the clients.
- It hashes the presented token and compares the hash, in constant time, with the stored one. When the token is malformed or its identifier unknown, it compares with a random dummy drawn at start instead.
- Only then does it check revocation and expiry.
A deterministic test counts exactly one hash and one comparison for a valid, malformed, unknown, wrong, revoked and expired token. The digest type cannot be compared with
==(row 12). The look-up never queries the database, so failed guesses cannot queue on the archive's connection. Every failure is the same401and spends the failure budget. - A copy that is not current. While the in-memory copy is not current, every token is answered503(busy) withRetry-After: 1, after the same hash and a comparison with the dummy. That answer counts no failure and spends no failure budget. - Revocation. These changes mark the in-memory copy stale and rebuild it inside their transaction: creating a client, revoking one, and a re-key that moves a client's chat from a phone number to a LID. - The new copy is published once the transaction commits, and before the change returns, so the next authentication sees it. - An authentication between the mark and the publication is never answered from the old copy. It waits for the new one, for at most the 2-second read deadline, and otherwise answers503. - A change that does not commit publishes nothing. The copy is then reloaded from the archive in the background, and authentications answer503until it is. - A request already authenticated finishes with the grant it holds, within the 2-second read deadline. - Tested with the real client list ininternal/store/admin/cache_test.go,internal/store/scoped/auth_http_test.goandinternal/api/pipeline_test.go. -
Write chats are readable chats. Write chats must be a subset of read chats, and a client that reads every chat holds no write chat. Database triggers enforce this too. One of them refuses to replace a client row, since a replacement would keep its chats without the update trigger running. A client allowed to write to a chat it cannot read could learn, from the answer to a reply, whether a message exists.
- Unknown chats. A write chat the archive has not seen is refused, unless it is a direct chat and first contact is allowed. A read chat not seen yet is allowed, and reported as not known.
- All-chats clients. A client that reads every chat is created only by
asking for it explicitly. The CLI warns. The admin status reports the number
of active ones, and the
all_chats_clientwarning while one is active. - Rotation. A token is never renewed or shown again. Rotating it means creating a client with the same scope under a new name, switching the agent to the new token, and revoking the old client; see agents.md.
The admin token¶
This section is the canonical statement of what the admin token is, what it guards and how its misuse shows.
-
Only generated tokens are accepted.
wawarden admin initprints a token made ofwwadm_, 32 bytes from the operating system's cryptographic random source in base64url, and a CRC-32 checksum that lets secret scanners recognise it.- The admin listener refuses any presented string that does not have exactly this form, even when its SHA-256 matches the configured hash. A chosen passphrase therefore cannot serve as the admin token.
- The service stores and compares an unsalted SHA-256, in constant time.
That hash travels in configuration that many people and systems can read:
container or task definitions,
docker inspectoutput, CI logs. With 256 random bits behind it, recovering the token from the hash is infeasible.
-
It does not expire. Rotate it by generating a new token, replacing the configured hash and restarting the process. The hash is read only at start.
-
It is a single factor. Within whatever network boundary the deployer provides, anyone who can reach the admin listener and holds the token can:
- read the Prometheus metrics;
- read the status: the engine's state, counts and the last ingest time, never an identifier;
- request pairing codes for the owner's number while no device is paired,
which shows a notification on the owner's phone. Pairing can link only the
number in
WAWARDEN_OWNER_PHONE, is refused while a device is paired, and is limited to three attempts an hour (see Pairing refusals); - request reconnections, which are not rate-limited beyond the token. A caller repeating them while the engine is connecting makes it dial WhatsApp again each time;
- create and revoke clients, writing ones included, and list every chat's identifier and name;
- request a history backfill, within its quotas.
-
The pairing code crosses the network in plain HTTP unless the deployer encrypts the path. A code is useful only to link the gateway itself, on the owner's phone, within WhatsApp's validity window.
- Metrics scrapers hold the admin token. Prometheus metrics are readable only with it, and scrape configurations are often widely readable. Treat any scrape configuration as holding the admin token. Prefer the EMF metrics, which need no token, for alerting.
- Detecting its misuse. Its use is made visible, not prevented:
- failed admin authentications are counted in
wawarden_admin_auth_failures_total, and reported in theadmin_auth_failureevent, at most once a minute with a count and nothing about the requests, and in the EMFAdminAuthFailuresmetric, which needs no token; - every call of a route that changes state reports an
admin_mutationevent with its action and outcome; - both events reach the webhook when one is configured;
- each client the token creates or revokes is recorded in the audit chain with the client's identifier, which events never carry.
- failed admin authentications are counted in
Keep the admin listener reachable from fewer places than the client listener.
Audit chain¶
What it records¶
- Client changes. One row per client change, appended in the same write transaction as the change, so the change fails when the row cannot be written.
- Client requests. One row per client request that the read budget
admits, allowed or refused, appended before the answer is sent. A request
whose row cannot be written returns nothing it read: it is answered
503(busy) when the row waited 2 seconds for the archive connection, and500otherwise. - Requests whose client left. The row is written whether or not the client is still connected. Hanging up while a send waits for its acknowledgement therefore does not keep the send out of the chain. A send that went out before its row failed is a residual risk (see Sends).
- What it does not record. Failed authentications and requests refused by the read budget write no row. Guessing and flooding therefore cannot grow the table faster than the budget allows.
- The chain. Each row's HMAC-SHA256, under a key derived from the master key, covers its fields and the previous row's HMAC.
- The chain head. After the commit, one line on standard output carries the client identifier, the action, the outcome, an HMAC of the chat (never the chat), the key id, and the first 16 bytes of the row HMAC: the chain head.
What verification shows¶
wawarden audit verify recomputes the chain from a read-only copy, with the
master key and the retired keys, each row under the key its key id names. It
reports:
- an edited row, a forged HMAC, or a row deleted from the middle;
- a chain recomputed with another key;
- a row written under a key it was not given;
- a row written under a retired key after one written under the current key.
It checks the audit anchor's MAC with the key whose id the audit anchor names, and starts the walk from it:
- an edited audit anchor is
anchor_invalid; - a removed audit anchor, or a removed first retained row, is an
id_gap; - rows cut from the start behind an audit anchor copied from the last of them,
without the key, are
anchor_invalid; - so are rows cut from the start behind an audit anchor made under a retired key, when no row is left or the first one left was written under the current key. No removal leaves that state.
A shipped head that the copy lacks counts as pruned only when two things hold:
its log time is not after the audit anchor's, and, behind an audit anchor made
under a retired key, its line names a key other than the current one. The
verification and tamper matrices, with one and two keys, are in
internal/store/admin/audit_test.go, internal/store/admin/prune_test.go and
cmd/wawarden/audit_test.go.
Retention¶
- When. Each night, after the backup of the night's slot and never while
it is owed, the scheduler removes the rows written more than
WAWARDEN_AUDIT_RETENTION_DAYSdays before. The setting runs from 7 to 3650 and defaults to 90. No setting keeps rows forever. - How much. It works in transactions of at most 10,000 rows, for at most 15 minutes. It never removes the newest row, so the chain never restarts from its genesis.
- After the first backup. With a backup recipient, nothing is removed before the first backup succeeds. The retention therefore also waits while no device is paired and while the first backup fails.
-
The audit anchor. Each transaction first moves the audit anchor to the last row it removes. The audit anchor holds:
- the row's id, key id and HMAC;
- the latest time of the rows removed so far, but never more than 10 seconds (the archive's write timeout) after the removal's cutoff;
- the removal time;
- an HMAC over them under the current master key's audit-chain key.
-
Checks before signing. Before it moves the audit anchor, the transaction checks the audit anchor's MAC under the keys the service holds: the master key and the retired keys. It then recomputes the HMACs of the rows it removes, from the audit anchor's or from the genesis, with the rules of
audit verify:- consecutive ids;
- each row under the key its key id names;
- no row under a retired key once the audit anchor or an earlier row names the current key;
- no row under the current key first behind an audit anchor made under a retired key.
-
A failed check. When a check fails, that transaction moves and removes nothing, and logs
audit_prune_refusedwith reasonchain_mismatch. The retention stops until the next night, when it tries again. Transactions that committed earlier that night keep what they removed. Rows under a key the service does not hold are never removed. -
Confinement. Architecture tests (
internal/archtest/sends_test.go) keep the retention in one place:- only
internal/store/admin/prune.goholds the statement that deletes audit rows, comments or a quoted schema name inside it included; - only it and the ingest schema hold SQL that writes the audit anchor;
- only
internal/app, the admin store and the backup taker may name aPrunemethod.
The first two read string literals and statements assembled from a file's constants. They guard against mistakes. The audit anchor's MAC, which
audit verifychecks, is what detects a moved audit anchor. - only
Triggers and shipped heads¶
Database triggers refuse:
- an
UPDATEor a replacingINSERTof a row; - a
DELETEof a row that the audit anchor does not cover, or that is the newest row; - moving the audit anchor back, or removing it.
Whoever can write archive.db can drop the triggers. Whoever also holds
keys/master can recompute the whole chain after editing it, or delete rows
from its end, and the table then verifies. Only the chain heads already
shipped off the host reveal that, through audit verify --log, which reports
every shipped head the copy lacks. A test shows a truncated tail verifying
without the heads and failing with them.
The root of trust for the audit chain is therefore the log pipeline, not the key: ship standard output where the host cannot rewrite it.
Security invariants¶
These properties must hold in every release. Secure by construction lists the mechanisms that enforce them.
- I-0 Authentication first. On the client and admin listeners, no route handler runs and no request body is read until the request presents a valid, unexpired, unrevoked credential.
- I-1 Read scope. A client receives data only from chats in its read scope.
- I-2 Write scope. A client sends only to chats in its write scope. A client that may read every chat cannot write at all.
- I-3 Resolution in the chat. Every entity a request refers to, such as a
reply_toreference, and every protocol message from WhatsApp (edit, revoke, reaction, poll update) is resolved inside the chat it belongs to, never by identifier alone. - I-4 Denied equals missing. A request for something outside the client's scope and a request for something that does not exist receive byte-identical responses.
- I-5 Nothing identifying leaves the process. No chat identity in plaintext and no message content, query text, token or protocol message reaches logs, metrics, notifications or MCP tool definitions.
- I-6 Fixed network surface. The process listens only on the client listener, the admin listener (when configured) and the loopback health listener.
- I-7 No plaintext credentials in the service. The admin credential is configured as a hash; client tokens are stored as hashes.
-
I-8 Deletions are honoured on disk. Once the write that revokes, edits or expires a message has returned without error:
- the message's row no longer holds its old text;
- the full-text index, page keys included, keeps only those of its trigrams (the three-character runs the index stores) that a remaining message holds;
- neither that text nor any of its trigrams is in the database or its journal, unless the archive still holds the same text or trigram elsewhere. Elsewhere means a remaining message, a message waiting in the inbox, a stored name (a push name, saved contact name, chat name or group subject), an identifier or another stored value, or the schema.
The exceptions are backups, a page key that holds less than a whole trigram, a trigram left by a stop or a failure between the deletion and the index rewrite it requires, and a revocation or edit that arrives before history sync has stored its target (see Deletion and storage). - I-9 Panics are contained. A panic is recovered, counted and logged without its value when it happens in a goroutine that WaWarden starts, in one of its HTTP handlers, or in the protocol adapter's event handler, which runs on the protocol library's goroutines. Other code on goroutines that dependencies start is not covered (see Dependencies).
Secure by construction¶
Each mechanism makes a class of mistake fail to compile or fail a test, rather than relying on review. Each subsection has a stable row number, so other documents can cite it as "row 4". Each gives the mechanism, what enforces it, its limits where a check can be passed around, and the release that introduced it.
The architecture tests live in internal/archtest, and
CONTRIBUTING.md says how to run them and fix a failure.
- They parse every Go file of the module with the standard library's
go/parser. - They build fixture packages, and fixture files added to package policy with a
go buildoverlay, that must fail to compile with a specific error. - Each rule has violating and conforming self-tests.
- They read only Go. So the walk that feeds them fails, and with it the test that runs every rule, when the module holds something the go tool can build in without their reading it. That is an assembly, C, C++, Objective-C, Fortran, SWIG or system object file, a symbolic link, or a nested module.
These rules keep the walk complete and the source honest. Each reads every file, test files included:
hidden-packages. The walk and the linters skip directories namedtestdataorvendor, and those whose name starts with_or.. No file may import a package of this module from such a directory, since it would be built in without being checked.generated-headers. No file may carry a generated-code header (// Code generated ... DO NOT EDIT.), which makes linters and reviewers skip the file. The module holds no generated code.nolint. A//nolintdirective must name its linters, and may not silencedepguardorforbidigo.credential-literals. No string literal or comment may hold a whole admin or client token of the generated shape. Secret scanners would read one as a live credential, so test vectors are assembled from parts.
1. Grant types¶
Mechanism.
ReadGrant,WriteGrantandAdminGrantare declared with only unexported fields ininternal/policy/internal/seal. Package policy exports them under the same names.- Outside the seal package, a valid grant can only come from the decision
functions in
internal/policy/decide.go. - Decisions deny by default. A missing, revoked, expired or never-expiring client gets no grant. A client without write chats, or one that may read every chat, gets no write grant. The zero value of every grant allows nothing.
- Grants copy the client's chat sets, so changing a client afterwards does not change a grant.
- The scoped store,
internal/store/scoped, is howinternal/apireads the archive. Every exported method of its exported types takes apolicy.ReadGrantvalue as its first parameter, exceptQuery.Expression, which reads nothing. Its constructor takes the database handle, which no package outsideinternal/storecan name. - Only such a method may call
Reader.read, the package's one way to the database (row 3a). It calls it only on its receiver, with its own first parameter as the grant. It may otherwise use that grant only as the receiver of the grant's methods: never assigned, shadowed or passed on. - Only
internal/apimay referencepolicy.DecideRead,policy.DecideWriteorpolicy.DecideAdmin. Package policy names its own functions without an import. So the scoped store cannot decide a grant of its own.
Protects. I-1, I-2, I-7.
Enforced by.
- The compiler. The fields belong to the seal package, so every other package,
package policy included, cannot write a grant literal with fields, convert
from a look-alike type, write a field or take a field's address. Fixtures in
internal/archtest/testdataprove it, outside and inside package policy. - The architecture rules
seal-constructors(row 4),scoped-grant-first,scoped-read-pathandgrant-decisions. - Unit tests of every decision, of zero-value grants and of grants built
without their validity flag, and a reflection test that the grant types have
no exported fields (
internal/policy/decide_test.go). - That each scoped method's rows honour its grant is property-tested (row 7).
Limits. See row 3a for what the scoped-read-path rule cannot see.
Since. v0.1.0.
2. Registration helpers¶
Mechanism.
- Client and admin routes are registered only through the unexported helpers
read,writeandadminininternal/api. Each obtains a grant from the matching decision before its handler runs. When the decision denies, it answers the same404as an unknown route. - The mux is unexported, and only
internal/api/register.gomay create one or register on it. So a route without a policy class cannot be added from outside. - Authentication runs before the mux. The health listener serves only
/healthz. POST /mcpis registered through a fourth helper,mcp, with its own classmcp, behind the same pipeline.- Each MCP read tool is registered through
readTool, which obtains a read grant fromDecideReadon every call and answersnot_foundwhen it is denied.send_messageis registered throughwriteTool, which obtains a write grant fromDecideWriteon every call the same way. The send route is registered throughwrite. - Only
internal/apiserves requests, and it reads JSON bodies only. No code names anet/httpform-parsing method:ParseForm,ParseMultipartForm,FormValue,PostFormValue,FormFileorMultipartReader.internal/apimay import neithertext/templatenorhtml/template. - The MCP library's registration surface is confined. The server's
AddToolmethod and the package's genericAddToolmay appear only insidereadToolandwriteToolininternal/api/tools.go. The server's construction and middleware may appear only insidenewMCPEndpointininternal/api/mcp.go. Prompts, resources and custom methods appear nowhere. Each of the endpoint's two servers admits exactly the tool names its helpers recorded.
Protects. I-0, I-1, I-2, I-4.
Enforced by.
- Compile-time fixtures that reach the router from another package.
- The architecture rules
mux-ownership,form-parsingandmcp-registration. - A coverage test that compares every registered route and its class with a
golden list (
internal/api/coverage_test.go). - Pipeline tests that authentication precedes routing and that no body is read
before it, form-encoded and multipart bodies included
(
internal/api/pipeline_test.go).
Limits.
form-parsingrefuses, in every non-test file, a reference to those method names, and any string literal or concatenation of literals in which one appears as a word. A name assembled from named constants or at run time, or written outside Go source, such as a template file embedded withgo:embed, passes it.mcp-registrationrecognises a method called, taken as a method value or as a method expression, under any import name, by name insideinternal/api. That is the only package that may import the MCP library.
Since. v0.1.0.
3. Import fences¶
Mechanism.
internal/apimay not importdatabase/sql,text/template,html/template, the SQLite driver, the protocol library, any package underinternal/engine, orinternal/sender(row 26). Of the packages underinternal/storeit may import onlyinternal/store/scoped, whose every read takes a grant (row 1). It reaches the engine and the sender only through interfaces thatinternal/appwires.- Outside test files, only packages under
internal/storemay importdatabase/sql, and onlyinternal/store/internal/dbthe SQLite driver. - Outside test files, only
cmd/wawarden/tokencommand.go, the admin CLI's token command, may importos/execor nameos.StartProcess,syscall.Exec,syscall.ForkExecorsyscall.StartProcess. - No non-test file may import
mime/multipart. internal/policyand its seal package may import only an allow-list of standard-library packages, which excludes networking, operating-system and filesystem packages.- Package policy may also import the seal package, and no other package may.
Go's
internalrule refuses it outsideinternal/policy, which a fixture proves. Theseal-constructorsrule refuses it in every file except those of package policy and of the seal package's own directory, test files included. So an external test packagepolicy_testmay not import it either. net/http/pprof,expvar,net/http/cgi,net/http/fcgi,plugin,unsafeand cgo are banned everywhere.- Outside test files, only
internal/api/dtoandinternal/logxmay importreflect. No file may use a selector namedNewAt,SetPointer,UnsafeAddrorUnsafePointer: thereflectfunctions and methods that read or write memory through an unsafe pointer without importingunsafe. - The non-test files of
internal/cursor,internal/keys,internal/logx,internal/ratelimitandinternal/sanitizemay import only the standard library. -
A module outside the standard library may be imported only when it is on a reviewed list:
- the SQLite driver,
modernc.org/sqlite; - the protocol library,
go.mau.fi/whatsmeow, and the Signal library it uses,go.mau.fi/libsignal; - protobuf,
google.golang.org/protobuf; - the backup encryption library,
filippo.io/age; - the Unicode text library
golang.org/x/text, which outside test files onlyinternal/store/scopedmay import, to normalise search queries to NFC; - the MCP library,
github.com/modelcontextprotocol/go-sdk, and its JSON Schema library,github.com/google/jsonschema-go, which outside test files onlyinternal/apimay import; - the property-test library
pgregory.net/rapid, which only test files may import, so it is never linked into the binary.
- the SQLite driver,
-
go.modmay require only those modules, and, marked// indirect, the 28 modules on a second reviewed list of what they require.modernc.org/libcmust be at exactly the version the driver's owngo.modnames, as the driver's documentation demands.
Protects. I-1, I-5, I-6.
Enforced by.
depguardingolangci-lint.- The architecture rules
import-fences,banned-imports,reflection,third-party-imports,standard-library-only,process-executionandconfined-imports(internal/archtest/imports_test.go,confinement_test.go,exec_test.go). The banned-import, third-party and reflection-selector checks cover test files too. - The fence's self-test accepts
internal/store/scopedand refuses its subpackages,internal/store/ingest,internal/store/admin,internal/store/sessionandinternal/store/internal/db.
Since. v0.1.0.
3a. One database handle¶
Mechanism.
- The raw
*sql.DBlives only ininternal/store/internal/db, the only importer of the SQLite driver. Other packages reach it through itsReadandWritemethods, each of which runs under a deadline inside a transaction. - That package alone names a database file, opens, inspects or removes one,
and runs
PRAGMA,VACUUM,ATTACHorDETACH. So no other code of this module can change a verified setting, open another database through SQL, or close a descriptor of the database file and so drop its lock. - Everywhere else, the SQL text passed to
Exec,Query,QueryRoworPrepare, and theirContextforms, is a string literal, a constant declared once in the same file, or a concatenation of those. So is each schema step passed to that package'sMigrate, in a slice literal at the call. No SQL is built from values. - In
internal/store/scoped, onlyReader.readreaches the handle. It resolves the caller's grant to a scope and hands its callback a scoped querier. The querier'sQueryContext, and the row collector's, take only constants of the calling file. Its single-rowscalarruns only three reviewed constants (row 5). Onlyinbuilder.goturns a query's scope markers into SQL. - The connection's settings are row 15.
Protects. I-1, I-3, I-8.
Enforced by.
- Go's
internalpackage rule, anddepguard. - The architecture rules
confined-imports,database-files,sqlite-statements,constant-sql,scoped-read-pathandgrant-decisions(internal/archtest/storage_test.go,scoped_test.go,decisions_test.go):sqlite-statementsreads every string literal and concatenation of literals, and also the whole text of each SQL argument and schema step with the file's constants resolved. A statement split across constants is therefore refused.constant-sqlrefuses schema steps that are not such a slice literal, and those methods andMigrateused as values.scoped-read-pathconfines each selector, type and literal that reaches the handle, the querier or the scope to the one function of the scoped store that needs it. It refuses conversions to those types,newof either, and another type name declared as either.scoped-read-pathalso refuses a call through a selector, with arguments and on any receiver, of a method named like one of the package's read methods. A test holds that list equal to the methods the package declares. So no read method calls a sibling that way, which would read under a grant that is not the caller's own.
Limits.
- A database file name assembled at run time from parts that are not literals
passes
database-files. - A read method taken as a value and called later, such as
f := r.Chats; f(…), passesscoped-read-path. Without type information it cannot be told apart from a field of the same name. Such a call would still need a valid grant other than the caller's own, which only a decision could produce, andgrant-decisionsrefuses a decision there. - The tests catch mistakes, not a deliberate edit of that package or of the tests themselves, which review covers.
Since. v0.2.0.
4. Chat identifier normaliser¶
Mechanism.
CanonicalChatisseal.Chat, declared in the seal package with unexported fields and a validity flag. Its zero value is invalid.- Outside the seal package, a valid chat can only come from
Normalize, ininternal/policy/normalize.go. - Servers.
Normalizeacceptss.whatsapp.net,lidandg.us, andc.us, which it maps tos.whatsapp.net, in lower case only. It rejects every other server,broadcast,newsletter,hosted,hosted.lid,bot,msgrandinteropincluded. -
Users. Before the
@of a phone-number or LID user it acceptsUSER,USER:DEVICEorUSER.AGENT:DEVICE.USERis 1 to 24 digits,AGENT1 to 3 digits of value at most 255, andDEVICE1 to 5 digits of value at most- It drops the agent and the device, and rejects an out-of-range value instead of wrapping it.
-
Groups. Before the
@of a group it accepts 1 to 24 digits, or two such runs joined by-. - Rejections. Digits are ASCII only. Any other byte, the empty string and an input longer than 128 bytes are rejected. A rejection returns the zero value and allocates nothing. An accepted input allocates only to build and intern its result.
- Result. The result is the user or group,
@and its server. Normalising it again returns the same value. - Printing. A chat holds its identifier as a
unique.Handle[string]. Sofmtand the text handler oflog/slog, which print unexported fields, print a pointer in its place, andencoding/jsonand the JSON handler oflog/slogprint{}. It exposes the identifier only throughJID(), its kind (phone-number user, LID user or group) throughKind(), and its validity throughValid(). It declares noString,GoString,Format,MarshalText,MarshalJSON,MarshalBinary,LogValueorErrormethod. - Decisions drop the zero value from every scope, and grants never allow it.
The guarantee. Outside internal/policy/internal/seal, a valid chat can
only come from Normalize, and a grant only from a decision function. It rests
on:
- the compiler, which refuses a literal with fields in any syntactic form, a conversion from a look-alike type, a field write and a field's address;
-
one constructor-reference rule,
seal-constructors. A function the seal package exports may be referenced only as a direct call, outside any function literal, in the body of its one permitted function:NewChatinNormalize;NewReadGrant,NewWriteGrantandNewAdminGrantinDecideRead,DecideWriteandDecideAdmin;NewAdminCredentialinParseAdminCredential;- the constant-time digest comparison
MatchAdminCredentialinDecideAdmin.
It refuses renamed, dot and blank imports of the seal package. Another test requires its table to name exactly the functions and variables the seal package exports. A reviewed list must name exactly its exported methods whose results mention a sealed type: only
ReadGrant.Chats, which returns copies of the grant's chats. So a method that could mint a sealed value fails the test until it is reviewed; - the rules that keep code from going around the type system: - the ban onunsafe, whichgo:linknameneeds to call a constructor by its symbol name, and the ban on cgo; - the reflection rule's ban on the unsafe-pointer functions and methods ofreflect(row 3); - the walk's refusal of assembly, C and object files.
Weakening any of these rules weakens the guarantee. Inside the seal package anything is possible: its code can build any value. So it holds only the types, their accessors, one constructor per type and the digest comparison, and review, not a test, keeps it that way.
Protects. I-1, I-2.
Enforced by.
- Compile-time fixtures added to package policy, with a positive control that
proves the fixtures are compiled into the package
(
internal/archtest/testdata). - The architecture rules
seal-constructorsandchat-methods(internal/archtest/constructors_test.go,chats_test.go).chat-methodsrefuses the printing and marshalling methods above onseal.Chat, and on any alias of it declared in the seal package, in every file of the seal package, test files included. The compiler refuses methods declared onCanonicalChator any other alias outside it. - Table, canonical-form, allocation, printing and zero-value tests
(
internal/policy/normalize_test.go,decide_test.go), and fuzzing (row 8).
Since. v0.2.0.
5. One IN-list builder¶
Mechanism.
- All chat-set SQL goes through
internal/store/scoped/inbuilder.go. - Each read query carries scope markers that only the builder expands. Three queries that return no message content carry none: the look-up of a phone number's LID in the mapping, and the highest change number and the highest message sequence number of the whole archive.
- An invalid grant or an empty set yields
AND 0, and an all-chats grantAND 1. A set yieldsAND <column> IN (?n, …), with one numbered parameter per chat, bound to the grant's sorted, distinct canonical identifiers. So the SQL text never holds an identifier, and an invalid chat is left out of a set. - The column comes from the constant query text. The same parameters serve every marker of a query, so the sender-name join is restricted by the same set.
- A set of more than 1,000 chats is an error, never a truncation.
- The builder refuses a query without a marker, and the single-row helper refuses any text but those three. An unexpanded marker is not valid SQL.
Protects. I-1.
Enforced by.
- A source test. It requires the SQL text of every
QueryContext,QueryRowContextandscalarcall of the package to be one of its constants, except where the builder passes on the query it was given. Every such constant, and every constant holdingSELECT, must carry a marker unless it is one of the three. - The
sql-in-listsarchitecture rule. In every file except the builder's, it refuses anIN (string literal, including one assembled by concatenation and one with any white space or SQL comments that SQLite accepts betweenINand(. It also refuses an SQL argument whose text holds one once the file's constants are resolved. - Property-tested over random grants and fuzzed over the SQL text
(
internal/store/scoped/inbuilder_test.go). A test proves thatAND 0ends a query before its first row.
Since. v0.1.0.
5a. Uniform resolution¶
Mechanism.
- A chat reference, the chat filter of a search or of the change feed, and a message's chat are resolved by one query that carries the scope clause, never by a look-up followed by a check.
- A chat outside the grant, a missing chat and a malformed reference all return "not found", with the same SQL text and the same empty result.
- A message named by its chat, identifier and sender is canonicalised through the identity mappings. It is refused before any message query runs when the grant does not allow its chat. The message query carries the scope clause as well.
Protects. I-1, I-4.
Enforced by. Tests that an out-of-scope chat, a missing one and garbage
issue identical SQL and return identical results, and that an out-of-scope
message issues no message query (internal/store/scoped/reads_test.go);
property-tested (row 7).
Since. v0.3.0.
5b. Bounded reads¶
Mechanism.
- Every read query has a
LIMITof at most 201, works on index ranges, counts nothing, and finishes within the read deadline. - Search walks the full-text index in windows of 20,000 sequence numbers, and the change feed in windows of 20,000 change numbers. Each call walks at most five windows, with a cancellation check between them.
- A change feed started from a time looks in the same windows for the first change, dated at or after that time, of a message in scope. It carries the time in its position until it finds one, and from there returns every change in scope.
- At most 8 reads run at once. A read that finds every slot taken, or that passes its 2-second deadline, fails with a "busy" error at once instead of queueing behind the engine's writes. The audit row of its answer waits at most 2 seconds for the connection before the answer is also "busy".
- Nothing runs
ANALYZEorPRAGMA optimize, so the planner keeps choosing by the schema alone. Statistics can turn a cross-chat search of index entries into a scan of every message followed by a sort.
Protects. Availability of reads and of ingest, which share the archive's one connection.
Enforced by.
EXPLAIN QUERY PLANgolden files for every read query, with an all-chats, an empty, a one-chat and a three-chat grant (internal/store/scoped/plans_test.go). The test refuses a scan of a table, and a temporary b-tree other than the chat list's sort of at most 1,000 granted chats and the change feed's sort of one window. A source test requires everySELECTconstant of the package, and every constant passed as the SQL text of a query call, to have a golden plan, and refuses any other SQL text at such a call.- The
no-analyzearchitecture rule, which refusesANALYZEandPRAGMA optimizein every non-test file. - Tests of the read slots and of reads past their deadline, and HTTP tests
with the real audit chain that a read with every slot held, and a read behind
a held write, are answered
503(busy) within seconds (internal/store/scoped/reads_test.go,http_property_test.go).
Limits.
- The bound is on time, not on what time reveals. Search applies the scope after its walk of the index, and both walks count windows over the whole archive. So latency and the number of pages depend on chats outside the scope (see Timing and volume).
- The plans are those of the SQLite version the driver pins. A driver upgrade that changes a plan fails the golden test until it is reviewed.
Since. v0.3.0.
6. DTO firewall¶
Mechanism.
- Response bodies are types of
internal/api/dto, built field by field. The response interface has an unexported method, so a type declared elsewhere cannot be returned. The encoder also checks this at run time. - Every field of a database row struct carries
json:"-". The scoped store's rows carry noraw,sender_alt,media_metaororiginfield. - No response type can produce the keys
raw,media_meta,sender_alt,seqortoken. Only the client create answer may declare or reach the keycredential. - The read API builds each chat and message body field by field from a scoped
row:
untrustedis a constanttrueof the builder;originisgateway:<client id>for a message a client sent through WaWarden,ownerfor any other message the owner's account sent, andpeerotherwise;sender.nameis the owner-saved name only when the scoped read returned it, which it does only when the contact's direct chat is in the grant, and otherwise the push name.
Protects. I-5.
Enforced by.
- A compile-time fixture that returns a foreign response type.
- Reflection tests over every response type, through pointers, slices, arrays
and embedded structs, for the forbidden keys and the
credentialkey (internal/api/dto/dto_test.go). - A reflection test over the rows, pages and positions of the scoped store:
every exported field, nested ones included, carries
json:"-", and each type encodes to{}(internal/store/scoped/firewall_test.go). A source test keeps it complete by requiring it to list every exported struct of the package. - API tests that every message body carries
untrusted: true, theoriginof its row, the saved or the push name, and null text when revoked (internal/api/reads_test.go).
Since. v0.1.0.
7. Property tests¶
Mechanism. Random archives are built with the real store: phone-number and LID pairs, messages in chats and groups, edits, revokes, re-keys, and chats without messages. They are crossed with random clients (all chats, a set, revoked). Three properties drive them, and compare every answer with a pure model over an unscoped copy of the archive:
- The scoped store. Every read returns exactly the rows in scope, in order, across pages of random sizes in both directions, the change feed from a random time included. A revoked or edited canary never appears. An out-of-scope chat and a missing chat return the same result through the same SQL. A re-key never lets a client see a message, stored before it, that the client could not see. A sender's saved contact name appears exactly when that sender's own chat is in the scope.
- The REST read API, through its real handler and pipeline. Listings, messages, the change feed and searches equal the model. A chat outside the scope answers the same bytes and headers, through the same SQL, as a missing chat on every route that takes a chat. A revoked client's listing answers as an unknown route, without SQL. Replayed cursors stay inside a narrowed grant. No response byte outside the sealed values carries a revoked or edited canary.
- The MCP read tools at
POST /mcpof the same handler.list_chats,get_messages,get_changesandsearch_messagesequal the model. On every tool that takes a chat,get_chatincluded, an out-of-scope chat answers the same bytes, through the same SQL, as a missing one. The REST property's other checks hold here too.
A tally fails the test when the random cases do not cover re-keys, revokes, edits, partly visible archives, change feeds from a time, and saved names both shown and withheld.
Protects. I-1, I-3, I-4, I-8.
Enforced by. pgregory.net/rapid in CI: 100 checks with the race detector
in every test run, and 1,000 without it in a separate job, which uploads the
failure files. See internal/store/scoped/property_test.go,
http_property_test.go and mcp_property_test.go.
Since. v0.3.0.
8. Fuzzing¶
Mechanism. These targets are fuzzed. Each checks that its input never causes a panic, and the property named:
- the admin token syntax check: anything accepted has the generated form
(
internal/policy/admin_test.go); Normalize: it accepts exactly what a regular-expression model of the forms in row 4 accepts, and returns a canonical result (internal/policy/normalize_test.go);Normalizeagainst the protocol library's identifier parser: every chat it accepts is read by the protocol library as the same user and server (internal/engine/wa/fuzz_test.go);- the display and terminal sanitisers: output is valid UTF-8, holds nothing
either removes, and is unchanged by a second pass
(
internal/sanitize/sanitize_test.go); - strict JSON decoding of request bodies: refusals are only the three fixed
errors, and an accepted body meets every rule of the decoder
(
internal/api/decode_test.go); - crafted protocol messages, decoded with a protobuf recursion limit and
translated for three kinds of sender: a revocation or an edit only from a
protocol message with that type set (
internal/engine/wa/fuzz_test.go); - the history decompressor: it never returns more than its cap, and agrees
with an uncapped read (
internal/engine/history_test.go); - the inbox payload codec: a decoded payload re-encodes stably
(
internal/engine/pipeline_test.go); - the client token parser: everything accepted re-encodes to itself
(
internal/token/token_test.go); - the full-text query builder: no operator or column filter reaches FTS5, and
for ASCII queries it returns exactly the rows that hold every term
(
internal/store/scoped/search_fuzz_test.go); - the
INbuilder's SQL text (row 5); - cursor and message-reference opening (row 23;
internal/cursor/fuzz_test.go); - the read API's query parsing: refusals are only
invalid_query(internal/api/reads_test.go); - idempotency keys (
internal/store/ingest/key_test.go), outbound message text (internal/sanitize/outbound_test.go) and the send route's body, key and text checks (internal/api/sends_test.go): only the fixed answers, and nothing refused reaches the sender.
Protects. I-1, I-3, I-5, I-7, I-9.
Enforced by. Go native fuzzing. Seed corpora replay in every test run, and CI fuzzes every target for 60 seconds.
Since. v0.1.0.
9. Panic-safe goroutines¶
Mechanism.
- Every goroutine WaWarden starts goes through
safego.Go. It recovers a panic, logs the panic's type and stack (never its value), and counts it per goroutine name. - WaWarden's HTTP handlers recover the same way and answer a fixed
500. - The protocol adapter's event handler recovers the same way, under the name
wa.event, and refuses the event, so WhatsApp is not acknowledged.
Protects. I-5, I-9.
Enforced by.
- The
goroutinesarchitecture rule. In non-test files outsideinternal/safego, it refuses agostatement,time.AfterFunc,context.AfterFunc,runtime.AddCleanup,runtime.SetFinalizer, and any method namedGoorRegisterOnShutdown. - A test that makes the engine's handler panic on a message, and checks that
the handler the adapter registers with the protocol library
(row 17) refuses it, logs the
panic without its value and counts it (
internal/engine/wa/client_test.go).
Limits. Goroutines that other standard-library code starts internally,
such as http.Server's per-connection goroutines, are not covered by the
rule. The handlers that run on them recover as described.
Since. v0.1.0.
10. Listener inventory¶
Mechanism.
- Only
internal/listenersopens sockets, and onlyinternal/appmay use it. - Only
internal/listenersbuildshttp.Servervalues, each with an explicit handler. net.Listenand related calls outside it,http.ListenAndServe,http.Serve, the package-levelhttp.Handleandhttp.HandleFunc, andhttp.DefaultServeMuxare banned. So a dependency that registers handlers on the default mux exposes nothing.- Tests listen on loopback only. In test files outside
internal/archtest, thetest-wildcard-addressesrule refuses a wildcard address: in a string literal or concatenation, asnet.IPv4zero,net.IPv6zero,net.IPv6unspecified,netip.IPv4Unspecifiedornetip.IPv6Unspecified, or built from zero bytes or from bytes that are not literal constants. A per-file allow-list names, with a count and a reason, the few tests that need one. An example is a test that binds0.0.0.0on an ephemeral port to prove that a non-loopback listener logs a warning.
Protects. I-6.
Enforced by.
forbidigowith type analysis.- The architecture rules
sockets,server-ownership,net-http-identifiersandtest-wildcard-addresses(internal/archtest/ownership_test.go,wildcard_test.go). They also refuse an alias of*http.Server, or, outsideinternal/listeners, oflisteners.Spec. - A test that inspects the test process's open sockets and expects exactly the
client, admin and health listeners (
internal/app/inventory_test.go). - Tests that
/debug/paths answer404on every handler and on every live listener, with handlers registered on the default mux to prove the point (internal/app/app_test.go).
Limits. A generic function that makes the zero value of a type parameter
inferred from a *http.Server passes the rules. Outside internal/listeners,
no listener can be opened for it to serve.
Since. v0.1.0.
11. Golden files for MCP tools¶
Mechanism. The tools/list answer, with every tool's name, description,
annotations and input and output schemas, is built from string constants and
Go types, never from data. It depends only on whether the client may write.
Every client sees the five read tools, list_chats, get_chat,
get_messages, search_messages and get_changes. A client with a write
grant also sees send_message.
Protects. I-5.
Enforced by. A test fetches the answer through the mounted endpoint for a
client with listed chats, an all-chats client and a write client. It compares
each with a committed golden file with sorted keys
(internal/api/testdata/mcp). It refuses a tool name outside [a-z0-9_],
checks that no chat name or reference of the fixture appears, and refuses to
run with MCPGODEBUG or JSONSCHEMAGODEBUG set (internal/api/mcp_test.go).
Since. v0.3.0.
12. Constant-time credential comparison¶
Mechanism.
- The admin credential type and the client token digest type cannot be
compared with
==. - The admin digest is compared only through
crypto/subtle, in the seal package'sMatchAdminCredential. - A client token digest is compared only in
internal/token'sMatch, which every authentication calls exactly once.
Protects. I-7.
Enforced by.
- Compile-time fixtures comparing two credentials and two digests.
- A test counting one hash and one comparison per authentication outcome.
- The
secret-comparisonsarchitecture rule on comparisons ininternal/policy, the seal package included, andinternal/token. Because it recognises calls by name, theshadowingrule refuses, in non-test files under those two packages, any declaration other than a label that is named like a package the file imports or like a predeclared identifier such aslen. Receiver type parameters and import names count as declarations (internal/archtest/secrets_test.go,shadowing_test.go).
Since. v0.1.0.
13. One log writer¶
This is the canonical statement of the log writer's rules.
Mechanism.
servewraps standard output in onelogx.Writerbefore it logs anything. It loads the master key right after the configuration and data directory checks, and keys the writer with thelog-redactkey derived from it.- Every logger is built by
logx.Newon that writer.app.Newtakes it, so these all write through it: the app's logger, its alert logger, the notifier (row 19), the EMF lines, the HTTP servers' error logs, and, onceapp.Newhas installed its logger, the panic reports ofsafego. - A panic recovered before that, in the signal goroutine
mainstarts first, is recovered but neither logged nor counted. So it cannot reach Go's default logger on standard error. -
The writer handles each line in this order:
- It replaces each run with the exact shape of a client token with
ww_, its client id and_[redacted], and of an admin token withwwadm_[redacted]. - It replaces each WhatsApp identifier in
user@serverform, when no ASCII letter or digit follows the server name directly, with a keyed pseudonym:jid:and 8 hexadecimal digits of an HMAC. It keeps a JSON line valid when an identifier follows an escape sequence. - It drops every line longer than 65,536 bytes.
-
It drops every line carrying XML in one of these shapes, also when written with the JSON escapes
\u003c,\u003eand\":- a closing tag;
- a self-closing tag without attributes;
- the start of a tag with an attribute (
<x a="), whatever its value holds and whether or not the tag ends; - a tag directly followed by another (
<x><y); <!--,<![CDATA[or<?xml.
An opening tag without attributes followed only by text is kept. 5. It writes each line whole, under one lock, only once its newline has arrived. So a line written in pieces is checked as a whole.
- It replaces each run with the exact shape of a client token with
-
logx.Newrenders values fail-closed:- a byte slice of any type, or a pointer to one, is written as its length,
even when it has a
StringorErrormethod, and so is a raw JSON value; - any other value that is not a string, number, boolean, time, error or
value with a
Stringmethod is written as its type name; - a protocol message or a URL is written as its type name, whatever its methods.
- a byte slice of any type, or a pointer to one, is written as its length,
even when it has a
-
Text that code formats into a message is only redacted, pseudonymised and checked for XML.
- Protocol library. Its logger feeds the writer through an adapter. The
adapter also redacts tokens, then masks runs of six or more digits outside
the
user@serverform, cuts each line at a word boundary after 1 KiB, and discards debug output outside aWAWARDEN_UNSAFE_DEBUGwindow. - Signal library. The Signal library that the protocol library links for its encryption prints its log lines to standard output unless it is given a logger. The adapter gives it one when it is built. That logger discards its debug and information lines, since they carry key material, and writes its warnings and errors through the protocol-library adapter (row 17).
- MCP library. It is given no logger, so its log output is discarded (row 24). Tool arguments are never logged.
Protects. I-5.
Enforced by.
- The compiler:
app.Newandlogx.Newtake a*logx.Writer. - The
log-handlersarchitecture rule, which refusesslog.NewJSONHandlerandslog.NewTextHandlerin non-test files outsideinternal/logx. - The
log-outputarchitecture rule. In non-test files it refuses Go's default loggers (slog.Default,slog.SetDefault, the package-level logging functions oflog/slogandlog,log.Default,log.SetOutput,log.Writerandlog.Output),fmt.Print,fmt.Printfandfmt.Println, the built-insprintandprintln,syscall.Stdout,syscall.Stderrandsyscall.Write. It refusesos.Stdout,os.Stderrandos.NewFileoutsidecmd/wawarden/main.go, and a string literal naming/dev/stdout,/dev/stderr, an entry of/dev/fd/or of a process's/proc/.../fd/(internal/archtest/logging_test.go). - Canary tests: a canary identifier or token passed as every kind of value,
through every logger path and split across writes, never reaches the output;
every XML shape drops its line; lines stay valid JSON; and the Signal
library's lines reach standard output only through the writer
(
internal/logx/logx_test.go,redact_test.go,internal/engine/wa/log_test.go).
Limits.
- A custom
slog.Handlerwritten elsewhere passeslog-handlers. - A raw system call, or a descriptor path assembled at run time, passes
log-output. log-outputreads only this module's code. Code in a linked module that writes to standard output or standard error itself passes it. Examples are the Signal library's default logger, which a test covers, and the printing functions of the SQLite runtime's C library, such as its assertion-failure handler.- The writer recognises identifiers, tokens and XML by their shape; see Logging.
Since. v0.2.0.
14. The keys directory¶
Mechanism.
- Only
internal/keyscreates, checks and readskeys/, the master key and the retired keys inkeys/retired/(see Startup refusals). It derives every purpose key with HKDF-SHA256. - Only its
Rotatereplaces the master key. Thekeys rotatecommand calls it with a probe that refuses while another process holds the archive's lock.Rotatelinks the old key intokeys/retired/and renames a new one overkeys/master, flushing each file and directory. - The
keyssubcommand's name comes frominternal/keystoo. - A master key value, with its retired keys, prints no key material through
fmt,encoding/jsonorlog/slog.
Protects. I-5, I-7.
Enforced by.
- The
keys-directoryarchitecture rule. In non-test files outsideinternal/keys, it refuses a string literal outside import declarations, a run of concatenated literals, or a run assembled from the file's constants, with a path element namedkeys(internal/archtest/keysdir_test.go). - Tests of every refusal, of racing creators, of derivation vectors checked
against an HKDF written out in the test, of printing, and of rotation and its
refusals (
internal/keys/keys_test.go,retired_test.go,cmd/wawarden/keys_test.go).
Limits. A name assembled at run time, such as one built by
strings.ToLower or a conversion, passes the rule. So does a wildcard or a
directory walk that reaches keys/ without naming it, such as filepath.Glob
on */master under the data directory.
Since. v0.2.0.
15. Verified storage and deletion on disk¶
Mechanism.
- Settings. The archive is opened with foreign keys on, a
TRUNCATErollback journal,synchronousFULL, exclusive locking, temporary storage in memory,secure_deleteON, and a 5-second busy timeout. A per-driver connection hook reads every one of them back and refuses the connection on any difference. SoPERSIST, write-ahead logging andsecure_deleteFASTcannot take effect. The hook then takes the exclusive lock. - One connection. The single connection stays valid after an interrupted call, and a second connection is refused. Otherwise the driver would discard the first and silently drop the lock.
- Lock kinds. Storage profile
localselects open-file-description locks on Linux, and refuses to start when the kernel or the filesystem rejects them. Profilenfsselects classic POSIX locks. A process uses one kind. - The DSN is built with
net/url. - Deletion. The full-text index is created with its
secure-deleteoption. A revoke, an edit or an expiry, in one transaction, issues the index'sdeletecommand with the exact old text, nullstextandtext_display, and writes a new or empty index row. - Page keys. That transaction also tokenises the old text in a contentless full-text table of the in-memory temporary database. Secure delete removes a term but keeps its page key. When a trigram of the old text that no remaining message holds is still a page key of the index, the transaction records that an index rewrite is due.
- Rewrite. After the commit, the index is rewritten whole, under a
5-minute deadline of its own, and the record is cleared in the same
transaction. The rewrite is
optimize, with a filler row added before and deleted after, so that an index of a single segment is rewritten too. A start that finds the record, left by a stop or a failed rewrite, rewrites before it opens the archive. - Quoted text is never stored, and reactions and poll updates store no content.
Protects. I-3, I-8.
Enforced by.
- Tests of each setting refused when it reads back differently, of a lock
kept through interrupted calls (with a negative control), of each profile's
lock kind, of file modes under
umask 0, and of every file-mode, owner, filesystem and lock-wait refusal (internal/store/internal/db/db_test.go,ofd_linux_test.go). - A canary deletion test with negative controls. It byte-scans
archive.dband its journal, open and closed, and checks the index's vocabulary, page keys andMATCHafter a revocation, an expiry and an edit, including a rewrite cut short or run out of time (internal/store/ingest/deletion_test.go).
Since. v0.2.0.
16. A neutral engine core¶
Mechanism.
internal/engineand its subpackages, other than the protocol adapterinternal/engine/wa, handle plain data. They may import neither the protocol library nor protobuf.- Like all code outside
internal/store, they may import neitherdatabase/sqlnor the SQLite driver. The core reaches the archive only throughinternal/store/ingest. - Every goroutine it starts goes through
safego.Go(row 9).
Protects. I-3, I-5, I-9.
Enforced by. The import-fences architecture rule on the non-test files of
those packages (internal/archtest/imports_test.go), and depguard.
Since. v0.2.0.
17. The protocol library behind one adapter¶
Mechanism.
- Imports. Outside test files, only
internal/engine/waimportsgo.mau.fi/whatsmeowandgoogle.golang.org/protobuf, and of the Signal librarygo.mau.fi/libsignalonly its logger package.internal/store/sessionimports the protocol library's two device store packages and nothing else of it. The device store's raw handle, which the protocol library needs, can be reached only there. -
Refused calls. These are refused in every file, test files included:
- presence, chat presence and presence subscriptions;
- read receipts and visible delivery receipts;
- the history download that writes any own device's payloads into the device store;
- every newsletter call and the status-message setter, inside which the protocol library can end the process;
- its raw GraphQL query;
- the QR channel, which logs the codes that carry the device's pairing secret;
- the decrypted event buffer, which keeps plaintext in the device store;
- the protocol library's internals;
- an event handler whose answer the protocol library ignores;
- the removal of one or all event handlers, after which the protocol library would acknowledge every event without delivering it;
- the loggers that bypass the log writer: its standard-output logger, its zerolog adapter and a default context logger;
- its proxy setters.
The test refuses these names wherever they appear: as a selector, a declared name, a composite-literal key or a struct field, and in a string literal or a concatenation of string literals, in any letter case. A string literal is checked as written and again with its JSON
\uescapes decoded. The letter-case check follows Unicode case folding, as JSON's key matching does. - Client settings. The settings the adapter gives the client are its reconnection, login re-dial, history download and receipt, message re-request, retry store, reporting token, acknowledgement, token refresh and pre-pairing settings. Their names may appear only as a field selected from a value. Only the adapter'sinstallLocked, which builds every client the adapter holds, may assign one, range into one or take its address, outside any function literal. Every other function, in every file, may only read them. - Construction. Outside test files, onlyinstallLockedmay name the protocol library's client constructor and its handler registration, each outside any function literal. Only it may call the pre-login, websocket and media HTTP client setters, as calls outside any function literal. So no later call, method value or method expression replaces a transport. - One handler. The one handlerinstallLockedmay register ishandlerFor(<receiver>.gen)of its own receiver: the adapter's handler for the current client, which returns the engine's answer. So the protocol library acknowledges an event and sends its delivery receipt only when the engine accepted it (row 9). - Pointers only. A protocol client that is not a pointer is refused in every file, so no literal,newor zero value builds one. Only the constructor does, which onlyinstallLockedcalls and test files may not name (row 18). - No decoders. Outside test files, the adapter and the device store may not import a document decoder or a template package:encoding/json,encoding/gob,encoding/xml,net/rpc,text/templateandhtml/template, each with its subpackages. These reach a value's methods and fields by names held in a document or a template. So outside test files, reflection by name, a decoded document, an executed template and a literal cannot reach any refused name, and nothing changes the settings afterinstallLocked. - Behaviour. The adapter turns the protocol library's own reconnection off, the re-dial it makes when WhatsApp asks a device to log in again included. It reports that request and every failed pairing to the engine as a drop, so that no attempt is left waiting. It stubs the connection-token refresh, whose absence panics in a goroutine nothing recovers. It gives the protocol library HTTP clients with timeouts, size caps, no proxy from the environment and no redirect to another host. - Sends and history requests. The adapter sends text messages and asks the owner's primary device for history throughSendMessage,SendPeerMessageandBuildHistorySyncRequest, withGenerateMessageID,IsConnectedandIsLoggedInaround them. It builds their protobuf messages itself, since no other package may import protobuf. It addresses only a canonical chat, built without the protocol library's JID parser. It hands the protocol library the caller's context without its cancellation and under its own 20-second deadline, so a caller that goes away cannot cut a send short. - Reviewed method list. Outside test files the adapter calls no other method of the protocol library's client. The method set is read from the pinned protocol library's source. In the adapter's non-test files every method is refused exceptConnectContext,Disconnect,Logout,PairPhone,DownloadToFile,ParseWebMessage,SendProtocolMessageReceipt, the handler registration, the three HTTP client setters, and the six send and history calls above. - The names are matched wherever they appear. So a method value, a method expression, an interface method or a method promoted through an embedded client is refused like a call. - The adapter's own methods that share a name with the protocol library's (Connect,Disconnect,Logout,PairPhone) may be declared. - OnlytransmitMessagecallsSendMessage, and onlytransmitPeerMessagecallsSendPeerMessage, each directly and outside any function literal. - A protocol library update that adds a method finds it refused.
Protects. I-5, I-6, account standing.
Enforced by.
- The architecture rules
confined-imports,raw-database-handle,protocol-callsandprotocol-client-calls(internal/archtest/protocol_test.go,protocolclient_test.go,storage_test.go), anddepguard. - Tests of the adapter's construction, on every client it builds: the
settings, that the protocol library's HTTP clients are the adapter's, and
that it holds exactly one event handler (
internal/engine/wa/client_test.go). - Tests that every event delivered through
handlerForgets the engine's answer, of the events reported for a login request and each kind of failed pairing, and of sends and history requests through an injected function (internal/engine/wa/engine_test.go,send_test.go).
Limits.
- A name built at run time other than by joining string literals passes the
test. Examples are a method or field that
reflectreaches by its index or by a formatted name, or a document whose names a test assembles at run time and decodes into a client. Outside test files,reflectis confined by row 3, and the decoders and templates by this row. - The tests read the protocol library's HTTP clients and its number of
handlers by reflection. They cannot call the handler it holds, since row 3
refuses the reflection that reaches unexported memory. That this handler is
handlerForof the current client rests on theprotocol-callstest alone. - The
protocol-client-callstest reads the adapter's files only. A client that the adapter handed out as an interface value could be called elsewhere, through an interface whose methods take no type of the protocol library, which no test refuses.
Since. v0.2.0.
18. No test reaches the network¶
Mechanism.
- The adapter's dialer, and every transport the adapter builds whatever dial function it is handed, dial through a guard. The guard refuses all connections when the binary is a test binary.
- A version source that its constructor did not build fetches nothing, instead of falling back to the protocol library's default HTTP client.
- So a test that builds the production path, or a client or version source over the adapter's transports, cannot reach WhatsApp through it.
-
Test files may not name:
- a WhatsApp or Meta host;
- a URL, or a host name with a port, outside loopback and reserved example names;
- an IPv4 or bracketed IPv6 address with a port, IPv4-mapped and zoned forms included, other than a loopback or unspecified one. The exception is the addresses a per-file allow-list names, each one an address that the code under test validates or refuses before any connection;
- the protocol library's client constructor, its own WebSocket
(
socket.NewFrameSocketandsocket.FrameSocket), its setters for the client's transports, or its version fetch; - the network calls of its client that no method of the adapter shares a name with: connecting with a context, downloads, uploads, application-state fetches and sends, to a contact or to the owner's own devices.
-
Row 17 also refuses, in every file, a client built by a literal,
newor a zero value, and the proxy setters. Outside test files it refuses the client constructor anywhere but the adapter'sinstallLocked. So every protocol client a test can hold was built by the adapter, and its connect, pairing and logout calls dial through the guard as well. hack/offline-test.shruns the whole suite, release and dev builds, in a container without a network, from a module cache filled beforehand. The whatsmeow bump workflow runs it before it opens or updates a pull request, and the maintainer runs it by hand before a release.
Protects. Account standing.
Enforced by.
- The
offline-testsandprotocol-callsarchitecture rules (internal/archtest/protocol_test.go). - Tests that the dialer, a transport built over another dial function, the
protocol client's HTTP clients, and a version source over such a transport
refuse to dial from a test binary, and that a version source without its
client asks for no connection (
internal/engine/wa/client_test.go,version_test.go). - A test that checks by reflection that the protocol client holds the adapter's HTTP clients before it connects, so that a missing setter fails the test before any dial.
- The offline run.
Limits. A test could pass the static check by opening a socket to an
address it computed at run time. That includes an IP address written without a
port and joined to one by net.JoinHostPort, or set in a net.TCPAddr. A test
could also hand the version source's test seam a transport or HTTP client of
its own making, the standard library's included. The guard covers only the
dialer and the transports the adapter builds.
Since. v0.2.0.
19. One notifier¶
Mechanism.
- Every operational event is written by
internal/notify:unpaired,disconnected,pair_rejected,logout_failed,quarantine,rekey_conflict,ingest_paused,backup_done,backup_failed,backup_pruned,backup_prune_failed,admin_mutation,admin_auth_failure,audit_key_changed,send_outcome_unknownandsend_unrecorded. - There is one method per event, whose parameters are that event's fields. Each event is written as a line of the log writer (row 13) and, when configured, as a signed post to the webhook (trust boundary 9).
- Each string field is checked against the shape of a lower-case code, an
error type against that of a Go type name, and a key id against 8
lower-case hexadecimal digits. A field that fails is written as
invalid. - The engine, the sender and the admin routes reach the notifier through small interfaces. No send event carries a key, a chat or a text.
- The pairing code reaches no event, log line, metric or response other than the pairing route's.
Protects. I-5.
Enforced by.
- The
notify-eventsarchitecture rule. In non-test files outsideinternal/notify, it refuses an"event"key followed by the name of one of these events in any call, a concatenation of literals included. It checks its list against the constants ofinternal/notify(internal/archtest/notify_test.go). - Tests of every event's fields, of non-codes written as
invalidon standard output and in posted bodies, and of the webhook's signature, retries, destination check, transport settings, answer cap, queue and shutdown (internal/notify/notify_test.go,webhook_test.go). - Canary tests that a pairing code reaches neither standard output, metrics, EMF lines, events nor response headers, from the API handlers and through a running service.
Limits. A string field filled with text that has the shape of a code, such as a single lower-case word, passes the check.
Since. v0.2.0.
20. Recipient-only backups¶
Mechanism.
- Encryption only. Only
internal/backupimportsfilippo.io/age. Outside test files it may use onlyEncrypt,ParseRecipientsand theRecipienttype of age's root package: no identity type or constructor, noDecrypt, and no other age package. So the service can encrypt a backup but holds nothing that decrypts one. WAWARDEN_BACKUP_AGE_RECIPIENTis parsed by age's own parser behind a fixed error. A refusal never repeats it, a pasted secret key included.- Staging. Only
internal/backupnames thebackups/directory. It stages each database copy with mode0600in a0700backups/tmp/, and streams it through age without a second plaintext copy. It flushes the encrypted file and renames it into place. On any failure it removes the staging directory, the partial file, and a file renamed but not flushed.serveremovesbackups/tmp/at every start, once it holds the archive's lock. - Manifest. The manifest in a backup holds a build version, schema versions, sizes and a time, never an identifier.
-
Retention. After a successful backup, and only then, the scheduler prunes
backups/. It deletes nothing when the directory holds any entry other than:- a
0700backups/tmp/owned by the service's user; - regular files of mode
0600, owned by the service's user, whose name is a backup's name with a valid time.
Otherwise it keeps the newest
WAWARDEN_BACKUP_KEEPbackups by that time, and the file just written, and deletes the rest. - Verifier.wawarden backup verifyreads whatagedecrypted, from standard input. Three parts do the work: the command's file, the stream checkerinternal/backup/verify.go, andinternal/store/inspect, which opens the extracted copies read-only and returns counts, never an identifier. None of them imports age, the protocol library, the engine, the device store or the app. The rest ofinternal/backup, which shares the stream checker's package, imports none of them but age. So the binary still holds nothing that decrypts, and the verifier cannot construct a WhatsApp client. - a
Protects. I-5, I-7.
Enforced by.
- The
confined-importsarchitecture rule and adepguardrule confine age tointernal/backup. Theage-recipient-onlyrule refuses every other use of age in non-test code (internal/archtest/age_test.go). - The
backups-directoryrule. Outsideinternal/backupand tests, it refuses a literal path elementbackups, in a string literal, a run of concatenated literals or a run assembled from the file's constants. It also refuses any use ofbackup.Direxcept the start-time check ininternal/config(internal/archtest/backups_test.go). - The
backup-verifier-fencerule. It lets onlycmd/wawardenimportinternal/store/inspect, and refuses the imports above in the verifier's files and in the rest ofinternal/backup. A test lists the transitive dependencies ofinternal/store/inspectand ofinternal/backupand refuses any of them (internal/archtest/verifier_test.go). - Round-trip, failure-path, tamper, mode, recipient, prune and verifier tests
(
internal/backup/backup_test.go,prune_test.go,verify_test.go,internal/store/inspect/inspect_test.go).
Limits. A name assembled at run time passes backups-directory. So does a
wildcard or a directory walk that reaches backups/ without naming it.
Since. v0.2.0.
21. A fake engine for development builds only¶
Mechanism.
internal/engine/fakeis a scripted stand-in for the protocol adapter. It pairs, connects, delivers synthetic events and history, and answers sends and history requests, offline. A development build runs it in place of WhatsApp whenWAWARDEN_DEV_FAKE_ENGINEselects it (see the fake engine).- Every file of the package, and every file that imports it, compiles only
with the
devtag. So a release build has no path to it and always runs the WhatsApp engine. - Outside its tests, the fake imports only buffers, zlib, context, JSON,
errors, I/O, slices, sync and time from the standard library, and the engine
core,
internal/policyandinternal/safegofrom the module. Nothing it imports opens a socket or starts a program. - It starts goroutines only through
safego.Go(row 9), and never logs its pairing code.
Protects. Integrity of the archive; account standing.
Enforced by.
- The
dev-only-fake-enginearchitecture rule. It refuses a file underinternal/engine/fake, test files included, or a file that imports it, unless it has exactly one//go:buildline, where the go tool reads it, that is false for every assignment of its other tags whiledevis unset. - The
fake-engine-importsrule, which refuses any other import in the fake's non-test files. The walk must find the fake and its selector (internal/archtest/fakeengine_test.go). hack/repro-build.sh, which fails when a release binary contains the fake's marker string, which dev binaries keep, or any symbol of the package.- A release-only test that a release build refuses
WAWARDEN_DEV_FAKE_ENGINE(internal/config/release_test.go). - The fake's own tests (
internal/engine/fake/fake_test.go), and end-to-end tests that runservewith the fake, pair through the admin CLI, check that no pairing code, message text, chat identifier or group name reaches the output, and see a wrong account rejected (cmd/wawarden/fake_test.go).
Limits. Nothing stops a development build that runs the fake on a release build's data directory from storing its synthetic messages in that archive.
Since. v0.2.0.
22. Read, search and send budgets¶
Mechanism.
- Read budget. Every request on the client listener that authenticates
spends one from its client's read budget before routing: 600 a minute, burst
60, by default. So unknown routes and
404s cost the same as reads. EveryPOST /mcpspends one read the same way. - Search budget. A search also spends one from the search budget: 60 a
minute, burst 6. A
search_messagescall spends one from the same bucket as/v1/search, before its arguments are checked (row 25). - A spent budget answers
429(rate_limited) withRetry-Afterat once. Nothing queues. - Buckets. The budgets are token buckets per client id, on an injected
clock, in
internal/ratelimit, which may import only the standard library. A bucket left full is dropped. Only authenticated clients get one, so a caller without a token cannot grow the map. -
Archive contention. Reads share the archive connection with ingest, behind 8 read slots. These answer
503(busy) withRetry-After: 1:- a read that finds the slots taken or passes its 2-second deadline;
- a request whose audit row cannot be written within 2 seconds;
- a request that waits more than 2 seconds for the client list to be republished after a change.
Authentication never reads the archive, so no answer waits until a long ingest write ends. - Send budgets. The send budgets are not token buckets. They are counted over the rows of the archive's idempotency table, in sliding windows on the same injected clock, so a restart does not refill them. Each client may send 10 a minute, and its share of the global 30 an hour. The share is the global figure divided by the number of clients that may write, and at least 1. - Pacer. A global pacer admits the next send of any client 3 seconds, plus a random wait under 2 seconds, after the last one was admitted. A send that waits for its acknowledgement longer than that gap can therefore be followed at once. - Caps. The send limits range from 1 to 10 a minute and 1 to 60 an hour. Only
WAWARDEN_UNSAFE_SEND_CAPS=1lifts them, to 60 and 600, andWAWARDEN_UNSAFE_RATE_CAPSnever does. - Every send refusal is the one429rate_limited, with the earliest admit time asRetry-After. Only a send that wrote nothing to WhatsApp gives its place back. The engine runs one send at a time and refuses another at once.
Protects. I-6, and the availability of reads and ingest; for sends, I-2 and account standing.
Enforced by.
- Injected-clock tests, and a property that admissions over any window never
exceed the burst plus the rate (
internal/ratelimit/ratelimit_test.go). - Configuration tests for the caps and the
UNSAFE_overrides (internal/config/config_test.go). - Tests of the send budgets at every window boundary, across a close and a
reopen, and across clock steps both ways
(
internal/store/ingest/budget_test.go). - A property test of the sender over random clients, chats, keys, texts,
clock steps, engine outcomes and restarts, with the real archive. It finds
at most one engine send per key within its day, every send inside the
windows and the pacer, and no send outside the write set
(
internal/sender/property_test.go). - Tests that a valid client behind a held write answers
503, never401or429(internal/store/scoped/auth_http_test.go).
Since. v0.3.0.
23. Sealed cursors and message references¶
Mechanism.
- Cursors. A
nextcursor holds only a position (a time, an arrival number, a change number), sealed with AES-256-GCM and a random nonce under thecursor-sealkey. Its additional data binds the client id, the route, the chat filter, and, for search, a hash of the normalised query. - Message references. A message reference holds the chat, message id and
sender, sealed under the
mrefkey and bound to the client. Its nonce is the first 12 bytes of an HMAC-SHA256 of the client id and the sealed values, under a nonce key derived from themrefkey with a fixed label. The nonce key is an HMAC-SHA256 under themrefkey ofwawarden/mref-nonce/v1. So one message seals to one stable reference for a client, and to different references for different clients or messages. - Opening. Opening caps the input (256 and 640 characters). It decodes strict unpadded base64url and refuses any text that does not re-encode to itself, since the decoder skips line breaks. It checks the version, and that the key id is the current key's or a retired key's, and opens under that key. It fails with one error. Sealing uses only the current key.
- Grants. The grant is taken from the client at every request, never from
a cursor. So a replay after the scope narrowed returns nothing outside it. A
request that names a chat checks the chat before the cursor, so an invalid
cursor with a chat outside the scope answers
404. A reference that does not open, or opens to a chat outside the scope, answers the uniform404. - Sends. The idempotency store fingerprints a send from the chat's
reference, the text, and the reply target's message id and archive row. It
never uses a sealed reference or the target's sender, which a re-key
rewrites. It stores neither a reference, the text nor the chat. A send's
reply_toopens only under the sending client's key. It must name a message of the same chat, in the client's read scope, that is not revoked and has text. Any failure is the uniform404.
Protects. I-1, I-4.
Enforced by.
- Unit tests of tampering, binding, nonce derivation, recorded reference
texts, retired keys and the sealers' roles, and fuzz targets for cursor and
reference opening (
internal/cursor/cursor_test.go,fuzz_test.go). - API tests that every
404case is byte-identical to an unknown route's and takes the same archive calls as a missing chat, and that misused cursors answer400(invalid_cursor) (internal/api/reads_test.go). - The HTTP property of row 7.
- A pinned fingerprint that survives a re-key of the reply target's sender
(
internal/store/ingest/key_test.go), and an end-to-end retry after that re-key that sends once (internal/app/rekeyreplay_test.go).
Since. v0.3.0.
24. The MCP endpoint behind the client pipeline¶
Mechanism.
POST /mcpis one stateless, JSON-only handler of the MCP library, the MCP Go SDK, registered on the client router. Authentication, the read budget, the browser refusal and the audit row apply to every POST.-
Body checks. Before the MCP library reads a body, the endpoint refuses these with the fixed JSON errors, which the MCP library would otherwise accept:
- a wrong media type;
- a body over 64 KiB, announced or read;
- a body that is not exactly one JSON object without duplicate keys, trailing data or nesting past 16 levels.
-
Methods. The server advertises tools only. A receiving middleware answers method not found to every method but the handshake,
ping,server/discover,tools/listandtools/call. It answers a fixedunknown toolerror, which never repeats the name, to any tool not registered. - Authentication. The MCP library's bearer check takes the client the pipeline authenticated, and never looks a token up again.
- Host check. The MCP library's DNS-rebinding
Hostcheck is off on purpose. It refuses every request that reaches a loopback listener through a proxy forwarding the original host, which is the deployment this service expects. The pipeline'sOriginandSec-Fetch-Siterefusal and the bearer token cover the same browser threat. - A writer restores
Cache-Control: no-store, which the MCP library replaces on success. - Confinement. The MCP library may not be imported outside
internal/api, and may not build or wrap a handler outside packageapi. The service starts only withMCPGODEBUGandJSONSCHEMAGODEBUGunset. - No logger. The MCP library is given no logger in its server or handler options, so its log output is discarded.
- Two servers. The endpoint holds two servers built at start. One has the
five read tools,
list_chats,get_chat,get_messages,search_messagesandget_changes. The other addssend_message. The endpoint picks the second for a request only whenDecideWritegrants its client. For every other client, asend_messagecall is the sameunknown toolerror, byte for byte, with the same audit reason as any unknown name. - One key, one send. The idempotency store keys a send by client id and idempotency key alone. So one client's key names one send, whichever protocol carries it. Of concurrent reservations of one key, exactly one proceeds.
Protects. I-0, I-1, I-4, I-6.
Enforced by.
- Tests through the mounted handler with the MCP library's own client, over
both the 2026-07-28 and the
initializeprotocol paths (internal/api/mcp_test.go). They cover the uniform refusals, the body checks, refused methods,no-store, a non-loopbackHost, one audit row per POST, and concurrent reservations. They also compare a hiddensend_messagebyte for byte with an unknown tool. - The
confined-imports,handler-ownershipandmcp-registrationarchitecture rules, anddepguard. - The
mcp-loggerarchitecture rule, which refuses a logger in the MCP library's server and handler options (internal/archtest/logging_test.go). A test captures the default logger and standard error, drives requests the MCP library logs when it has a logger, and finds nothing written. - The startup refusal
library_debug_set.
Since. v0.3.0.
25. MCP tool calls fail closed¶
Mechanism.
-
Each read tool is registered through
readTool, which:- charges the search budget for
search_messages; - checks the arguments against a schema with explicit bounds (
limit1 to 200, string lengths); - mints the read grant;
- gives the call a 10-second deadline;
- recovers a panic through
safego, since the MCP library runs tools on its own goroutine, where an unrecovered panic would stop the process; - answers with one fixed code in
content[0].text.
- charges the search budget for
-
The tools call the same code as the REST routes, so scope, cursor binding and denied-equals-missing are the routes'.
- Argument values, schema-validation text, store errors and panic values never reach a result. The MCP library's own argument checking, which repeats the caller's values, is not used.
- An idempotency key is a sealed type whose printed and logged forms are a fixed redaction. So formatting cannot carry it into a result or a log line.
send_messagegoes throughwriteTool. It checks the arguments against bounds of its own: text of 1 to 4096 characters, a key of 8 to 128 printable ASCII characters, andreply_toup to 640. It mints the write grant, gives the call 25 seconds, recovers a panic the same way, and answers the route's fixed codes. For409,429and503it addsretry_after_secondsinstructuredContent.- Every tool but
send_messagecarriesreadOnlyHint.
Protects. I-1, I-2, I-4, I-5.
Enforced by.
- Tests over both protocol paths for every code, with a canary argument, a
canary key and text, and a canary store error absent from the wire and the
audit rows (
internal/api/mcp_test.go,sends_test.go). - Tests that denied and missing answers are byte-identical with the same
archive calls, that a panicking tool answers
internal_errorand the endpoint answers the next call, and that a stalled read answersbusyat the deadline. - A test that takes every tool from
tools/list, for a read-only and a write client, and checks its annotations, output schema and failure answers. - The MCP property of row 7, and the
mcp-registrationrule of row 2.
Since. v0.3.0.
26. The send path¶
Mechanism.
internal/sendersends one text message for a client that holds a write grant, at most once per idempotency key. It reaches the engine and the HTTP handlers only through interfaces thatinternal/appwires. It may not importdatabase/sql, the SQLite driver, the protocol library, protobuf, or any package underinternal/engineorinternal/api.internal/apimay not importinternal/sender(row 3). Neither directly nor through another package mayinternal/apidepend on the sender, the engine, the protocol library, or a store package other than the scoped store and its database package.- Every exported method of the
Store,TxandReadertypes ininternal/store/ingest/sends.gotakes apolicy.WriteGrantvalue as its first parameter. So the send ledger cannot be reserved, settled or released, and a sent message cannot be recorded, without a grant fromDecideWrite. The two exceptions write nothing for a client:Store.RecoverSendsmarks sends left pending as failed, andTx.SweepSendsdeletes expired ledger rows. - The column
messages.sent_byholds the id of the client that sent a message through the gateway, which readers see asorigingateway:<client id>. Onlyinternal/store/ingest/sends.gowrites it. Outside test files, only that file, the ingest schema and the scoped store's message columns name it. So no other code can attribute a message to a client.
Protects. I-2, and the attribution of outbound integrity.
Enforced by.
- The
send-grant-firstandsent-by-confinementarchitecture rules (internal/archtest/sends_test.go).send-grant-firstrefuses a pointer to a grant, and a grant type of another package, as well as a missing grant. - The
import-fencesrule's sender and API fences (internal/archtest/imports_test.go), and a test that runsgo list -depsoninternal/apiand refuses any of the dependencies above (TestTheAPIReachesTheArchiveOnlyThroughTheScopedStore). - The sender's property test (row 22), and
the send store's tests (
internal/store/ingest/sends_test.go,sent_test.go).
Limits.
send-grant-firstreadssends.goonly. An exported send method declared in another file of the ingest package passes it.sent-by-confinementreads string literals and statements assembled from a file's constants. A column name assembled at run time passes it.
Since. v1.0.0.
Other tests¶
Other tests that guard the invariants:
- A byte scan of
archive.dband its journal, the full-text index's vocabulary and page keys, and aMATCHquery for a revoked, an expired and an edited canary, with negative controls (I-8; row 15). - Tests that a startup refusal never repeats the configured value, and that the
token
admin initprints appears exactly once in its output (I-5, I-7). - Release builds prove that the
devbuild tag is absent. The build settings embedded in the binary show nodevtag, and the binary contains neither the marker string that only dev builds carry, nor the fake engine's marker, nor any symbol ofinternal/engine/fake(row 21). A release build also refuses to start when it sees aWAWARDEN_DEV_*variable.
Startup refusals¶
wawarden serve validates its whole configuration, the master key, the
archive and the device store before it opens any socket. The first failed check
stops it with a stable reason code and exit status 2. A refusal names the
variable or the file involved, never a configured value.
configuration.md lists every check, its
reason code and its order.
The checks that matter most for security:
-
Unknown and unsafe variables. The service refuses:
- any
WAWARDEN_variable the build does not implement, so a misspelt setting never falls back to a default unnoticed; - a
WAWARDEN_DEV_*variable in a release build, such asWAWARDEN_DEV_FAKE_ENGINE, which selects the fake engine that onlydevbuilds contain (row 21); MCPGODEBUGorJSONSCHEMAGODEBUG, set to any value, an empty one included. Both switch compatibility behaviour of the MCP and JSON Schema libraries, such as how tool schemas are inferred and encoded. A malformedMCPGODEBUGmakes the MCP library panic when the program starts, before this check;GOTRACEBACKset to anything other than empty,noneorsingle, numeric levels included. Every other level prints every goroutine's stack in crash output.
- any
-
Plaintext admin credential.
WAWARDEN_ADMIN_TOKENis refused, whatever its value. Only a SHA-256 is accepted, fromWAWARDEN_ADMIN_TOKEN_SHA256orWAWARDEN_ADMIN_TOKEN_SHA256_FILE. Without a hash, the admin listener is not opened. - Listeners. A listen address that is not an IP literal with a port, a health address that is not loopback, and two enabled listeners that share an address are refused.
- Root. A real or effective user ID of 0 is refused without
--allow-root. - Ownership and modes. The data directory,
keys/, the master key,keys/retired/and its keys,history/,backups/,archive.db,session.dband their journals must be owned by the service's user, have the modes trust boundary 7 gives, and not be symbolic links. A retired key must also be the key its name says, and not the current master key. - Storage. The service refuses archive or device store settings that read
back differently from the fixed ones
(row 15), storage profile
localon a network filesystem or without open-file-description locks, and an archive schema newer than the build knows. - Secrets in files. A webhook secret file that grants write permission to its group or any permission to others, or that holds fewer than 32 or more than 4096 bytes, is refused. So is a backup recipient that is not exactly one age X25519 or hybrid recipient, such as an age secret key pasted by mistake. Neither refusal repeats the value.
- Every value has a form and a range. That includes the log level, the storage profile, the free-space floor, the owner's number, the history cap, the debug window, the webhook URL, the backup schedule, the audit retention, and the rate and send limits.
- Rate limits. A limit of zero, a negative one, or one above its hard cap
is refused;
0never means unlimited.WAWARDEN_UNSAFE_RATE_CAPS=1lifts the read and search caps (600 and 60 a minute per client) to 1,000,000 a minute, and logsunsafe_rate_capsat every start. The send limits have their own caps (10 a minute per client, 60 an hour for all clients) and their own override,WAWARDEN_UNSAFE_SEND_CAPS=1. It lifts them to 60 and 600, never further, and logsunsafe_send_capsat every start. The read override never lifts the send caps.
An unset WAWARDEN_OWNER_PHONE refuses pairing, not the start.
Pairing refusals¶
-
Before pairing. Pairing is refused while a device is already paired, while the owner's phone number is missing, and beyond three attempts an hour. An owner's number that is set but not in E.164 form refuses the start. The admin route that starts pairing answers:
409already_pairedwhile a device is paired;409owner_phone_missingwithout the owner's number;429rate_limitedbeyond three attempts an hour.
-
The pairing code goes to the caller of that route only. It is never logged, counted, kept or put into an event.
- After pairing. The linked account must be the owner's number. Otherwise
the engine raises a
pair_rejectedalert and logs the new device out. It retries with backoff, and raises alogout_failedalert, for as long as the logout fails. Until the device is gone, the engine does not connect it, neither on its own nor on an explicit reconnect, and drops what its connection delivers before it is written. - Before every connection. Before every connection, and at the start
before the restart budget and the version fetch, the engine checks that the
stored device's number is exactly the owner's. When it is not, the engine
stays disconnected (
owner_mismatch) with an alert. It refuses an explicit reconnect, drops what a connection would deliver, and leaves the device in place for the operator, without logging it out. Two cases lead here: a rejected device whose logout never succeeded before a restart, and an owner's number that differs from the paired one. - When the owner's number is unset, the stored device is not checked, and the stored number never reaches a log or an event.
- In the adapter. The protocol adapter refuses another account before the protocol library saves anything. It requests a pairing code for the owner's number only, drops QR codes, deletes the local device when a logout cannot reach WhatsApp, and starts from a fresh device after a logout.
A pairing completed from someone else's account therefore cannot bind the gateway to that account. These rules are tested in the engine core and in the protocol adapter.
Secure defaults¶
| Default | Since |
|---|---|
The client and admin listeners bind to loopback (127.0.0.1:8080, 127.0.0.1:8082) unless configured otherwise. A warning is logged at every start for each of them bound elsewhere, since plain HTTP carries bearer tokens, and the log level cannot suppress it. The health listener defaults to 127.0.0.1:8081 and accepts only loopback addresses. |
v0.1.0 |
| The admin listener exists only when an admin token hash is configured. | v0.1.0 |
umask 077 and the single traceback level are set by the first two statements of main, which an architecture test checks. |
v0.1.0 |
HTTP servers have read-header, read, write and idle timeouts and a 16 KiB header limit. Responses written by WaWarden's handlers carry Cache-Control: no-store and X-Content-Type-Options: nosniff, and no CORS headers. Go's HTTP server answers malformed requests itself, before any handler runs, with a plain-text or empty error response without those headers. |
v0.1.0 |
Requests to the client or admin listener carrying an Origin or Sec-Fetch-Site header are refused with 403. OPTIONS on those listeners is refused with 405. |
v0.1.0 |
| Every request on the client and admin listeners is authenticated before its body is read. Error bodies are fixed strings that never echo the request. | v0.1.0 |
The container image runs as a non-root user (65532:65532) on a distroless static base with no shell, and works with a read-only root filesystem. |
v0.1.0 |
The data directory is created with mode 0700. |
v0.1.0 |
Request bodies are read only by a route handler, after authentication and the route's decision, through one strict JSON decoder. It takes at most 16 KiB; the client create route and the send route take 64 KiB, so that two sets of 256 chats, or the longest text with every character escaped, fit. It requires exactly one Content-Type of application/json, valid UTF-8 without a byte-order mark, and one object at most 8 levels deep with lower-case snake_case keys. It refuses a duplicate key once escapes are decoded, an unknown field and trailing data. Refusals are fixed 415, 413 and 400 answers that never repeat the request. The pairing, reconnection and revocation routes accept only the empty object. The only client REST route that reads a body is the send route, after its write grant is decided. POST /mcp checks its body before the MCP library reads it (row 24). |
v0.2.0 |
Secrets reach the service as _FILE paths, never as command-line arguments. The CLI reads the admin token from a file, standard input or a token command, never from command-line arguments or environment variables, and checks its form before sending it. |
v0.1.0 |
The service holds one injected secret: the optional webhook signing secret, which the operator supplies as a _FILE path and the service reads once at start. The admin credential is a hash, client tokens are hashed in the database, and every other key is derived from a master key generated on first start, with mode 0600. |
v0.1.0 |
Database files are created with mode 0600 before SQLite opens them, and their journals take that mode. |
v0.2.0 |
| Every log line passes through one log writer, which redacts tokens, pseudonymises identifiers, drops XML lines and renders values fail-closed. The protocol library's and the Signal library's logs reach it through an adapter, and the MCP library has no logger. Tool arguments are never logged. Row 13 gives the rules. | v0.2.0 |
Text that the admin CLI prints from a server answer passes through a terminal sanitiser. It replaces every control, format, line-separator and paragraph-separator character, and every invalid byte, with U+FFFD. So an answer cannot move the cursor, rewrite the screen, set a link or write the clipboard. The refusal code the CLI prints has client and admin tokens redacted as the log writer does. A test runs failing serve, admin, audit, keys and backup commands with tokens as their inputs and as the listener's answer, and finds no token secret in their output. |
v0.2.0 |
| The notification webhook is off unless configured. When it is on, trust boundary 9 gives its transport and destination rules. | v0.2.0 |
| Live status, broadcast and newsletter traffic, and live traffic of every other chat that is not a phone-number user, a LID user or a group, is dropped at ingest, before it is written anywhere. Such conversations inside a history-sync blob are dropped while the blob is applied. The blob's file and its inline content, which hold them until then, are deleted once the blob is processed or quarantined. | v0.2.0 |
Live protocol messages are not stored. The engine receives each live message as plain data, a type with no field for a media key, a message secret or the protocol message. The archive's messages table has no raw column, which a test checks. History sync is the exception. Each history blob, the protocol's own messages with their media keys and message secrets, is kept as received, in a 0600 file under history/ or, when it arrives inline, in the archive. The archive keeps its download reference, media key included. Both are kept only until the blob is processed or quarantined, and a start deletes the files of blobs no longer pending. The protocol library keeps its own state, message secrets included, in the device store (see Assets). |
v0.2.0 |
| The service never sends read receipts, presence, typing indicators or status updates. No code asks the protocol library for them, and an architecture test refuses the calls. The protocol library sends delivery receipts in their inactive form, and other traffic by itself (see Protocol library). | v0.2.0 |
A service without a paired device makes no connection to WhatsApp, not even the version fetch, until pairing is requested. It reports unpaired once, and again whenever a paired device is lost. |
v0.2.0 |
| Outbound HTTP to WhatsApp uses no proxy from the environment, a timeout on every step, TLS 1.2 or later, response caps and no redirect to another host. The caps are 8 MiB for the version page, and the history cap plus 32 bytes for a history download. | v0.2.0 |
No first contact, a direct chat without any stored message from the other person, unless the client is allowed it. Sends are paced, idempotent and budgeted per client, with 429 rather than queueing. A send whose outcome is unknown is stored as failed, and the service never sends it again. Outbound text with control, bidirectional or tag characters is refused, never altered. |
v1.0.0 |
Disconnections from WhatsApp never exit the process, and /healthz does not depend on them. More than five starts within ten minutes start the engine disconnected. Reconnection after a drop backs off exponentially, with jitter, up to five minutes, to avoid reconnect storms, with the protocol library's own reconnection switched off. A connection that drops within a minute of coming up counts as a failed attempt and does not reset the delay. A replaced session, a temporary ban, a refused connection, a failed token refresh, or a stored device of another number than the owner's waits for the operator. |
v0.2.0 |
With storage profile local, ingest pauses while the data directory's filesystem has less free space than WAWARDEN_MIN_FREE_BYTES (256 MiB by default; 0 turns the floor off). It reports ingest_paused, and resumes once the free space reaches 1.25 times the floor. While it is paused, the engine writes no message and no history-sync notification and leaves them unacknowledged, which loses them (see Protocol library). It also applies nothing from its inbox or from history sync. Profile nfs has no floor. |
v0.2.0 |
No backup is taken without WAWARDEN_BACKUP_AGE_RECIPIENT, and the start says so with backup_disabled. Every backup is encrypted to that recipient, and there is no plaintext backup of either database. One backup is taken per paired device once its initial history sync has settled. Then one is taken per night at WAWARDEN_BACKUP_TIME, 03:00 UTC by default, and the newest WAWARDEN_BACKUP_KEEP, 14 by default, are kept. A backup that would take the data directory below its free-space floor is refused. |
v0.2.0 |
Residual risks¶
These remain after every control above is in place. They are grouped by subject.
Account and protocol¶
- Unofficial protocol and account standing. WhatsApp offers no official API for a personal account. WaWarden is a linked-device client built on an unofficial implementation of the multi-device protocol, and WhatsApp can restrict, log out or ban the account. The defaults reduce the signal but cannot remove it: one linked session, read-mostly use, no read receipts or presence, no reconnect storms, guarded pairing, and paced sends to explicit chats without first contact. The phone must also come online at least every 14 days, or WhatsApp logs out every linked device. ban-risk.md has the details.
- Protocol churn. A protocol change can stop WhatsApp connectivity until a new release ships. Reads of the archive keep working. The engine refreshes the client version at run time for the common case.
-
Backfill depends on the owner's phone.
POST /admin/v1/backfillsends a history request to the owner's primary device, at most one every 5 minutes and 20 a day, and only the administrator can make one.- The phone answers when it processes the request, possibly after a long time or never. How long WhatsApp keeps a request for an offline phone is not known.
- That the answer names the request's id, which marks the request complete, comes from a report by users of the protocol library that has not been verified here. An unanswered request stays counted and incomplete.
- Which chat identifier form, phone number or LID, the phone expects for a direct chat is not verified either.
-
Limits that WhatsApp does not publish. The text cap of 4096 characters and 8192 bytes is this service's policy. WhatsApp's own limit is not known, nor how it renders a reply to a media message, which the service never sends.
- Bot accounts look like users. WhatsApp's own bot accounts, such as its AI
assistant, have phone-number identifiers.
Normalizeaccepts them, and their chats are archived like any other. - WhatsApp behaviour not observed yet. These are not known:
- whether the phone honours the request for a full history sync;
- how large real history blobs are;
- whether WhatsApp's servers check that an admin revocation comes from an admin. The engine checks the archive's record either way;
- whether every country's account identifiers equal its E.164 numbers. When they differ, pairing fails closed.
Protocol library¶
This subsection is the canonical statement of what the protocol library
(go.mau.fi/whatsmeow) does that WaWarden cannot change.
-
Traffic the protocol library sends by itself. While connected, the protocol library:
- acknowledges every stanza;
- sends a delivery receipt for every message it decrypts, in the inactive form that a sender's phone does not show as delivered unless the device announced itself as available, which WaWarden never does;
- sends retry receipts for messages it cannot decrypt, and asks the owner's phone to resend messages WhatsApp marks as unavailable;
- announces the device as active at each connection;
- uploads pre-keys and fetches the application state;
- sends session telemetry once after pairing.
None of this can be switched off. All of it tells WhatsApp, and in part the sender, that the device is online. - A refused or interrupted message is lost. The protocol library advances and saves its session keys when it decrypts a message, before the engine sees it. It acknowledges the message only after the engine wrote it to the inbox. - A message the engine refuses is delivered again by WhatsApp. Refusals are a full inbox of 5,000 rows, an ingest paused below the free-space floor, and a failed write. So is a message decrypted just before the process stops. - The protocol library can no longer decrypt that second copy, and drops it, so the message never reaches the archive. - Group changes are acknowledged as they arrive, and are lost the same way. - The protocol library's buffer of decrypted messages would make delivery at-least-once, at the price of message plaintext in the device store. It stays off.
-
Crafted live messages are decoded before WaWarden sees them. The protocol library decodes every decrypted message with protobuf's default limits: 10,000 levels of nesting, and no size limit below its 16 MiB frame. So a peer can make the process allocate many times a message's size, about 40 times in measurements, before any WaWarden check runs. History blobs, which the adapter decodes itself, are held to a nesting of 28 levels and the history cap.
-
The device store belongs to the protocol library.
- The protocol library's queries on
session.dbrun without the archive's per-call deadlines, on the single connection that holds the lock. Whether its concurrent use of that one connection can stall has not been observed outside tests. - The protocol library keeps the identity mappings and privacy tokens it learned after a device is deleted. It accepts a contact's changed identity key without asking.
- Its own identity map, which any of the owner's devices can write, is never read for the archive.
- Sends address canonical chats. The adapter builds the recipient from the chat's canonical identifier, and reports the sent message under that chat, even when the protocol library rewrites a phone number to a LID through that map.
- The protocol library's queries on
-
Requests answered by the owner's phone. When WhatsApp marks a message unavailable, the protocol library asks the owner's phone for it. It passes the phone's answer on as live traffic in the chat that answer names.
- The protocol library accepts such answers only from the owner's primary device, the trust that history sync gets as well.
- It builds them the way it builds history rows. So an edit's identifier and message fields hold its target's identifier and the new text. The edit's own identifier and protocol message remain only in the raw message and the row the phone sent, both of which the event carries.
- The adapter reads each answer from that row, as it reads history rows. So an edit is applied to its target, and never stored as the target itself, which a test through the engine checks.
- Nothing in an answer shows that its sender wrote it. The archive holds what the owner's phone says was sent.
Dependencies¶
-
Upstream dependency. The protocol library has a single maintainer and no tagged releases.
go.modpins it to an exact pseudo-version, andgo.sumpins its content. The go command checks every download against that hash. With default settings, it checks a new hash against the Go checksum database before recording it.- Modules are never vendored: the build script, the CI
modulesjob and an architecture test refuse avendordirectory. So a build needs the Go module proxy, or whateverGOPROXYnames. - A scheduled workflow proposes each update as a pull request, and never merges one. The pull request carries the upstream commit log, a diffstat and the diff of the watched paths between the two module versions' sources. When GitHub's limit on a description cuts the diff, it is complete in an artifact of the run. Its CI runs only once a maintainer approves it, and a maintainer reviews and merges it by hand (RELEASING.md).
- The protocol library links the Signal library,
go.mau.fi/libsignal, licensed under GPL-3.0. That is why WaWarden is licensed under GPL-3.0-or-later.
-
The MCP library.
POST /mcprelies on the MCP library, the MCP Go SDK, to parse and dispatch JSON-RPC.- Its own refusals, before a request reaches the server, can repeat the caller's method name, header values or message text, though never anything read from the archive.
- It runs each request on a goroutine of its own. Tool handlers recover their panics (row 25), but a panic in the MCP library's own code would stop the process.
- A client that disconnects does not cancel a running tool call, which ends at its 10-second deadline.
- The MCP library also checks a token's expiry against the wall clock, after
the service checked it on its injected clock. A token that expires between
the two checks gets the MCP library's plain-text
401.
-
Panics in dependency goroutines. A panic in a goroutine that a dependency starts, such as the protocol library, is not recovered. The process exits, and the Go runtime prints the panic value to standard error first. That value can carry chat data, so I-5 does not hold for crash output.
- The protocol library handles each incoming stanza on a goroutine of its own, and recovers a panic in only a few places.
- The adapter removes the one known panic, a missing connection-token refresh, by stubbing it. It recovers a panic in its own event handler and in the engine code that handler calls, and refuses the event then. A message whose handling panicked is therefore lost the way a refused message is (Protocol library).
-
SQLite on a network filesystem is outside SQLite's recommended configurations. The network-filesystem storage profile reduces the risk; it does not remove it. That profile uses a rollback journal, exclusive locking, no write-ahead log and a single process. It uses classic POSIX record locks, which a close of any other descriptor of the database file in the process would drop. The service's own code never opens the file (row 3a), but a dependency could.
- A database deadline lost in the driver. A deadline that passes just as
the database driver starts a statement can be lost.
- That statement then runs to its end before the call fails, past its call's deadline (2 seconds for a read, 10 for a write), and nothing bounds how long it runs.
- Until it ends, the archive's one connection stays taken. Other calls wait
for it no longer than their own deadlines: a read then answers
503(busy), and a write, an ingest write included, fails. - Grants and the audit chain still hold. The loss costs availability, not scope. See Message archive.
- A test checks that interruption works and tolerates the race. Slow reads, writes and statements under a 100-millisecond deadline must each return within 3 seconds. A call that overruns because its interrupt was lost or late runs again, up to three times, and the test fails only when all three overrun.
Tokens and the admin token¶
- Bearer tokens are not bound to a network. A stolen token works from anywhere it can reach the service, and a network mistake goes unnoticed. See Bearer tokens without network binding.
- The failed-authentication throttle does not limit guessing. See Failed authentication.
- The admin credential is a single factor. See The admin token.
- Revocation in flight. A request authenticated just before a revocation commits finishes with its grant, within the 2-second read deadline.
Timing and volume¶
- Timing of an out-of-scope message. A message named outside the client's scope is refused before any message query runs. A message inside the scope that does not exist costs one query. Both answers are the same, but the first comes sooner. The difference tells a client only that a chat lies outside its own scope, which its grant already tells it. Chats outside the scope and missing chats run the same query, and cannot be told apart by the SQL they issue.
-
Timing of authentication. Every authentication hashes the presented token once and compares it once, whatever the outcome. So the time spent does not depend on how much of a secret is right. What still varies is outside the secret:
- parsing stops at the first part of the token's public form that is wrong;
- a token refused for its revocation or expiry reads the clock once its hash has matched;
- a request that arrives while a client change is being published waits for it, for at most 2 seconds, whatever its token.
None of these tells a caller more than the answer it gets. - Timing of a malformed message reference and of a cursor. A message reference that does not open answers
404without any query, sooner than a reference that opens. The answer is the same, and only references the service sealed for this client open. A request with an invalid cursor that names a chat looks the chat up first. So the404of a chat outside the scope and the400of a chat inside it cost the same query. A cursor never opens under another client, route, chat or query. - Timing of search. A search walks the full-text index across every chat, in each window of 20,000 sequence numbers, and applies the client's scope to each match afterwards. - Its time therefore grows with the number of messages in the window, in chats outside the scope, that hold the query's trigrams: about a millisecond for each thousand on a development machine. - By comparing the time of searches that give the same answer, a client that reads some chats can estimate how common a term of 3 or more characters is in chats it cannot read. It can do so within each stretch of up to 100,000 sequence numbers it can reach by followingnext. It learns neither the text nor which chat holds it. - Only the search budget (60 a minute by default) and the 2-second read deadline bound this. Walking the scope first does not remove it, since each probe of the index costs more for a term with more matches anywhere, and it is far slower for a busy chat. - A client that reads every chat learns nothing from it. -
Volume through paging. Search and the change feed walk the whole archive in windows of 20,000 sequence or change numbers, at most five a call.
morestaystrueuntil the walk reaches the start of the archive, for search, or its newest change, for the feed. The number of calls a client needs, and the empty pages withmore: trueamong them, therefore grow with the number of messages and changes in the whole archive, not only in its scope. A client can estimate the archive's size in steps of 100,000, and see that changes happen in chats it cannot read, but not where or what they are. - Rate limits restart with the service. The read and search budgets live in memory and start full at every start. A client that can make the service restart gets a fresh burst; a restart costs far more than the burst it buys.
-
The pacer and the global budget are shared. The pacer and the global hourly limit count every client's sends. The single
rate_limitedcode never says which limit refused a send, and no answer names another client or its chat. But theRetry-Afterof a refusal shows something of other clients' sends:- A refusal by the pacer, or by the one send the engine runs at a time, tells a client that some client sent within the last few seconds.
- A refusal by the global hourly limit carries a
Retry-Afterfrom which the client can work out, to the second, when the oldest send still counted in the last hour was admitted. That holds whichever client made it, to a chat outside the asking client's scope included. - A refusal is not counted, and costs only one read from the read budget, so the client can ask again.
- The global limit refuses only once it is spent. That needs more clients
that may write than
WAWARDEN_SEND_GLOBAL_PER_HOUR, or a change within the hour in that number or in the limit. After such a change, the sends already counted can leave less of the global limit than the new shares allow. - The per-minute limit and a client's hourly count take its own sends alone.
But its hourly share is the global limit divided by the number of clients
that may write at that moment. A client that sends up to its share can
therefore tell how many clients may write. It sees a write client created,
revoked or expiring when a refusal turns into an admission or back, or when
its
Retry-Aftermoves.
-
The send route is visible to every client. Any method other than
GET,HEADandPOSTon/v1/chats/{ref}/messagesis answered405withAllow: GET, HEAD, POSTbefore any grant is decided. A read-only client therefore learns that the route exists, which this documentation states anyway. APOSTfrom a client without a write grant is the uniform404.
Audit chain limits¶
-
Audit anchoring. These limits follow from the audit chain's design:
- Whoever can write
archive.dband holds the master key can rewrite the audit chain or cut its end, and the table still verifies. Only chain heads already shipped off the host show it. A deployment that keeps standard output only on the host has no root of trust for the chain. - The audit anchor is signed under the master key. Without the key, the start of the chain cannot be cut behind a forged audit anchor. With it, the audit anchor can be moved forward over any rows. Only their shipped heads then show it, and only if their log times are after the audit anchor's.
audit verifyaccepts an audit anchor signed under a retired key, unless no row is left or the first one left was written under the current key. Behind such an audit anchor, only shipped heads whose lines name a key other than the current one count as pruned. No row under a retired key may follow one under the current key.- So whoever holds a retired key and can write
archive.dbcan still, against every shipped head, remove the rows of any key but the current one from the start of the chain. They sit behind an audit anchor dated after them, and their shipped heads then count as pruned. With one retired key these are its own rows and those of keys missing from the key set. Across two or more rotations they also include the rows of another retired key. - After a rotation, and until a row is written under the current key, that
holder can also append rows under the retired key.
audit_key_changedreports that state at each start. - The retention checks the rows it removes, and the audit anchor before them, under the keys the service holds, before it signs. So it does not sign over a row renamed to a retired key. But it cannot tell rows written under a retired key in place of every row under the current key from rows written before the rotation.
- So the one removal that first reaches the current key's rows can still be
rewritten under a retired key, when it reaches all of them. Once every row
written under the current key is older than the retention's cutoff, whoever
holds a retired key and can write
archive.dbwhile the service is stopped can replace all of those rows with rows under the retired key. They can also replace an audit anchor that names one of them with one made under the retired key. - The next removal accepts them and signs its audit anchor under the current key. Their shipped heads then count as pruned. The replaced newest row stays under the retired key, and later rows chain on it.
- The start after that write reports
audit_key_changed, as after a rotation, until a row is written under the current key.audit verify --logon a backup taken before that removal finds the replaced rows' shipped heads missing.
- Whoever can write
-
Audit growth and retention. Rows are kept
WAWARDEN_AUDIT_RETENTION_DAYSdays, 90 by default.- A client reading continuously at the default read budget adds about 131 MB a day, so the table can hold about 12 GB per such client. The archive file does not shrink when rows go.
- Removed rows can be checked only in a backup taken before the removal, until that backup is deleted. With a backup recipient, the retention waits for the night's backup and for the first one. Without one, it removes rows that no backup holds.
audit verifycounts a shipped head that the copy lacks as pruned when the time on its log line is not after the audit anchor's time. That time is the latest time of the rows removed, which the removal never sets more than 10 seconds after its cutoff. So the head of a retained row logged no later than the audit anchor's time counts as pruned, not missing, when the row is gone from the copy. That is so also when someone without the key moved the times of rows or of the audit anchor.
-
Key rotation is offline and manual.
keys rotaterefuses while another process holds the archive's lock, but it does not keep that lock. So a service started while it runs can load the old key.- An interrupted rotation leaves the current key in
keys/retired/, which the next start refuses untilkeys rotateruns again. - Retired keys are never deleted by the service. Deleting one leaves the
audit rows and audit anchor made under it
unknown_keyoranchor_invalid, and its cursors and references unopenable. Keeping one keeps its power over what was made under it. - A message reference for the same message reads differently after a rotation.
Sends¶
-
A lost acknowledgement. When WhatsApp's acknowledgement of a send does not arrive, the service cannot tell whether the message arrived. That happens on a timeout, a drop after the message was written, a restart, or an expired 60-second lease.
- The service stores the send as failed. It answers
504send_outcome_unknownto the request and to every replay, reportssend_outcome_unknown, and never sends it again. - The contact may have the message or not; only reading the chat tells.
- The protocol library itself writes a message again under the same id when the connection comes back within 5 seconds. Whether WhatsApp's servers drop such a repeat is not known.
- When an acknowledged send cannot be stored, the request answers
500,send_unrecordedis reported, and the key answers504once its lease ends, although the message was delivered. If history later brings the message, it is stored as the owner's, without the client's origin.
- The service stores the send as failed. It answers
-
A delivered send can answer
busy. The audit row of a request is written after the send has settled, just before the answer, and waits at most 2 seconds for the archive. So does the read of the stored message that the answer carries.- When the archive is held longer, by a long ingest write for example, a send
that went out is answered
503(busy), as an unanswered read is. - When it was the audit row that waited too long, the send has no row. When
it was the read, its row says
busy. - The archive still stores the message with
origingateway:<client id>, and the idempotency ledger still holds the send. A retry under the same key, whichbusyasks for, answers the stored201and writes the row. - A client that does not retry leaves the send attributed in the archive but not in the chain.
- When the archive is held longer, by a long ingest write for example, a send
that went out is answered
-
Recipients cannot tell a gateway send from the owner's. A message sent through WaWarden goes out from the owner's account like any message the owner types, so contacts cannot see that an agent wrote it. Only readers of the archive see
origingateway:<client id>. Every client whose read scope holds the chat sees which client sent it; the id is a look-up handle, not a credential. The quote of a reply carries the quoted text as the archive holds it. - Replayed results. For 24 hours, a client's idempotency key answers the
stored outcome of its first send to whoever presents that client's token,
without the limits.
- A stolen token therefore learns whether a key was used, and reads the sent message as it is now (a revoked message without its text).
- Keys are per client, so another client's key names nothing.
- The ledger holds the key, a fingerprint, the message id and the outcome, never the text or the chat.
- A restore from a backup takes the ledger back to the backup's time. So a send made since can be made again under its old key (see Backups).
Clients and agents¶
-
Prompt injection through message text. Message text, contact names and group subjects are written by third parties and reach agents by design.
- WaWarden limits what a manipulated agent can do with per-chat read and
write scopes, and no write access for read-all clients. Text is marked
untrustedwith itsorigin, tool definitions are constant, and every request has an audit row. Sends allow no first contact, at most one send per request and per key, and low limits behind a pacer. Only clients that may write see thesend_messagetool. - It cannot stop an agent from acting on what it reads, or from sending what a message told it to a chat it may write to.
- A request outside the scope cannot be counted apart from a request for
something missing, since both are the same
404. Sowawarden_policy_denials_totalstays0and is no alarm. The audit rows'not_foundreasons are what shows a client probing. - How an agent should treat what it reads is in agents.md.
- WaWarden limits what a manipulated agent can do with per-chat read and
write scopes, and no write access for read-all clients. Text is marked
-
References of merged chats. When a re-key merges a chat without messages into the other chat of the same person, the merged chat's reference stops resolving and answers as missing. A merge that would remove a chat a client names is refused instead (
rekey_conflictwithscoped_chat). So only a reference that a client reading every chat kept is affected. The surviving chat stays listed. - Search normalisation. The search query is normalised to NFC, but stored text is kept as it arrived. So a message whose text arrived decomposed (NFD) is not found by a query for a word in it that has a precomposed form.
- Search terms in proxy logs.
GET /v1/searchcarries its query in the URL, asq. WaWarden never logs it, but a reverse proxy in front of the client listener usually logs every URL. Its access logs then hold what clients searched for, and keeping, trimming or protecting them is the deployer's concern. The MCP toolsearch_messagescarries its query in the request body instead. The other paths and parameters hold only chat references, sealed cursors and message references, which name no chat or person. -
Group admins as the archive knows them. A revocation of another member's message in a group is applied when the archive records the revoker as an admin of that group.
- That record is only as current as the group changes and history WhatsApp has delivered. A demotion the engine never received leaves a former admin able to revoke. A member the archive does not record is refused.
- A member list from history sync never overrides what a live group change recorded. It cannot raise, re-add or remove a member that a live join, promotion, demotion or removal named. It is ignored for a group whose whole member list arrived live.
- It still records the members no live change has named, so a group known only from history keeps its admins.
-
References from the other side of a direct chat. The other party of a direct chat names it by the owner's identifier. The engine accepts the owner's number there. It accepts the owner's LID only once the archive has learned which LID belongs to that number. Until then, and whenever
WAWARDEN_OWNER_PHONEis unset, such edits, revocations, reactions and replies are dropped, or stored without their reference.
Logging¶
- Log pseudonyms can be linked and tested. A pseudonym stands for the same identifier in every log line until the master key changes, so lines about one chat can be linked to each other. It is 32 bits long, so two identifiers can share one. Whoever holds the master key, from the data directory or a backup, can confirm a guessed identifier against a pseudonym.
- Identifiers are recognised by their shape. Only identifiers in
user@serverform are recognised, and only when no ASCII letter or digit follows the server name directly. A phone number on its own, a protocol-library address of a user and a device without a server, or an identifier run into the following word passes unchanged, so code must not log one. - Tokens are recognised by their exact shape. The log writer, the
protocol-library adapter and the refusal code an admin command prints redact
only a whole client or admin token of the generated shape. A token cut short,
split by other text or written in another encoding passes unchanged, so code
must not log one. The
token:line ofadmin clients createand ofadmin initprints the new token on purpose, outside the writer. The admin CLI otherwise prints server answers through the terminal sanitiser only. The Go runtime's crash output does not pass the writer. - XML is recognised by its shape. The log writer drops a line only when it
holds one of the XML shapes listed in row 13 and
Pseudonyms and dropped lines.
An opening tag without attributes followed only by text, such as a cut-off
<body>element, passes. So code must not log message content, even inside a tag. - Notifications can be late, lost or read by their receiver.
- Failed admin authentications after the first are reported up to about 70 seconds later, together.
- The webhook drops events when its queue of 64 is full, after five failed
attempts, or at shutdown, counting them in
wawarden_notify_dropped_total. A receiver that must not miss an event should alert on the EMF metrics as well. - The receiver learns the events, their reasons, counts and times. Anyone with the signing secret can forge events that the receiver accepts, so keep the secret file and the receiver's copy as private as the admin token.
- The URL itself can hold a secret of the receiver's, and sits in the service's environment.
- The destination check refuses addresses by range. So a public address that the operator did not mean, or an allowed private one, is reached as configured.
- It recognises only the well-known and local-use NAT64 prefixes. On a network whose NAT64 uses a prefix of its own, a name whose IPv4 address is private or link-local is synthesised into an IPv6 address that the check does not map back, and is reached.
Deletion and storage¶
-
Deletion residuals.
- Backups taken before a message was revoked, edited or expired keep its old
text until they are deleted. Retention deletes a backup once
WAWARDEN_BACKUP_KEEPnewer ones exist. So on an active service, the old text stays in backups for that many nights after the change. - A service that stops backing up keeps its last backups indefinitely, and a prune refused for an unexpected entry deletes nothing.
- Copies made outside
backups/, such as storage snapshots, recovery points or copies the operator took off the host, keep the old text for as long as they are kept. Filesystem blocks, snapshots and storage-level copies are outside the service's control. - A restore brings revoked, edited and expired text back into the archive, the expired text until the next sweep.
- A trigram of the old text that a remaining message also holds stays in the full-text index, because that message still needs it.
- The deletion clears only the message's own text. Those bytes stay in the archive when the same text, or one of its trigrams, is held by a push name, a saved contact name, a chat name or a group subject, a message waiting in the inbox, an identifier or another stored value, or the schema.
- The full-text index finds a term through page keys, and a key can be a prefix of the first term of its page. The index rewrite runs only for a key that is a whole trigram of the old text. So a key holding its first one or two characters, or part of a character, stays until a later rewrite.
- A trigram that is a key also stays from the commit of the deletion until the rewrite commits. When the process stops or the rewrite fails in between, it stays until the next start or the next rewrite.
- A revocation or an edit whose target is not yet in the archive is dropped, and not kept for later. History sync can store the target afterwards, as it can during the initial sync, where live traffic is applied while blobs wait for their download. The target then keeps its original text, and nothing that arrives later removes it.
- Backups taken before a message was revoked, edited or expired keep its old
text until they are deleted. Retention deletes a backup once
-
An interrupted revoke. A crash in the middle of a revoke, an edit or an expiry rolls it back. The old text is then in the database again until the change is applied again, and the journal keeps the pre-image until the next write truncates it.
- Crashes count as attempts. A history blob whose processing is cut short by a crash three times is quarantined like a failing one. The batches it had applied before each crash stay in the archive, and its remaining messages do not reach it. A graceful stop gives its attempt back.
- Raw history blobs wait on disk. A downloaded history blob, the
protocol's own messages with their media keys and message secrets, stays in
its file under
history/until it is processed or quarantined. A start keeps the files of pending blobs. So while ingest is paused or the service is stopped, a blob can stay there indefinitely.
Backups¶
- Who encryption protects against. Encrypting to a recipient only protects a backup from whoever reads it without the age identity: the storage it is copied to, a backup of the data directory, a stolen disk. It does not protect a backup from whoever holds the identity, nor the live databases, which are in plaintext on the same host.
- No check in the service. The service cannot check that an earlier backup
still decrypts.
wawarden backup verifychecks a backup offline, where the identity is kept. Nothing checks the backups inbackups/unless the operator copies them off and runs it. - Plaintext while a backup runs. A plaintext copy of one database at a time
sits in
backups/tmp/with mode0600. On a filesystem that keeps deleted blocks or snapshots, it can outlive its removal. A crash leaves it there until the next start removes it. - age does not authenticate the sender. Anyone who knows the recipient,
which is a public key, can write a well-formed backup that decrypts. So a
backup is only as genuine as the storage that holds it. The permissions of
backups/, and of wherever backups are copied, must keep others from replacing or adding files. A truncated or altered backup is detected only when it is decrypted. - Two points in time. The two databases are copied one after the other, the archive first, and the device store goes on changing after its copy. A restored device store holds the device's state as of the backup.
- Restore rules are documented, not enforced. Restore only into a stopped service, take both databases from one backup, and never start two instances from one device store. There is no command that restores; the runbook gives the procedure.
-
A restore takes the archive back in time.
- Clients revoked after the backup are active again, with their old tokens.
- The send budgets and the record of sends roll back, so a retried send can go out twice.
- These hold until the operator revokes again from the
admin_mutationevents since the backup. - The audit rows written between the backup and the restart are lost, and
their ids reused. So the heads shipped in that window never match again,
and
audit verify --logmust be given a capture without them, as the runbook shows.
-
Keys are not in a backup.
keys/masterandkeys/retired/are not in a backup. A restore without the operator's copies changes pseudonyms, cursors and references, and leaves older audit rowsunknown_key. - The device store going back in time. What WhatsApp's servers and contacts do with a device store whose sessions and pre-key numbers went back in time is not known, and is testable only with a live account.
- Retention. Retention runs only after a successful backup. It deletes
only what matches a backup's name, mode and owner. A prune that finds
anything else deletes nothing, after every backup, until the entry is
removed. So the directory grows past
WAWARDEN_BACKUP_KEEP, and the operator must act onbackup_prune_failed. - Skipped backups. A backup skipped because neither database changed is skipped only within one process. A write that lands between a backup's copy and its bookkeeping is noticed only with the next write.
- History blob rows. The rows of history blobs processed more than 30 days before are deleted. Whether WhatsApp could deliver such a blob again after its row is gone, which would download and apply it again, is not known.
Trust roots¶
WaWarden cannot compensate for the following; a deployment must address them outside the service.
- Whoever controls the deployment pipeline or the host controls the account. Anyone who can change the image or configuration that is deployed, read the data directory, or restore its backups can impersonate the account without passing through any WaWarden control. Limit write access to the deployment pipeline and its configuration. Do not give hosts that run agents credentials that can change them.
- One host is one trust domain for tokens. Processes running as the same user can read each other's tokens, and nothing in WaWarden can tell them apart. Do not place a token that reads every chat, or a write token, on a host where agents with shell access read untrusted input. agents.md gives the token rules for agents.
- Other linked devices bypass the gateway. Any other client linked to the same account has unrestricted access to it. The protocol library accepts history-sync and identity-mapping payloads from any of the account's own devices. WaWarden accepts history only from the primary device, and learns its own identity map only from server-asserted fields and the primary device's history. Unlink the companion devices that agents can reach once WaWarden replaces them.
- Anyone who can read process logs must not learn chat identities or content. Treat everyone with access to standard output, the log pipeline or its storage as a reader of logs. That is why I-5 holds for every line the process writes, with the crash-output exception under Dependencies.
- The maintainer's GitHub account controls releases. It is the only
approver of the
releaseenvironment. A release built from malicious source onmainpasses every verification in RELEASING.md: provenance, signatures and reproducibility show where and how an artifact was built, not that its source is benign. Deploy by digest, and review the source diff between the version you run and the next one before upgrading.
Reporting¶
Report vulnerabilities as described in SECURITY.md.