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:
2026-10-27 21:32:38 +01:00
co-authored by Claude Sonnet 5.5
parent bfc117b0c5
commit 1611f3baeb
50 changed files with 3350 additions and 120 deletions
+300
View File
@@ -4,8 +4,13 @@
// checks of the wire codec, crypto, Noise handshake and Signal session. The
// live pairing/send paths need a phone and are not covered here. See
// docs/whatsapp.md.
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <string>
#include "json.h"
@@ -15,6 +20,10 @@
#include "wa_noise.h"
#include "wa_proto.h"
#include "wa_signal.h"
#include "wa_appstate.h"
#include "wa_media.h"
#include "wa_receiver.h"
#include "wa_store.h"
using namespace detz::wa;
using detz::json::Value;
@@ -362,15 +371,306 @@ static void test_device() {
check(ub && !ub->paired(), "unpaired device serialize/deserialize round-trip");
}
// Regression (docs/whatsapp.md section 14): a server-only JID decoded to "@s.whatsapp.net", and the
// pairing reply copied it as `to`, so the server refused pair-device-sign and login ended in 401.
static void test_server_jid() {
WaNode n("iq");
n.attrs["to"] = "s.whatsapp.net";
n.attrs["from"] = "31630045827@s.whatsapp.net";
Bytes wire{0}; // 1-byte flags header (0 = uncompressed), as WaClient::send_node frames it
Bytes body = WaBinary::encode(n);
wire.insert(wire.end(), body.begin(), body.end());
WaNode back = WaBinary::decode(WaBinary::unpack(wire));
check(back.attr("to") == "s.whatsapp.net", "server-only JID round-trips as s.whatsapp.net");
check(back.attr("from") == "31630045827@s.whatsapp.net", "user JID round-trips unchanged");
}
// WaMedia against wa-go's golden vectors (Baileys-generated, MIT): every IV, derived key,
// ciphertext+MAC and both hashes must match byte for byte.
static void test_media() {
Value d = Value::loads(slurp(path("media_vectors.json")));
Bytes media_key = fromhex(d.get("mediaKeyHex").str());
int ok = 0, total = 0;
for (const auto& c : d.get("cases").items()) {
const Value& pt = d.get("plaintexts").get(c.get("plaintext").str());
Bytes plain = fromhex(pt.get("plaintextHex").str());
std::string info = c.get("info").str();
WaMedia::Keys k = WaMedia::expand(media_key, info);
WaMedia::Encrypted e = WaMedia::encrypt(plain, info, media_key);
bool keys_ok = tohex(k.iv) == c.get("iv").str() && tohex(k.cipher_key) == c.get("cipherKey").str() &&
tohex(k.mac_key) == c.get("macKey").str() && tohex(k.ref_key) == c.get("refKey").str();
bool blob_ok = tohex(e.data_to_upload) == c.get("ciphertextHex").str();
bool hash_ok = tohex(e.file_sha256) == c.get("fileSha256").str() && tohex(e.file_enc_sha256) == c.get("fileEncSha256").str();
bool len_ok = e.file_length == plain.size();
++total;
if (keys_ok && blob_ok && hash_ok && len_ok) ++ok;
else std::printf(" mismatch: %s %s keys=%d blob=%d hash=%d len=%d\n", c.get("plaintext").str().c_str(), info.c_str(), keys_ok, blob_ok, hash_ok, len_ok);
}
check(total > 0 && ok == total, "media encrypt matches wa-go golden vectors (" + std::to_string(ok) + "/" + std::to_string(total) + ")");
check(WaMedia::token(WaCrypto::sha256(Bytes{'a'})).size() == 44, "upload token is base64url with padding (44 chars for 32 bytes)");
}
// ---- app-state sync (docs/whatsapp.md section 14) ----
// Oracle: tests/whatsapp_data/appstate_vectors.json, generated by appstate_oracle.go with whatsmeow's own
// lthash, hkdfutil and protobuf packages. Nothing here is produced by the code under test.
static std::string make_temp_home() {
std::string tmpl = "/tmp/wa_test_XXXXXX";
char* d = ::mkdtemp(tmpl.data());
return d ? std::string(d) : std::string();
}
static void test_lthash_and_keys() {
std::printf("[appstate: keys and LTHash]\n");
Value v = Value::loads(slurp(path("appstate_vectors.json")));
Bytes key_data = fromhex(v.get("key_data").str());
WaAppStateKeys k = WaAppStateKeys::expand(key_data);
std::string want = v.get("expanded_keys").str();
std::string got = tohex(k.index) + tohex(k.value_encryption) + tohex(k.value_mac) + tohex(k.snapshot_mac) + tohex(k.patch_mac);
check(got == want, "expanded app-state keys match whatsmeow expandAppStateKeys (160 bytes)");
// LTHash: add then remove is the identity; the order of items does not matter.
Bytes a(32, 1), b(32, 2), c(32, 3);
Bytes st = WaLtHash::zero();
WaLtHash::subtract_then_add(st, {}, {a, b});
WaLtHash::subtract_then_add(st, {a}, {c});
Bytes st2 = WaLtHash::zero();
WaLtHash::subtract_then_add(st2, {}, {c});
WaLtHash::subtract_then_add(st2, {}, {b});
check(st == st2, "LTHash: add a,b; remove a, add c equals add c; remove b (order-free, add/remove cancel)");
Bytes st3 = WaLtHash::zero();
WaLtHash::subtract_then_add(st3, {}, {a});
WaLtHash::subtract_then_add(st3, {a}, {});
check(st3 == WaLtHash::zero(), "LTHash: add then remove returns the zero state");
}
// Fake collection server: serves one snapshot and the given patches, and answers the external blob download.
struct FakeCollection {
Bytes snapshot;
std::vector<Bytes> patches;
int iq_calls = 0;
WaNode operator()(WaNode iq) {
(void)iq;
WaNode col("collection");
col.attrs["name"] = WaAppStateSync::kContactsCollection;
WaNode::Children kids;
if (iq_calls == 0) {
WaNode snap("snapshot");
snap.content = Bytes{0x0a, 0x00}; // stands in for an ExternalBlobReference; the download hook returns the body
kids.push_back(snap);
}
WaNode patches_node("patches");
WaNode::Children pk;
for (const auto& p : patches) {
WaNode pn("patch");
pn.content = p;
pk.push_back(pn);
}
patches_node.content = pk;
kids.push_back(patches_node);
col.content = kids;
++iq_calls;
WaNode sync("sync");
sync.content = WaNode::Children{col};
WaNode reply("iq");
reply.content = WaNode::Children{sync};
return reply;
}
};
static WaAppStateSync::DownloadFn snapshot_download(const Bytes& snapshot) {
return [snapshot](const WaExternalBlobRef&, std::string&) -> std::optional<Bytes> { return snapshot; };
}
static void test_appstate_sync() {
std::printf("[appstate: snapshot and patch sync]\n");
Value v = Value::loads(slurp(path("appstate_vectors.json")));
Bytes key_id = fromhex(v.get("key_id").str());
Bytes key_data = fromhex(v.get("key_data").str());
Bytes snapshot = fromhex(v.get("snapshot").str());
Bytes patch = fromhex(v.get("patch").str());
Bytes tampered = fromhex(v.get("patch_tampered").str());
std::string name = WaAppStateSync::kContactsCollection;
std::vector<std::string> logs;
auto log = [&](const std::string& s) { logs.push_back(s); };
// 1. good chain: snapshot then one patch
{
std::string home = make_temp_home();
WaStore store(home, "sync");
store.put_app_key(key_id, key_data, 1767225600123);
FakeCollection col;
col.patches = {patch};
WaAppStateSync sync(store, log);
auto rep = sync.sync(name, col, snapshot_download(snapshot));
check(rep.error.empty() && rep.rejected == 0, "good snapshot+patch chain verifies (snapshot MAC, patch MAC, value and index MACs)");
check(rep.patches_applied == 1 && rep.mutations_applied == 10, "applied 1 patch with 10 mutations (4 snapshot + 6 patch)");
check(rep.contacts_saved == 6 && rep.contacts_removed == 1 && rep.contacts_unnamed == 1,
"contacts: 6 saved (3 snapshot, 3 patch), 1 removed, 1 unnamed skipped");
auto contacts = store.contacts();
std::map<std::string, std::string> got(contacts.begin(), contacts.end());
std::map<std::string, std::string> want = {
{"31611111111@s.whatsapp.net", "Nigel Dokter (work)"},
{"31622222222@s.whatsapp.net", "Ann"},
{"31644444444@s.whatsapp.net", "Joop Visser"},
};
check(got == want, "wa_contact holds exactly the named contacts after snapshot + patch (C removed, unnamed F and the pushName setting not stored)");
auto ver = store.app_state_version(name);
check(ver && ver->version == 2 && tohex(ver->hash) == v.get("patch_hash_hex").str(),
"stored version 2 and LTHash state equal whatsmeow's state after the patch");
// a second sync from the stored version is a no-op on the server side
FakeCollection none;
none.iq_calls = 1; // the server sends no snapshot once the client is at version 2
auto rep2 = sync.sync(name, none, snapshot_download(snapshot));
check(rep2.error.empty() && rep2.patches_applied == 0 && rep2.version == 2, "a re-sync from version 2 with no new patches changes nothing");
std::filesystem::remove_all(home);
}
// 2. tampered patch: MAC mismatch, the patch is not stored, the snapshot before it is
{
std::string home = make_temp_home();
WaStore store(home, "tamper");
store.put_app_key(key_id, key_data, 1767225600123);
FakeCollection col;
col.patches = {tampered};
WaAppStateSync sync(store, log);
auto rep = sync.sync(name, col, snapshot_download(snapshot));
check(rep.rejected == 1 && rep.patches_applied == 0, "tampered patch is rejected (value MAC) and not applied");
auto ver = store.app_state_version(name);
check(ver && ver->version == 1, "after a rejected patch the stored version stays at the last verified state (1)");
std::filesystem::remove_all(home);
}
// 3. missing key: nothing is stored, the key id is reported for a key request
{
std::string home = make_temp_home();
WaStore store(home, "nokey");
FakeCollection col;
WaAppStateSync sync(store, log);
auto rep = sync.sync(name, col, snapshot_download(snapshot));
check(rep.missing_key_ids.size() == 1 && rep.missing_key_ids[0] == key_id && rep.contacts_saved == 0,
"missing app-state key: key id reported, no data stored");
check(!store.app_state_version(name).has_value(), "missing key leaves the collection version unset");
std::filesystem::remove_all(home);
}
// 4. key share message parses to the oracle key; key request encodes a parseable Message
{
Bytes ks = fromhex(v.get("key_share_message").str());
WaProtocolMessage pm = parse_protocol_message(ks);
check(pm.type == 6 && pm.key_share.size() == 1 && pm.key_share[0].key_id == key_id && pm.key_share[0].key_data == key_data &&
pm.key_share[0].timestamp == 1767225600123,
"APP_STATE_SYNC_KEY_SHARE parses: key id, 32-byte key data, timestamp (whatsmeow proto)");
Bytes kr = encode_app_state_key_request({key_id});
WaProtocolMessage req = parse_protocol_message(kr);
check(req.type == 7 && req.key_share.empty() && tohex(kr) == v.get("key_request_message").str(),
"APP_STATE_SYNC_KEY_REQUEST encodes byte-exact to whatsmeow's Message");
}
}
static void test_receiver() {
std::printf("[appstate: key share receipt]\n");
// Alice (the phone) starts a session with our device with X3DH and sends the key share as pkmsg.
WaDevice bob = WaDevice::generate();
WaCrypto::KeyPair alice_id = WaCrypto::generate_x25519();
WaCrypto::KeyPair alice_base = WaCrypto::generate_x25519();
WaCrypto::KeyPair one_time = WaCrypto::generate_x25519();
std::string home = make_temp_home();
WaStore store(home, "recv");
store.put_prekey(77, Bytes(one_time.priv.begin(), one_time.priv.end()));
WaSession::InitiatorParams p;
p.local_identity = alice_id;
p.local_base = alice_base;
p.remote_identity = signal_pub(bob.identity_key.pub);
p.remote_signed_pre = signal_pub(bob.signed_pre_key.pair.pub);
p.remote_pre_key = signal_pub(one_time.pub);
p.has_pre_key = true;
WaSession s = WaSession::initiate(p, WaCrypto::generate_x25519());
s.set_pending(1234, signal_pub(alice_base.pub), bob.signed_pre_key.key_id, true, 77);
Bytes plain = fromhex(Value::loads(slurp(path("appstate_vectors.json"))).get("key_share_message").str());
CiphertextMessage ct = s.encrypt(WaCrypto::pkcs7_pad(plain));
WaNode enc("enc");
enc.attrs["v"] = "2";
enc.attrs["type"] = ct.type;
enc.content = ct.serialized;
WaReceiver rx(store);
auto res = rx.decrypt(enc, "15550001111@s.whatsapp.net", bob);
check(ct.type == "pkmsg" && res.ok && res.message == plain, "pkmsg from the phone decrypts to the key-share Message (X3DH responder + unpad)");
check(store.load_session("15550001111.0").has_value(), "the new session is stored under the phone's signal address");
Bytes bad = ct.serialized;
bad.back() ^= 0x01;
WaNode enc_bad("enc");
enc_bad.attrs["type"] = ct.type;
enc_bad.content = bad;
auto corrupt = rx.decrypt(enc_bad, "15550001111@s.whatsapp.net", bob);
check(!corrupt.ok, "a corrupted pkmsg is refused with a reason, not decrypted");
std::filesystem::remove_all(home);
}
// The phone only pushes history and app-state keys to a companion that declares historySyncConfig
// (DeviceProps field 5), so registration must carry it.
static void test_device_props() {
WaDeviceProps dp;
Bytes with = dp.encode();
dp.history_sync_config = false;
Bytes without = dp.encode();
check(with.size() > without.size() + 20, "DeviceProps carries historySyncConfig by default");
ProtoReader r(with);
ProtoReader::Field f;
bool found = false;
while (r.next(f)) found = found || (f.field == 5 && f.wire == 2);
check(found, "DeviceProps field 5 (historySyncConfig) is length-delimited");
}
// Contact search: the user's phone has "Nigel Dokter"; the user types "Nigel", "Niggel", "dokter" or the number.
static void test_contact_search() {
std::string home = "build/probe/wa_contact_test_home";
std::filesystem::remove_all(home);
WaStore store(home, "t");
store.put_contact("31612345001@s.whatsapp.net", "Nigel Dokter");
store.put_contact("31612345002@s.whatsapp.net", "Nina Smit");
store.put_contact("31612345003@s.whatsapp.net", "Peter de Vries");
auto top = [&](const std::string& q) {
auto h = store.search_contacts(q, 3, 0.3);
return h.empty() ? std::string("(none)") : h[0].name;
};
check(top("Nigel") == "Nigel Dokter", "contact search: first name");
check(top("Niggel") == "Nigel Dokter", "contact search: typo (Niggel -> Nigel Dokter)");
check(top("nigel dokter") == "Nigel Dokter", "contact search: full name, any case");
check(top("dokter") == "Nigel Dokter", "contact search: surname only");
check(top("12345003") == "Peter de Vries", "contact search: number fragment");
check(store.search_contacts("zzzzqq", 3, 0.3).empty(), "contact search: nonsense matches nothing");
store.remove_contact("31612345001@s.whatsapp.net");
check(top("Nigel") != "Nigel Dokter", "contact search: removed contact is gone from the index");
WaStore reopened(home, "t");
check(reopened.search_contacts("Nina", 3, 0.3).size() >= 1, "contact search: index survives reopening the database");
// send log: the floor under the rate caps
check(store.sends_since(60) == 0, "send log starts empty");
store.record_send("31612345002@s.whatsapp.net");
store.record_send("31612345002@s.whatsapp.net");
check(store.sends_since(60) == 2 && store.sends_since(86400) == 2, "send log counts sends in the window");
std::filesystem::remove_all(home);
}
int main(int argc, char** argv) {
DIR = argc > 1 ? argv[1] : "tests/whatsapp_data";
test_contact_search();
test_device_props();
test_crypto();
test_codec();
test_media();
test_server_jid();
test_client_payload();
test_noise();
test_signal();
test_device();
test_hostile();
test_lthash_and_keys();
test_appstate_sync();
test_receiver();
std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES");
return g_fail == 0 ? 0 : 1;
}