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>
This commit is contained in:
+90
-1
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
@@ -169,7 +181,7 @@ pair, as the rest of taicpp.
|
||||
| `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),
|
||||
app-state sync (LTHash contact list), history sync, calls, status, newsletters,
|
||||
history sync, calls, status, newsletters,
|
||||
receiving media, editing, reactions.
|
||||
|
||||
## 5. Protocol walkthrough and crypto mapping
|
||||
@@ -557,3 +569,80 @@ parser-focused tests and the first sanitizer runs because those never exercised
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user