Files
tai/taicpp/docs/whatsapp.md
T
retoorandClaude Sonnet 5.5 1611f3baeb whatsapp: fix link/login 401, add image send, contact sync and search; harden failover
Root causes, each reproduced against the live server or endpoint:
- Server-only JIDs decoded as "@s.whatsapp.net" and pairing replies copied them
  into `to`, so the device was never registered and every login returned 401.
  Decoder/encoder fixed, replies use s.whatsapp.net, ADV hosted prefixes follow
  whatsmeow. The link wizard now waits for the restart, logs in, uploads prekeys
  and retries once with a fresh device on 401/403.
- Closed sockets made every receive loop spin until its deadline (the 7 minute
  CPU burn). Loops stop on a closed stream.
- whatsapp_send had no image argument and send_image was a stub. Added WaMedia
  (byte-exact against 8 wa-go golden vectors), media_conn upload, image send and
  the DeviceSentMessage copy for the user's other devices.
- Phone contacts never reached the client: added app-state sync (WaAppState,
  WaReceiver, LTHash and MAC checks verified against a whatsmeow-built oracle)
  and the historySyncConfig declaration at registration.
- Contact lookup: FTS5 trigram plus fuzzy ranking, ambiguity is reported and
  never guessed, global search lists contacts. Send caps (10/min, 200/day) are
  enforced and survive unlink.

Failover and agent:
- Every failed backend candidate is printed the moment it fails.
- muse runs with low reasoning effort, an empty preset and one stable workspace
  (36.5s down to 2.8s on the real prompt); Ctrl-C no longer counts as a backend
  failure; CLI backends use the circuit breaker; opencode runs are capped at 30s.
- Orphan tool results are dropped before sending (devplace HTTP 400, reproduced);
  compaction no longer starts its window on a tool result.
- Terminal art pasted as a prompt is refused before any model call.

Tests: whatsapp_test, muse_flags_test, llm_history_test; full suite 40 steps
(agent.e2e reads live tmux panes and is flaky, green on re-run).
Research archived under research/whatsapp-automation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 21:32:38 +01:00

649 lines
41 KiB
Markdown

# whatsapp (taicpp): research result and build specification
`whatsmeow` is an open source Go library for the WhatsApp Web multidevice API
(author Tulir Asokan, module `go.mau.fi/whatsmeow`, license MPL-2.0). It speaks
the WhatsApp protocol directly over one WebSocket, with no browser involved:
QR and pair-code linking, Noise handshake, Signal encryption, send and receive,
media upload, and app-state (contact) sync. detz does not link or ship it: the
native C++ module `detz::wa` is transpiled from `wa-go` (MIT) with `whatsmeow`
as the behavior reference (section 3), and the test oracle
`tests/whatsapp_data/appstate_oracle.go` imports its Go packages to generate
byte-exact vectors. Any comment saying "ported from whatsmeow" means the logic
or byte layout was copied from upstream and must stay compatible with it. Full
comparison: section 2.1.
Status: **built and integrated; the offline core is verified, the live link
is verified through QR display, message send is built but unverified** (it
needs a phone-linked account, which development does not have). What is in the
tree now (`src/whatsapp/`, namespace `detz::wa`):
| Piece | Files | State |
|---|---|---|
| `WaCrypto` (X25519, AES-GCM/CTR/CBC, HKDF, PBKDF2, SHA, XEdDSA) | `wa_crypto.*` | verified: RFC vectors + XEdDSA roundtrips |
| `WaBinary` (binary-XML codec, token dict v3) | `wa_binary.*`, `wa_tokens_data.h`, `wa_node.h` | verified: wa-go codec battery 19/19 |
| `WaProto` (protobuf + the needed messages) | `wa_proto.*` | verified: ClientPayload byte-exact |
| `WaNoise` (Noise XX + transport) | `wa_noise.*` | verified: golden transcript **and live server** |
| `WaSignal` (X3DH + Double Ratchet) | `wa_signal.*` | verified: golden session 4/4 byte-exact |
| `WaDevice` (identity + ClientPayload) | `wa_device.*` | verified via the handshake |
| `WaSocket` (libcurl WebSocket) | `wa_socket.*` | verified live (connect + frames) |
| `WaClient` (connect, pair, login, send) | `wa_client.*` | connect/QR live-verified; pair-complete + send built, unverified |
| `WaStore` (vault SQLite) | `wa_store.*` | verified: round-trips device + contacts |
| `WaQr` (terminal QR) | `wa_qr.*` | verified: renders a scannable code |
| `WaWizard` (`--whatsapp-link`) | `wa_wizard.*` | live-verified through QR display + device persist |
| agent tools `whatsapp_send`, `whatsapp_contacts` | `src/agent/detz_tools.cpp`, `tools_ops.cpp` | wired; contacts verified, send unverified |
Verified against the live server (no phone needed): `detz --whatsapp-link`
connects, completes the Noise handshake, receives the `pair-device` node, renders
the QR, and saves the device. A phone scanning that QR would link it; the
pair-success handler (ADV HMAC + account-signature verify, device signing,
`pair-device-sign`) is ported faithfully from whatsmeow but cannot be exercised
in-tree. `whatsapp_send` performs a real usync + prekey-fetch + Signal-encrypt +
send-node sequence; it is faithful to whatsmeow but unverified without a live
linked account. Unit test: `build/whatsapp_test` (16/16), vectors in
`tests/whatsapp_data/`. Dependencies added: none at runtime (OpenSSL was already
linked for the static build; added to the default link), plus two vendored
libraries, `third_party/ed25519` (zlib) and `third_party/qrcodegen` (MIT).
Remaining to make send fully work: own-device fan-out (DeviceSentMessage),
media upload for `send_image` (`WaMedia`), prekey upload after login, retry
receipts, and a live test with a sacrificial number.
Original research and plan below (kept as written; some "not built yet" lines
are now superseded by the table above).
---
This file states what was found, what was
decided, and the exact plan to build a native C++ WhatsApp client into detz.
Research archive: [`../../research/whatsapp-automation/`](../../research/whatsapp-automation/)
(sources and three conclusions). Every number below was measured on 2026-10-27
on this machine unless a line says otherwise.
## 1. Goal
One sentence must work in detz:
> "Create screenshot and explain what is genius about it and send to Niggel on WhatsApp"
The flow breaks into four steps. Three of them already exist:
| Step | Owner | Exists |
|---|---|---|
| take a screenshot | `shell` tool (`grim`, `scrot`, `import`) | yes |
| explain it | `describe_image` tool (src/agent/tools_core.cpp) | yes |
| resolve "Niggel" to a WhatsApp JID | `WaContacts` (this spec) | no |
| send text plus image | `whatsapp_send` tool over `WaClient` (this spec) | no |
The missing part is one module: a WhatsApp companion device written in C++.
## 2. Answers to the questions asked
### 2.1 Best Python libraries for a normal phone number
There is no Python library that speaks the current WhatsApp protocol itself.
Measured on the cloned repositories:
| Library | Language of the protocol code | How it works | Verdict |
|---|---|---|---|
| neonize (Apache-2.0, 759 stars, 62 commits in 90 days) | **Go** (whatsmeow) | 4362 lines of Go export 93 C functions (`//export`), 14918 lines of Python call them through `ctypes` | best "Python" option, but it is whatsmeow with a Python skin |
| whatsfly | Go (whatsmeow) | same idea, PyPI 0.0.1, linux 32 bit only | early, not usable |
| whatsapp-mcp (lharries, MIT) | Go (whatsmeow) | Go bridge with HTTP `/api/send`, Python MCP server with `send_message`, `send_file` | usable shortcut, see section 10 |
| yowsup (GPL-3.0) | Python | old phone-registration protocol (`e1.whatsapp.net:443`), last commit 2021-12-14, README warns that registration gives an instant ban | dead, do not use |
| pywhatkit, Astra, wa-automation, whalesong | Python | drive WhatsApp Web in a real browser | browser, rejected |
| Supbot2 | Python | drives the Android app with Appium | needs a phone or emulator, rejected |
| pywa and other Cloud API clients | Python | official Meta Business Cloud API | needs a business number; the number cannot stay on the normal app, rejected for a personal number |
Conclusion: transpiling Python gets us nothing. The real implementations are:
| Project | Language | License | Activity (commits in last 90 days) | Size (non-blank, non-test, non-generated) |
|---|---|---|---|---|
| whatsmeow (tulir) | Go | MPL-2.0 | 78 | 24,301 lines, plus 107,816 generated protobuf lines |
| libsignal-protocol-go (tulir), used by whatsmeow | Go | **GPL-3.0** | not measured | 5,416 |
| wa-go (jfelipesjc) | Go | **MIT** | 17, created 2026-06-26, 1 star | 23,278 (wire 2778, signal 1625, keys 311, media 517, client 14379) |
| Baileys (WhiskeySockets) | TypeScript | MIT | 7 | 48,898 |
| whatsapp-rust (oxidezap) | Rust | MIT | at least 100 (API page limit) | about 650,000 including VoIP and plugins |
| libsignal-protocol-c (signalapp) | C | **GPL-3.0** | last commit 2020-03-27 | 29,456 |
No C or C++ library implements the multi-device protocol. Search found only
WebKit wrappers (WasIstLos, YAWF) and a REST wrapper (Green-API).
### 2.2 Is a browser required
**No.** whatsmeow, Baileys, wa-go and whatsapp-rust all speak the protocol
directly over one WebSocket. The phone is needed exactly once, to link the
device: scan a QR code shown in the terminal, or type an 8-character pairing
code into WhatsApp > Linked devices. After that the companion runs alone. The
phone must come online now and then; one directory page reports
re-authentication after about 20 days (not verified).
### 2.3 Can it be done in C++ with what detz already links
Yes. detz already links libcurl and OpenSSL:
- libcurl 8.18.0 on this machine lists `ws wss` as protocols.
`curl -H "Origin: https://web.whatsapp.com" wss://web.whatsapp.com/ws/chat`
answered **`101 Switching Protocols`**, so the transport is proven reachable
from here with the library we already ship.
- OpenSSL 3.5.5 covers every primitive except XEdDSA signing (section 5).
- Protobuf needs no library: we need about 15 messages, and a hand-written
varint and length-delimited writer and reader is about 200 lines.
New dependencies: **none.**
## 3. Decision: native C++, transpiled
Build `detz::wa` as a native C++ module. Transpile from two sources:
1. **wa-go (MIT) is the code source** for wire, Noise, binary codec, keys,
Signal (X3DH, Double Ratchet), media crypto and pairing. Reasons: MIT, small
(the whole Signal layer is 1625 lines against 3585 for the libsignal-go
subset), layered exactly like detz wants (`internal/wire`, `signal`, `keys`,
`media`), and it ships **788 KB of golden vectors captured from real Baileys**
(`testdata/traces/connect_pair`, `testdata/signal/session_ab.json`,
`testdata/media/media_vectors.json`, `testdata/paircode/vectors.json`). Those
become our C++ test fixtures as they are.
2. **whatsmeow (MPL-2.0) is the behaviour reference**: it is the most used and
most maintained (78 commits in 90 days). When wa-go and whatsmeow disagree,
whatsmeow wins. The current client version, the server certificate key and
the node shapes are read from whatsmeow at build time of each release.
Do **not** transpile libsignal-go or libsignal-protocol-c: both are GPL-3.0,
and detz is commercial. MPL-2.0 is file-level: a C++ file transpiled from a
whatsmeow file keeps the MPL notice and its source stays available. Copying
code from wa-go, or reading whatsmeow to learn a value, does not trigger this.
This is a fact about the licenses, not legal advice.
## 4. Module layout
Namespace `detz::wa`, sources in `src/whatsapp/`, one class per `.h`/`.cpp`
pair, as the rest of taicpp.
| Class | Responsibility | Transpiled from (wa-go / whatsmeow) |
|---|---|---|
| `WaConstants` | URL, origin, Noise pattern, header, dict version, cert key, client version, all caps (section 8) | `socket/constants.go`, `store/clientpayload.go` |
| `WaProtoWriter`, `WaProtoReader` | protobuf wire format: varint, zigzag not needed, length-delimited, fixed32 | none, hand-written |
| `WaMessages` | the ~15 protobuf messages as plain structs with `encode()`/`decode()` | field numbers from whatsmeow `proto/*.proto` |
| `WaCrypto` | static: X25519, AES-256-GCM, AES-256-CTR, AES-256-CBC, HKDF-SHA256, HMAC-SHA256, SHA-256, SHA-512, PBKDF2-SHA256, random, over OpenSSL EVP | `util/*util`, wa-go `keys` |
| `WaXeddsa` | XEdDSA sign and verify over Curve25519 keys | libsignal-go `ecc/SignCurve25519.go` logic, wa-go `keys` (section 5) |
| `WaSocket` | libcurl WebSocket, 3-byte big-endian frame length, `WA` header on first frame, 16 MiB frame cap | wa-go `wire/framing.go`, `ws` |
| `WaNoise` | Noise XX handshake and the post-handshake read/write ciphers | wa-go `wire/noise.go`, whatsmeow `socket/noisehandshake.go`, `handshake.go` |
| `WaNode`, `WaBinaryCodec`, `WaTokens` | binary XMPP-like nodes, token dictionary v3 (236 single-byte, about 1030 double-byte tokens), nibble/hex packing, JID encoding | wa-go `wire/codec.go`, `token.go`, `node.go` |
| `WaStore` | SQLite tables: device identity, signed prekey, prekeys, sessions, identities, contacts, aliases | wa-go `store`, whatsmeow `store/sqlstore` |
| `WaPairing` | QR pairing (`pair-device`, ref rotation, `pair-success` with ADV signature check) and phone pairing code | wa-go `client` pairing, whatsmeow `pair.go`, `pair-code.go` |
| `WaSignal` | X3DH, Double Ratchet, `pkmsg`/`msg` encrypt and decrypt, session records | wa-go `internal/signal` (x3dh, ratchet, session, message) |
| `WaPrekeys` | upload 50 prekeys plus signed prekey after pairing, refill below threshold | whatsmeow `prekeys.go` |
| `WaUsync` | device list of a recipient (`usync` iq), "is this number on WhatsApp" | whatsmeow `user.go` (subset) |
| `WaMedia` | media key expansion (`WhatsApp Image Keys`), AES-CBC plus HMAC, `media_conn` iq, HTTPS upload to `/mms/image/<token>` | wa-go `media`, whatsmeow `upload.go`, `mediaconn.go` |
| `WaSender` | 1:1 send: device fan-out, encrypt per device, own other devices get a `DeviceSentMessage`, `<message><participants>` node, `phash` check | whatsmeow `send.go` (DM path only) |
| `WaReceiver` | decrypt incoming 1:1 text, send receipts and acks | whatsmeow `message.go`, `receipt.go` (subset) |
| `WaClient` | owns the above; connect, login, keepalive, reconnect, event loop on one thread | whatsmeow `client.go`, `keepalive.go`, `connectionevents.go` |
| `WaContacts` | name to JID: user aliases first, then contacts learned from push names, then fuzzy match with `detz::FuzzyMatcher` (floor 0.3) | none, detz design |
| `WaTools` | agent tools `whatsapp_send`, `whatsapp_contacts`, `whatsapp_alias` | none, detz design |
Out of scope for v1, listed so nobody assumes otherwise: groups (sender keys),
history sync, calls, status, newsletters,
receiving media, editing, reactions.
## 5. Protocol walkthrough and crypto mapping
Order of one session, with the OpenSSL call for each primitive.
1. **Connect.** WebSocket to `wss://web.whatsapp.com/ws/chat`, header
`Origin: https://web.whatsapp.com`. Every frame is a 3-byte big-endian
length plus payload; the first frame is prefixed with
`'W','A',6,3` (magic 6, dictionary version 3). Frame cap `1 << 24`.
2. **Noise XX handshake.** Pattern string
`Noise_XX_25519_AESGCM_SHA256\0\0\0\0` (32 bytes, used as the initial hash
as is). Send `HandshakeMessage.ClientHello{ephemeral}`; receive
`ServerHello{ephemeral, static (encrypted), payload (encrypted cert)}`;
mix X25519 shared secrets with HKDF-SHA256 into AES-256-GCM keys, counter IV;
verify the certificate chain against the pinned key `WACertPubKey` in
whatsmeow `handshake.go`; send `ClientFinish{static (encrypted noise key),
payload (encrypted ClientPayload)}`. Then split into a read and a write key.
The proto also defines post-quantum modes (`XXKEM`, `IKKEM`, `WA_PQ`);
whatsmeow does not use them today, so neither do we.
3. **ClientPayload.** Register (unpaired: `devicePairingData` with registration
id, identity public key, signed prekey and its signature, build hash = MD5 of
the version string) or login (paired: `username` = phone number, `device` =
device id). The client version is a constant
(`2.3000.1050728964` in whatsmeow on 2026-10-27). A stale version gives
connect failure **405 client outdated**; whatsmeow can fetch the live value
from `web.whatsapp.com` (`"client_revision":N`).
4. **Pairing.** QR: the server sends `pair-device` with refs; the QR string is
`https://wa.me/settings/linked_devices#ref,noisePub,identityPub,advSecret,clientType`
(keys base64), shown in the terminal, refs
rotate. Phone code: `PairPhone` sends a `link_code_companion_reg` iq; the
8-character code derives an AES-256-CTR key with **PBKDF2-SHA256, 131072
(2<<16) iterations**. On `pair-success` verify the account signature
(XEdDSA), sign the device identity (XEdDSA), send `pair-device-sign`, store
the JID, reconnect and log in.
5. **Prekeys.** Upload signed prekey plus a batch of 50 one-time prekeys (iq
`xmlns="encrypt"`, whatsmeow `WantedPreKeyCount`). Refill when the server
reports fewer than 5 (`MinPreKeyCount`).
6. **Send to a contact.** `usync` iq for the recipient's device list (and our
own); fetch prekey bundles for devices without a session; X3DH, Double
Ratchet encrypt `Message{conversation}` or `Message{imageMessage}` padded;
send `<message to type="text|media"><participants><to jid><enc type="pkmsg|msg"
v="2">` and compare the returned `phash`. Addresses can be phone JIDs
(`@s.whatsapp.net`) or LID JIDs (`@lid`); whatsmeow `send.go` mentions LID 45
times, so LID handling is part of the send path, not an extra.
7. **Image.** Random 32-byte media key; HKDF-SHA256 with info
`WhatsApp Image Keys` to 112 bytes (IV, cipher key, MAC key, ref key);
AES-256-CBC, HMAC-SHA256 truncated to 10 bytes appended; `media_conn` iq gives
hosts and auth; HTTPS POST to `https://<host>/mms/image/<token>`; put
`url`, `directPath`, `mediaKey`, `fileSha256`, `fileEncSha256`,
`fileLength`, `mimetype`, `caption` into `ImageMessage`.
| Primitive | Where | OpenSSL 3.5 |
|---|---|---|
| X25519 | Noise, X3DH, ratchet | `EVP_PKEY_X25519`, `EVP_PKEY_derive` |
| AES-256-GCM | Noise transport | `EVP_aes_256_gcm` |
| AES-256-CTR | pairing code | `EVP_aes_256_ctr` |
| AES-256-CBC | Signal message keys, media | `EVP_aes_256_cbc` |
| HKDF-SHA256 | Noise, ratchet chains, media keys | `EVP_KDF "HKDF"` |
| HMAC-SHA256 | ratchet, media MAC | `EVP_MAC "HMAC"` |
| PBKDF2-SHA256 | pairing code | `PKCS5_PBKDF2_HMAC` |
| SHA-256, SHA-512, MD5 | everywhere, XEdDSA, build hash | `EVP_Digest` |
| **XEdDSA sign** | signed prekey, pairing device signature | **not in OpenSSL** |
| XEdDSA verify | pairing account signature | Montgomery u to Edwards y, then `EVP_PKEY_ED25519` verify (to be proven) |
XEdDSA needs Edwards25519 scalar and point arithmetic, which OpenSSL does not
expose. The algorithm is 60 lines in libsignal-go `ecc/SignCurve25519.go`
(clamped scalar a, A = aB with sign bit forced to 0, r = SHA-512 of a
prefix, a, message and 64 random bytes, R = rB, s = r + SHA-512(R,A,M) a mod L).
The arithmetic underneath (filippo.io/edwards25519, BSD-3-Clause) is what has to
be ported or replaced. That is the one hard crypto item, see section 6.
## 6. Build versus deep research, decided up front
**Build** means mechanical transpile with a known oracle. **Deep research** means
the answer is not written down anywhere we have read, and has to be
established first, with an archive entry, before any code.
| Item | Kind | Oracle or open question |
|---|---|---|
| protobuf writer/reader, message structs | build | field numbers in whatsmeow `.proto`; wa-go `client_payload.json` trace |
| `WaCrypto` wrappers | build | RFC test vectors; wa-go `media_vectors.json` |
| framing, Noise, binary codec, tokens | build | wa-go `traces/connect_pair` (`noise.json`, `frames_raw.jsonl`, `nodes.jsonl`), `codec_battery/nodes.jsonl` |
| Signal X3DH, ratchet, session | build | wa-go `testdata/signal/session_ab.json` |
| pairing code crypto | build | wa-go `testdata/paircode/vectors.json` |
| media encrypt, upload | build | wa-go `media_vectors.json`, then one live send |
| usync, prekey upload, send node shape | build | whatsmeow source, then one live send |
| **XEdDSA sign** | **deep research** | port the Edwards25519 field/scalar/point subset (BSD-3, from filippo.io/edwards25519) or find a permissive C implementation; verify against wa-go `keys/testdata/verify_sig.mjs` style vectors |
| **current client version and 405 policy** | **deep research** | how often whatsmeow bumps `waVersion`; whether to fetch `client_revision` at start (adds one HTTPS call, kills the treadmill) |
| **LID addressing** | **deep research** | when the server hands out `@lid`, how sessions map phone JID to LID; read whatsmeow `send.go` and `store` LID tables |
| **ban behaviour** | **deep research, measured only with a sacrificial number** | no maintainer publishes heuristics; one vendor article names account age, message rate and reports |
| contact names without app-state | build | push names in incoming messages plus user aliases; app-state sync is v2 |
## 7. User flow (the customer view)
Two commands, nothing else to configure:
```
detz --whatsapp-link # shows a QR in the terminal, scan in WhatsApp > Linked devices
detz --whatsapp-link 31612345678 # prints an 8-character code to type on the phone instead
```
Then, in any detz session:
```
> remember that Niggel is 31687654321 on WhatsApp
> create a screenshot, explain what is genius about it and send it to Niggel on WhatsApp
```
The first line calls `whatsapp_alias`. The second runs `shell` (screenshot),
`describe_image`, then `whatsapp_send{to:"Niggel", text, image}`. `whatsapp_send`
resolves the name through `WaContacts`; when the best match is not exact, the
tool result names what it resolved to, the same note pattern as `@agent`
mentions. `detz --whatsapp-unlink` logs the device out and deletes its rows.
State lives in the profile's vault (SQLite, the existing `store`), not in a new
file. The link step is the only step that touches the phone.
Agent tools:
| Tool | Arguments | Result |
|---|---|---|
| `whatsapp_send` | `to` (name, alias or number), `text`, optional `image` (path) | message id, resolved JID and name, `phash` match |
| `whatsapp_contacts` | optional `query` | known names and JIDs |
| `whatsapp_alias` | `name`, `number` | stored alias, after a usync check that the number is on WhatsApp |
## 8. Caps (all static, per CLAUDE.md "everything gets a hard cap")
| Constant | Value | Why |
|---|---|---|
| `kWaFrameMax` | 16 MiB (`1 << 24`) | protocol limit |
| `kWaImageMax` | 16 MiB | upload cap; refuse larger, loud |
| `kWaSendPerMinute` | 10 | ban exposure; the agent must not burst |
| `kWaSendPerDay` | 200 | same |
| `kWaRecipientsPerSend` | 1 | v1 is 1:1 only |
| `kWaPrekeyBatch` | 50 | whatsmeow `WantedPreKeyCount` |
| `kWaConnectTimeout` | 20 s | handshake plus login |
| `kWaPairTimeout` | 120 s | QR or code wait |
| `kWaReconnectFloor` | 5 s, doubling to 300 s | never hammer the server |
| `kWaContactsMax` | 5000 | contact and alias rows per profile |
These are judgments, not measurements. They become measured once the live
tests below run.
## 9. Tests
Unit (`tests/whatsapp_test.cpp`, `make test`):
- every golden vector from wa-go `testdata/` (copied with its MIT notice into
`tests/whatsapp_data/`): Noise transcript, raw frames, decoded nodes, codec
battery, ClientPayload bytes, Signal session A to B, pairing code, media.
- XEdDSA: sign then verify round trip, plus verify of a signature made by
libsignal (vector to be produced once, archived).
- protobuf: encode equals the bytes in the traces, decode of every trace.
Integration (`tests/whatsapp.py`, real binary, real WhatsApp, per AGENTS.md):
- needs a sacrificial number linked once (`WA_TEST_PEER` names a second number);
- cases: link state survives restart, send text, send image, receive the
reply text, `405` handling with a forced old version;
- verification reads vault rows (message id, session rows), never CLI text.
## 10. The shortcut for the day-after-tomorrow promise
The native module is roughly the size measured in section 2.1, so it does
not ship in two days. What can work now, with no C++:
1. clone `lharries/whatsapp-mcp`, **bump its whatsmeow** (`go.mod` pins
`v0.0.0-20250318233852`, 19 months old; a stale whatsmeow risks the 405
client-outdated failure), run `whatsapp-bridge` once and scan the QR;
2. add the MCP server to detz (`mcp_add`, stdio transport,
`uv --directory .../whatsapp-mcp-server run main.py`);
3. the sentence then runs as `shell` + `describe_image` + `send_file`.
This path was **not run end to end**: it needs a linked phone, and a local
probe (an unpaired whatsmeow connect that prints QR events, which proves the
full handshake without a phone) was refused by this session's permission
classifier because it executes downloaded code. To run it yourself:
```
mkdir probe && cd probe && go mod init probe
go get go.mau.fi/whatsmeow@latest modernc.org/sqlite
# main.go: sqlstore.New("sqlite", "file:probe.db"), NewDevice, NewClient,
# GetQRChannel, Connect, print each event; expect "code" events within seconds
go run .
```
## 11. Phases
| Phase | Content | Exit test |
|---|---|---|
| 0 | MCP shortcut (section 10) | the sentence works once, live |
| 1 | `WaProto*`, `WaCrypto`, `WaXeddsa`, `WaSocket`, `WaNoise`, codec, tokens | all wire and crypto golden vectors green; live: QR events from the real server |
| 2 | `WaStore`, `WaPairing`, `WaPrekeys`, login | live: link by QR and by code, restart stays logged in |
| 3 | `WaSignal`, `WaUsync`, `WaSender`, `WaReceiver` | Signal vectors green; live: text to a peer and its reply decrypted |
| 4 | `WaMedia`, `WaContacts`, `WaTools`, CLI flags | live: the full sentence, then the MCP shortcut is removed |
Size guide (measured Go source to transpile, non-blank, non-comment): whatsmeow
files on the 1:1 path (handshake, pair, pair-code, prekeys, send, message,
receipt, upload, mediaconn, user, request, keepalive, client,
connectionevents, qrchan, retry, socket, binary, keys, crypto utils) total
8,616 lines, and only part of send, message and user is needed; wa-go wire,
keys, signal and media total 5,231. The C++ size is not measured until it exists.
## 12. Risks and gaps
- **Terms of Service.** Every unofficial client breaks WhatsApp's terms; every
project README and the one vendor article found say a number can be banned.
Use a sacrificial number for tests. The sanctioned path (Business Cloud API)
cannot use a personal number.
- **Protocol drift.** whatsmeow changes about 6 times a week (78 commits in
90 days). We follow whatsmeow; each release re-checks the version constant,
the cert key and the token dictionary.
- **wa-go is young** (created 2026-06-26, one author, 1 star). Its golden
vectors come from Baileys, which keeps them honest, but its code is not
battle tested. whatsmeow stays the referee.
- **Unverified here:** the full handshake (probe refused), XEdDSA verify via
OpenSSL Ed25519, the 20-day re-auth claim, the 405 behaviour.
## 13. Robustness against hostile input (incident WA-2026-10-27, and the standing rule)
Every byte this module parses comes off a network socket we do not control.
That makes the wire parsers (`WaBinary`, `WaProto`, and the Noise/Signal frame
readers) a security and stability boundary, not ordinary code. This section
records a crash that got through, exactly why, how it was fixed, and the rule
and test that keep it from ever coming back. **We do not accept regressions
here.**
### 13.1 What happened
Running `detz --whatsapp-link` on a real terminal, the wizard connected, showed
the QR, and after the code auto-refreshed it aborted:
```
terminate called after throwing an instance of 'std::length_error'
what(): cannot create std::vector larger than max_size()
Aborted (core dumped)
```
A core dump, not a handled error. The process died mid-session instead of
logging a bad frame and carrying on.
### 13.2 Root cause
Two independent faults lined up:
1. **An overflowable bounds check.** Both the protobuf reader and the binary-node
decoder guarded a length-delimited field with `pos + len > size`. `pos` is a
`size_t` and `len` was read straight from the wire (up to 2^32-1, or a
4-byte length sign-extended into a negative `int`). `pos + len` then wrapped
around past `SIZE_MAX`, computed a small sum, and the check passed. The code
went on to `vector::assign(begin, begin + len)` with a garbage `len`, and the
standard library threw `std::length_error` from inside `vector`.
2. **An unguarded top-level loop.** `WaClient::dispatch` routed nodes straight
into `handle_pair_success` and the other handlers with no `try/catch`. The
decode path in `recv_node` was wrapped, but the handlers that re-parse
sub-messages (the ADV identity in pairing, for instance) were not, so a
`std::length_error` raised there propagated out of `main` and called
`std::terminate`.
Either fault alone is survivable. Together they turn one malformed frame into a
process abort. This is the classic C++ failure: an arithmetic overflow in a
size computation silently defeats a bounds check, and an uncaught exception is
not a logged error but a dead process.
### 13.3 The fix (commit f16c5c6)
- **Overflow-safe length checks everywhere bytes are sliced.** The comparison is
now `len > size - pos` (never `pos + len > size`). Because `pos <= size` is an
invariant at every call site, the subtraction cannot wrap, so no attacker
value can slip through. `src/whatsapp/wa_proto.cpp` (`ProtoReader::next`) and
`src/whatsapp/wa_binary.cpp` (`Decoder::need`).
- **Lengths are read unsigned and range-checked.** The binary decoder reads
4-byte lengths into a `uint32_t` (never a sign-extendable `int`), rejects any
value larger than the whole frame, and `read_bytes` rejects negatives. A bad
length now raises `WireError`, a `std::exception` we catch, long before any
allocation.
- **Dispatch can no longer abort the process.** `WaClient::dispatch` wraps all
node routing in `try/catch(const std::exception&)`; a bad node is logged
(`dropped node <tag>: <reason>`) and the client keeps running.
### 13.4 The standing rule (project principle)
The goal is to make C++ safe to use here, which means a hostile or buggy peer
can waste our time but can never crash us or corrupt us. Three invariants, in
order of importance:
1. **No size arithmetic that can overflow a bounds check.** Never write
`pos + len <= size`. Write `len <= size - pos` against an established
`pos <= size`. Read lengths into an unsigned type wide enough to hold them,
and range-check before you allocate or slice. Treat every length, count and
index off the wire as adversarial.
2. **No unchecked allocation from an attacker-controlled count.** A length field
is a claim, not a fact. Validate it against the bytes actually present before
`reserve`, `resize`, `assign`, or a range constructor. A caller must never be
able to make us ask for a 4 GB vector.
3. **No exception may escape a top-level loop.** Any loop that reads and
dispatches untrusted input (`recv_node`, `dispatch`, the send and pairing
waits) wraps the per-item work in `try/catch(const std::exception&)` and
turns a failure into a logged, skipped item. An uncaught `std::exception` in
this module is a bug even if the parse that raised it was "impossible".
These apply to any code that reads untrusted bytes, not just WhatsApp. When a
new wire format is added (CAP, MCP, a new media type), it inherits this rule.
### 13.5 How we stop the regression
Prose does not prevent regressions; a failing test does. `tests/whatsapp_test.cpp`
has a `test_hostile` section (run by `build/whatsapp_test`, step `whatsapp.unit`
in `tests/run_all.py`, and `make test`) that feeds the parsers exactly the shapes
that used to crash and asserts they throw a *handled* exception and specifically
**not** `std::length_error`:
- a binary node whose content length claims 0xFFFFFFFF in an 11-byte buffer;
- a protobuf field whose length varint is ~4 billion with no payload;
- truncated and short frames;
- 2000 random byte strings through both `WaBinary` and `ProtoReader` (surviving
the loop is the proof: a single unchecked allocation would abort the run).
Each case `catch`es `std::length_error` and fails the test if it is thrown, so
if anyone reintroduces a `pos + len` check or an unguarded slice, this test goes
red. If you add a new parser, add its hostile cases here in the same pass; a
parser with no adversarial test is treated as unfinished.
### 13.6 Follow-up: the first fix was not enough, and how we proved it
Section 13.3's fix stopped the abort but was declared "verified" on a
hostile-input test that never exercised the live decode path. That was
overconfident: a later run still logged `dropped node <iq>: cannot create
std::vector larger than max_size()`. The crash was gone (the `try/catch` held),
but a real frame was still being mis-handled and dropped.
The cure was to stop reasoning and use a tool. Building the parsers under
AddressSanitizer + UndefinedBehaviorSanitizer + `_GLIBCXX_ASSERTIONS`
([`../tools/san-whatsapp.sh`](../tools/san-whatsapp.sh)) immediately found a
second, independent undefined-behavior bug that two manual reviews had missed:
a protobuf varint loop that shifted by up to 70 bits (shift >= 64 is UB) on
malformed input. It is now fixed and guarded by a deterministic test case and
the sanitizer net. Two more defenses were added:
- **Frame capture.** `WaClient` records the exact bytes of any frame that fails
to decode or dispatch to `<home>/whatsapp/last-error.log` (bounded). The next
time the still-unreproduced live `<iq>` failure happens, the precise input is
on disk and becomes a one-paste regression test and a deterministic fix.
- **The sanitizer net is now the standing requirement**, not a one-off. See the
project-wide playbook, [cpp-safety.md](cpp-safety.md), which this incident
produced: overflow-safe checked-reader parsing, cheap always-on release
hardening, the ASan+UBSan test net, and capture.
The honest open item: the exact frame the user hit is still unreproduced
in-session (the server did not resend it within a 160 s sanitizer run). The
capture hook exists precisely so that gap closes on its next occurrence rather
than by more guessing.
### 13.7 Root cause, found and fixed (the capture hook paid off)
The capture hook from 13.6 did its job: the next `--whatsapp-link` run recorded
the exact 726-byte frame to `<home>/whatsapp/last-error.log`, and replaying it
made the bug deterministic in minutes. The verdict overturns every earlier
guess: **it was never a parser bug.** The frame decoded perfectly (it was a
normal `pair-success` with a `device-identity`, `device`, `biz`, and
`encryption-metadata`). The `std::vector larger than max_size()` was thrown by
`WaDevice::serialize()`, in `WaClient::handle_pair_success` saving the newly
paired device:
```cpp
put_blob(b, Bytes((jid ? *jid : "").begin(), (jid ? *jid : "").end()));
```
`(jid ? *jid : "")` builds a **fresh temporary `std::string` for each
evaluation**, so `.begin()` and `.end()` point into two different, already
destroyed objects. The range constructor computed a garbage length and the
vector threw. It only misfired once `jid` was set, which is why it appeared
only after the phone scanned, and why the initial unpaired save and every
parser test passed. The fix binds the value to a named `const std::string`
before taking iterators. Verified by replaying the captured frame end to end
(serialize, save, encode the response: no throw) and by a paired-device
serialize round-trip test (`tests/whatsapp_test.cpp` `test_device`) that runs
under the sanitizer net.
The lesson is in [cpp-safety.md](cpp-safety.md): this was a C++ object-lifetime
bug, a different class from the earlier size-overflow, and it slipped past the
parser-focused tests and the first sanitizer runs because those never exercised
`serialize()` with a set optional. Two defenses were strengthened as a result:
the capture hook (which turned an unreproducible failure into a deterministic
one), and the rule that a round-trip test must cover the populated case, not
just the empty one. The honest open item from 13.6 is now closed.
## 14. App-state sync: the contact list (`WaAppState`, `WaReceiver`)
Status: implemented and verified offline. The live path needs a re-linked
phone: nothing here has been run against a phone that holds contacts.
Why: the phone's address book reaches a linked companion only through app-state
sync (collection `critical_unblock_low`, index `["contact", "<jid>"]`). Without
it `wa_contact` stays empty and names cannot be resolved.
| Piece | Files | What it does |
|---|---|---|
| Key receipt | `wa_receiver.*`, `wa_proto.*` (`parse_protocol_message`), `WaClient::handle_message` | Decrypts `<enc>` from our own account (pkmsg via X3DH responder, msg via session), unpads, stores each APP_STATE_SYNC_KEY_SHARE key in `wa_appstate_key`. Only the phone's own messages are decrypted; other senders are acked and ignored as before. |
| Key request | `WaClient::request_app_state_keys`, `send_peer_message`, `encode_app_state_key_request` | Peer message to the primary phone (category `peer`), at most `kWaAppStateKeyIdsMax` ids, one request per key id per `kWaAppStateKeyRequestInterval` (24 h), stored in `wa_appstate_keyreq`. |
| Key expansion, MACs, values | `wa_appstate.*` (`WaAppStateKeys`, `WaAppStateCrypto`) | HKDF-SHA256 `WhatsApp Mutation Keys` to 160 bytes; value MAC HMAC-SHA512 truncated to 32; index, snapshot and patch MACs HMAC-SHA256; AES-256-CBC values. `WaCrypto::hmac_sha512` added. |
| LTHash | `wa_appstate.*` (`WaLtHash`) | 128-byte state, HKDF-SHA256 info `WhatsApp Patch Integrity` (whatsmeow, not BLAKE2b; see research conclusion `appstate-lthash-hkdf-not-blake2b`). |
| Patch fetch and apply | `WaAppStateSync::sync` | iq `w:sync:app:state`, `return_snapshot` at version 0, `version=N` otherwise; external blobs via `WaMedia::download` (`md-app-state`); snapshot MAC, patch MAC, index and value MACs checked before anything is stored. |
| Contacts | `WaAppStateSync::apply_contact` | SET stores jid = index[1], name = fullName else firstName; empty name is counted and skipped; REMOVE deletes the row. |
| Login hook | `WaClient::login` calls `sync_app_state` after prekeys | Never fails the login (errors are logged). If a key is missing, requests it, waits up to `kWaAppStateKeyWait` s for a key share, then syncs once more. |
Caps (`wa_consts.h`): `kWaAppStatePatchMax` 50 patches per sync,
`kWaAppStateMutationMax` 10000 mutations, `kWaAppStateTimeout` 30 s per iq,
`kWaAppStateBlobMax` 16 MiB per external blob, `kWaAppStateKeyIdsMax` 16 key
ids per request, `kWaAppStateKeyWait` 20 s. A cap stops the sync loudly
(`capped` in the report, a log line) and the rest waits for the next login.
Failure rule: a patch or snapshot that fails a MAC or hash check is not stored,
the sync stops, and the collection version stays at the last verified state.
This skips the bad patch and everything after it, because each later patch
depends on the hash chain.
Tables: `wa_appstate_key`, `wa_appstate_version`, `wa_appstate_mac` (value MAC
per index MAC, needed to undo a SET or REMOVE), `wa_appstate_keyreq`. `clear_device`
empties them, so a new link syncs from version 0.
Verification (offline, `build/whatsapp_test`, section `appstate`):
- expanded keys equal whatsmeow `expandAppStateKeys` bytes;
- LTHash add/remove cancel and are order-free;
- a snapshot plus one patch (SET, REMOVE, in-patch replacement, unnamed SET, a
non-contact SET) decodes to the oracle's final hash and version, and the
contact table matches the oracle's expected names;
- a re-sync at version 2 changes nothing;
- a tampered patch is rejected and not stored;
- a missing key stores nothing and reports the key id;
- the key share and key request bytes match whatsmeow's Message encoding;
- a pkmsg built with `WaSession` decrypts through `WaReceiver` to the key share
Message, and a corrupted pkmsg is refused.
The oracle is `tests/whatsapp_data/appstate_vectors.json`, generated by
`tests/whatsapp_data/appstate_oracle.go` (a Go program using whatsmeow's own
`lthash`, `hkdfutil` and protobuf packages). It is not run by `make test`.
Not verified: the live server's answers to the iq, key share timing, the media
host for external blobs, and whether a REMOVE of an unknown index (whatsmeow
only warns) causes trouble. Known gap: a SET with an empty name keeps any older
name the row had; the spec says skip, not delete.
## 15. Incident WA-2026-10-27b: link ended in 401, no picture, no contacts
Everything here was measured on 2026-10-27/28 against the live server and the user's phone.
| Symptom | Root cause | Fix | Evidence |
|---|---|---|---|
| `Linked, but the login confirmation did not complete`; later login `failure 401` | The decoder printed a server-only JID (empty user) as `@s.whatsapp.net`; the pairing replies copied that into `to`, so the server refused `pair-device-sign` and never registered the device | decoder returns the bare server name; encoder writes bare server names as JID pairs; replies use `WaConsts::kServerJid` as whatsmeow does | captured `pair-success` decoded (`from=@s.whatsapp.net`), live login probe 401 before, `logged in` after re-link; test `server-only JID round-trips` |
| wizard never reported the real login result | `run_until` returned at once because `paired_event_` stayed set | `run_until` is stateless; `WaClient::await_pair_restart` + `WaClient::login`; one fresh pairing after a 401/403 (`kWaLinkRetries`) | wizard source |
| `user 7m` CPU while linking | a closed socket made every receive return instantly and the loops spun until their deadline | `WaSocket` clears `connected_` on close, every loop checks `WaClient::socket_open()` | probe after the 401: 1.7 s, 0.01 s CPU (was 20 s of spinning) |
| PNG could not be sent | the `whatsapp_send` tool had no image argument and `send_image` was a stub | `WaMedia` (HKDF `WhatsApp Image Keys`, AES-CBC, 10-byte HMAC, `media_conn`, HTTPS upload), `send_image`, DeviceSentMessage copy to our other devices, tool argument `image` | 8/8 wa-go golden vectors byte-exact; live: image to own number acknowledged by the server |
| Nigel not found | the phone address book reaches a companion only through app-state sync, which did not exist; and the phone only pushes history and keys to a companion that declares `DeviceProps.historySyncConfig`, which registration omitted | section 14 (`WaAppState`, `WaReceiver`); `WaDeviceProps` now sends `historySyncConfig` (field 5, whatsmeow values); peer key request encrypted to the phone's LID identity | unit tests; the live address book needs a link made with this build |
| name lookups were exact or one fuzzy guess | contact table only, one `FuzzyMatcher::Best` | `WaStore::search_contacts`: FTS5 trigram candidates + fuzzy rank; ambiguity (two scores within 0.08) is reported, never guessed; the global `search` tool lists matching contacts | tests `contact search: ...` |
| no send limits | caps were specified, not enforced | `wa_send_log`, `kWaSendPerMinute` 10 and `kWaSendPerDay` 200 enforced in `whatsapp_send` | test `send log counts sends in the window` |
Gaps: the contact sync and the address-book key share are not verified against a phone yet; a newly linked device
must be created with this build for the phone to send history. Set `DETZ_WA_TRACE=1` to log every node's tag and
attributes when debugging a protocol exchange.