// whatsapp_test.cpp: validates detz's native WhatsApp implementation against // golden vectors captured from the real Baileys client (tests/whatsapp_data, // copied from jfelipesjc/wa-go, MIT). No network; these are offline byte-exact // 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 #include #include #include #include #include #include #include "json.h" #include "wa_binary.h" #include "wa_cert.h" #include "wa_client.h" #include "wa_consts.h" #include "wa_crypto.h" #include "wa_device.h" #include "wa_iq.h" #include "wa_keepalive.h" #include "wa_noise.h" #include "wa_proto.h" #include "wa_signal.h" #include "wa_appstate.h" #include "wa_consts.h" #include "wa_jid.h" #include "wa_lidmap.h" #include "wa_media.h" #include "wa_receiver.h" #include "wa_store.h" #include "wa_tokens_data.h" #include "wa_vcf.h" #include "sqldb.h" using namespace detz::wa; using detz::json::Value; static int g_fail = 0; static void check(bool ok, const std::string& name) { std::printf(" %s %s\n", ok ? "ok " : "FAIL", name.c_str()); if (!ok) g_fail++; } static Bytes fromhex(const std::string& h) { Bytes b; for (size_t i = 0; i + 1 < h.size(); i += 2) b.push_back((uint8_t)std::stoi(h.substr(i, 2), nullptr, 16)); return b; } static std::string tohex(const Bytes& b) { std::string s; char t[3]; for (auto c : b) { std::snprintf(t, 3, "%02x", c); s += t; } return s; } static Bytes b64(const std::string& in) { static const std::string T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; Bytes out; int val = 0, bits = -8; for (char c : in) { if (c == '=') break; auto p = T.find(c); if (p == std::string::npos) continue; val = (val << 6) | (int)p; bits += 6; if (bits >= 0) { out.push_back((uint8_t)((val >> bits) & 0xff)); bits -= 8; } } return out; } static std::string slurp(const std::string& p) { std::ifstream f(p); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } static std::array a32(const Bytes& b) { std::array a{}; for (size_t i = 0; i < 32 && i < b.size(); ++i) a[i] = b[i]; return a; } static SignalKey sk33(const Bytes& b) { SignalKey k{}; for (size_t i = 0; i < 33 && i < b.size(); ++i) k[i] = b[i]; return k; } static WaCrypto::KeyPair kpair(const Value& o) { WaCrypto::KeyPair k; k.priv = a32(fromhex(o.get("priv").str())); Bytes pb = fromhex(o.get("pub").str()); k.pub = a32(Bytes(pb.begin() + (pb.size() == 33 ? 1 : 0), pb.end())); return k; } static const char* DIR = nullptr; static std::string path(const std::string& f) { return std::string(DIR) + "/" + f; } // ---- crypto (RFC vectors + XEdDSA) ---- static void test_crypto() { std::printf("[crypto]\n"); Bytes ikm(22, 0x0b), salt; for (int i = 0; i < 13; i++) salt.push_back(i); Bytes info; for (int i = 0xf0; i <= 0xf9; i++) info.push_back(i); check(tohex(WaCrypto::hkdf_sha256(ikm, salt, info, 42)) == "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", "HKDF-SHA256 RFC 5869 case 1"); check(tohex(WaCrypto::pbkdf2_sha256(Bytes{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'}, Bytes{'s', 'a', 'l', 't'}, 1, 32)) == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b", "PBKDF2-SHA256"); auto a = WaCrypto::generate_x25519(), b = WaCrypto::generate_x25519(); check(WaCrypto::x25519(a.priv, b.pub) == WaCrypto::x25519(b.priv, a.pub), "X25519 DH agreement"); bool xok = true; for (int i = 0; i < 64; ++i) { auto kp = WaCrypto::generate_x25519(); Bytes m = WaCrypto::random_bytes(1 + i); auto sig = WaCrypto::xeddsa_sign(kp.priv, m); if (!WaCrypto::xeddsa_verify(kp.pub, m, sig)) xok = false; sig[i % 64] ^= 1; if (WaCrypto::xeddsa_verify(kp.pub, m, sig)) xok = false; } check(xok, "XEdDSA sign/verify roundtrip + tamper (64 keys)"); } // ---- binary codec ---- static void test_codec() { std::printf("[codec]\n"); std::ifstream f(path("codec_battery.jsonl")); std::string line; int n = 0, ok = 0; std::function build = [&](const Value& t) -> WaNode { WaNode node(t.get("tag").str()); for (const auto& m : t.get("attrs").members()) node.attrs[m.first] = m.second.str(); const Value& c = t.get("content"); if (c.is_string()) node.content = fromhex(c.str()); else if (c.is_array()) { WaNode::Children ch; for (const auto& e : c.items()) ch.push_back(build(e)); node.content = ch; } return node; }; std::function eq = [&](const WaNode& x, const WaNode& y) -> bool { if (x.tag != y.tag || x.attrs != y.attrs || x.content.index() != y.content.index()) return false; if (auto* a = x.bytes()) return *a == *y.bytes(); if (auto* a = x.children()) { auto* b = y.children(); if (a->size() != b->size()) return false; for (size_t i = 0; i < a->size(); ++i) if (!eq((*a)[i], (*b)[i])) return false; } return true; }; while (std::getline(f, line)) { if (line.empty()) continue; ++n; Value v = Value::loads(line); WaNode want = build(v.get("decoded_tree")); WaNode got = WaBinary::decode(WaBinary::unpack(fromhex(v.get("encoded_hex").str()))); Bytes re = WaBinary::encode(got); Bytes framed; framed.push_back(0); framed.insert(framed.end(), re.begin(), re.end()); WaNode got2 = WaBinary::decode(WaBinary::unpack(framed)); if (eq(got, want) && eq(got2, want)) ++ok; } check(ok == n && n == 19, "codec battery " + std::to_string(ok) + "/" + std::to_string(n) + " (decode + re-encode)"); } // ---- ClientPayload byte-exact ---- static void test_client_payload() { std::printf("[proto]\n"); Value d = Value::loads(slurp(path("client_payload.json"))); const Value& fl = d.get("fields"); const Value& ua = fl.get("userAgent"); const Value& dp = fl.get("devicePairingData"); WaClientPayload p; p.has_passive = true; p.has_user_agent = true; auto& u = p.user_agent; u.app_version.has = true; u.app_version.primary = ua.get("appVersion").get("primary").as_int(); u.app_version.secondary = ua.get("appVersion").get("secondary").as_int(); u.app_version.tertiary = ua.get("appVersion").get("tertiary").as_int(); p.has_web_info = true; p.has_connect = true; WaDevicePairingData pd; pd.e_regid = b64(dp.get("eRegid").str()); pd.e_keytype = b64(dp.get("eKeytype").str()); pd.e_ident = b64(dp.get("eIdent").str()); pd.e_skey_id = b64(dp.get("eSkeyId").str()); pd.e_skey_val = b64(dp.get("eSkeyVal").str()); pd.e_skey_sig = b64(dp.get("eSkeySig").str()); pd.build_hash = b64(dp.get("buildHash").str()); pd.device_props = b64(dp.get("deviceProps").str()); p.pairing = pd; p.has_pull = true; check(tohex(p.encode()) == d.get("payloadHex").str(), "ClientPayload byte-exact (registration)"); } // ---- Noise handshake ---- static void test_noise() { std::printf("[noise]\n"); Value d = Value::loads(slurp(path("noise.json"))); auto ephPriv = a32(fromhex(d.get("ephemeralKeyPriv").str())); auto ephPub = a32(fromhex(d.get("ephemeralKeyPub").str())); auto stPriv = a32(fromhex(d.get("authStaticKeyPriv").str())); auto stPub = a32(fromhex(d.get("authStaticKeyPub").str())); Bytes shFrame = fromhex(d.get("serverHelloFrameHex").str()); size_t pos = 0; auto sh = WaFraming::unframe(shFrame, pos); WaNoise noise(ephPub); auto res = noise.client_handshake(ephPriv, ephPub, stPriv, stPub, *sh, Bytes{1, 2, 3, 4}); check(res.has_value(), "handshake completed (server static+payload decrypted)"); if (res) { check(tohex(WaFraming::frame(res->client_hello, true)) == d.get("clientHelloFrameHex").str(), "clientHello frame byte-exact"); check(res->server_static.size() == 32, "server static key is 32 bytes"); check(parse_cert_chain(res->server_payload).has_value(), "server payload parses as cert chain"); check(noise.in_transport(), "transport keys derived"); } } // ---- Signal session ---- static void test_signal() { std::printf("[signal]\n"); Value d = Value::loads(slurp(path("session_ab.json"))); auto& al = d.get("alice"); auto& bo = d.get("bob"); auto& eph = d.get("ephemeralsGenerated"); WaSession::InitiatorParams p; p.local_identity = kpair(al.get("identityKeyPair")); p.local_base = kpair(al.get("baseKey")); p.remote_identity = sk33(fromhex(bo.get("identityKeyPair").get("pub").str())); p.remote_signed_pre = sk33(fromhex(bo.get("signedPreKey").get("keyPair").get("pub").str())); p.remote_pre_key = sk33(fromhex(bo.get("preKey").get("keyPair").get("pub").str())); p.has_pre_key = true; WaSession s = WaSession::initiate(p, kpair(eph.at(1))); s.set_pending(al.get("registrationId").as_int(), signal_pub(p.local_base.pub), bo.get("signedPreKey").get("id").as_int(), true, bo.get("preKey").get("id").as_int()); WaCrypto::KeyPair nextR = kpair(eph.at(3)); s.set_ratchet_generator([&]() { return nextR; }); const Value& ex = d.get("exchanges"); auto pt = [&](int i) { std::string t = ex.at(i).get("plaintext").str(); return Bytes(t.begin(), t.end()); }; auto want = [&](int i) { return ex.at(i).get("ciphertextHex").str(); }; auto o1 = s.encrypt(pt(0)); check(o1.type == "pkmsg" && tohex(o1.serialized) == want(0), "exchange #1 pkmsg byte-exact"); auto p2 = s.decrypt(fromhex(want(1))); check(p2 && std::string(p2->begin(), p2->end()) == ex.at(1).get("plaintext").str(), "exchange #2 decrypt"); auto o3 = s.encrypt(pt(2)); check(o3.type == "msg" && tohex(o3.serialized) == want(2), "exchange #3 msg byte-exact"); auto o4 = s.encrypt(pt(3)); check(o4.type == "msg" && tohex(o4.serialized) == want(3), "exchange #4 msg byte-exact"); // session survives a serialize/deserialize round trip Bytes blob = s.serialize(); check(WaSession::deserialize(blob).has_value(), "session serialize/deserialize"); } // ---- hostile / malformed input (regression guard, incident WA-2026-10-27) ---- // Every parser of bytes off the wire must reject malformed input by throwing a // std::exception (caught upstream), never by an unchecked huge allocation that // aborts the process. A length field that overflows the bounds check used to // reach vector::assign and throw std::length_error, which aborted the client. // These cases must keep throwing a *handled* exception, never std::length_error // and never crash. See docs/whatsapp.md section 13. static void test_hostile() { std::printf("[hostile input]\n"); bool ok = true; // 1. binary node whose content length claims 0xFFFFFFFF in a tiny buffer. // flag(0) | LIST_8 size 2 | tag BINARY_8 "x" | content BINARY_32 0xFFFFFFFF Bytes huge_len = {0x00, 0xf8, 0x02, 0xfc, 0x01, 'x', 0xfe, 0xff, 0xff, 0xff, 0xff}; try { WaBinary::decode(WaBinary::unpack(huge_len)); ok = false; // must not succeed } catch (const std::length_error&) { ok = false; // the exact regression: an unchecked allocation } catch (const std::exception&) { // correct: a handled WireError / bounds error } check(ok, "binary decode rejects a 4GB length field without length_error"); // 2. protobuf field 1, wire 2, length varint ~4 billion, no payload. Bytes huge_pb = {0x0a, 0xff, 0xff, 0xff, 0xff, 0x0f}; ok = true; try { ProtoReader r(huge_pb); ProtoReader::Field f; while (r.next(f)) { } ok = false; } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } check(ok, "proto reader rejects an oversized length without length_error"); // 2b. an overlong varint (continuation bytes past 64 bits) must throw, not // shift-overflow. This is undefined behavior the sanitizer net caught. Bytes overlong = {0x08}; // field 1, wire 0 for (int i = 0; i < 12; ++i) overlong.push_back(0x80); // 12 continuation bytes overlong.push_back(0x01); ok = true; try { ProtoReader r(overlong); ProtoReader::Field f; while (r.next(f)) { } ok = false; } catch (const std::exception&) { } check(ok, "proto reader rejects an overlong varint (no shift-overflow UB)"); // 3. negative/short-buffer node tags and truncations throw cleanly. ok = true; for (const Bytes& bad : std::vector{{}, {0x00}, {0x00, 0xf8}, {0x00, 0xfe, 0x7f, 0xff, 0xff, 0xff}, {0x00, 0xf8, 0x04, 0xfc, 0xff}}) { try { WaBinary::decode(WaBinary::unpack(bad)); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } } check(ok, "truncated/short frames throw cleanly, never length_error"); // 4. fuzz: random byte strings must each return or throw a std::exception, // never a length_error and never a crash (surviving the loop is the proof). ok = true; for (int i = 0; i < 2000; ++i) { Bytes r = WaCrypto::random_bytes(1 + (i % 64)); try { WaBinary::decode(WaBinary::unpack(r)); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { ProtoReader pr(r); ProtoReader::Field f; while (pr.next(f)) { } } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } } check(ok, "2000 random inputs: no length_error, no crash"); // 5. M1: frame reassembly with hostile cursor/length values yields "no // frame yet", never a crash and never a wraparound slice. ok = true; try { Bytes buf = {0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o'}; size_t pos = 99; // cursor past the end: caller bug, still handled if (WaFraming::unframe(buf, pos).has_value()) ok = false; pos = 6; // 2 bytes left, fewer than the 3-byte length prefix if (WaFraming::unframe(buf, pos).has_value() || pos != 6) ok = false; Bytes huge = {0xff, 0xff, 0xff}; // length 16M-1 claimed, 0 bytes present pos = 0; if (WaFraming::unframe(huge, pos).has_value() || pos != 0) ok = false; Bytes empty; pos = 0; if (WaFraming::unframe(empty, pos).has_value()) ok = false; pos = 0; // exact frame still parses after the hostile attempts auto fr = WaFraming::unframe(buf, pos); if (!fr || fr->size() != 5 || pos != 8) ok = false; } catch (const std::exception&) { ok = false; // unframe never throws; incomplete input is a routine negative } check(ok, "hostile frame cursors/lengths yield no frame, never crash"); } // ---- device serialize round-trip (regression for incident WA-2026-10-27) ---- // A paired device serializes its optional jid/lid. The bug was a two-temporaries // iterator range over `(jid ? *jid : "")` that only misfired once jid was set, // so this test must cover the PAIRED case, not just a fresh device. Under the // sanitizer net it also proves there is no lifetime UB in the path. static void test_device() { std::printf("[device]\n"); WaDevice d = WaDevice::generate(); d.jid = "15551230000:7@s.whatsapp.net"; // synthetic, not a real account d.lid = "100000000000000:7@lid"; d.push_name = "test"; d.account = WaCrypto::random_bytes(186); Bytes blob = d.serialize(); // used to abort here with length_error auto back = WaDevice::deserialize(blob); bool ok = back && back->jid == d.jid && back->lid == d.lid && back->push_name == d.push_name && back->account == d.account && back->registration_id == d.registration_id && back->jid_device() == 7 && back->jid_user() == 15551230000ULL; check(ok, "paired device serialize/deserialize round-trip (jid+lid set)"); // also the unpaired case (optionals empty), the other temporary branch WaDevice u = WaDevice::generate(); auto ub = WaDevice::deserialize(u.serialize()); 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 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 { 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 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 got(contacts.begin(), contacts.end()); std::map 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"); // unlink must survive the process: clear_device without a commit was rolled back at exit ("Already linked") { WaDevice d = WaDevice::generate(); d.jid = "31612345002:5@s.whatsapp.net"; store.save_device(d); check(store.has_device(), "a paired device is stored"); store.clear_device(); WaStore again(home, "t"); check(!again.has_device(), "clear_device persists across reopening the database"); check(again.search_contacts("Nina", 3, 0.3).size() >= 1, "clear_device keeps the contacts"); } // 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); } // Key-request bookkeeping (whatsmeow PR 1268): a request that never left the device must not block the // retry for 24 h, so the mark can be cleared again. static void test_key_request_rollback() { std::printf("[appstate: key request rollback]\n"); std::string home = make_temp_home(); WaStore store(home, "keyreq"); Bytes id{0x01, 0x02, 0x03}; check(!store.app_key_request_time(id).has_value(), "no request mark for a fresh key id"); store.put_app_key_request_time(id, 1700000000); auto got = store.app_key_request_time(id); check(got && *got == 1700000000, "request mark is stored"); store.clear_app_key_request_time(id); check(!store.app_key_request_time(id).has_value(), "clearing the mark frees the key id for an immediate retry"); store.clear_app_key_request_time(id); // clearing twice is a no-op, not an error check(!store.app_key_request_time(id).has_value(), "clearing an unmarked key id is harmless"); std::filesystem::remove_all(home); } // Peer message helpers: own-sender classification (PN or LID, whatsmeow IsFromMe), the peer node shape // (device-identity only on a prekey message), server_sync parsing and retry-receipt detection. static void test_peer_helpers() { std::printf("[appstate: peer message helpers]\n"); check(WaClient::is_own_sender("31630045827@s.whatsapp.net", "31630045827", "173735822786802"), "own PN sender is accepted"); check(WaClient::is_own_sender("173735822786802@lid", "31630045827", "173735822786802"), "own LID sender is accepted (the phone answers from its LID)"); check(WaClient::is_own_sender("31630045827:0@s.whatsapp.net", "31630045827", ""), "own sender with an explicit device 0 is accepted"); check(!WaClient::is_own_sender("31611111111@s.whatsapp.net", "31630045827", "173735822786802"), "another user is not an own sender"); check(!WaClient::is_own_sender("173735822786802@lid", "31630045827", ""), "an unknown LID is not accepted without a stored LID"); WaNode enc("enc"); enc.attrs["v"] = "2"; enc.attrs["type"] = "pkmsg"; enc.content = Bytes{1, 2, 3}; Bytes account{9, 9, 9}; WaNode pre = WaClient::build_peer_message_node("3EB0AA", "31630045827@s.whatsapp.net", enc, true, account); check(pre.tag == "message" && pre.attr("category") == "peer" && pre.attr("push_priority") == "high" && pre.attr("to") == "31630045827@s.whatsapp.net" && pre.child("meta") != nullptr && pre.child("enc") != nullptr && pre.child("device-identity") != nullptr, "peer node: meta + enc + device-identity on a prekey message"); WaNode msg = WaClient::build_peer_message_node("3EB0AB", "31630045827@s.whatsapp.net", enc, false, account); check(msg.child("device-identity") == nullptr && msg.child("enc") != nullptr, "peer node: no device-identity on an existing-session message"); WaNode notif("notification"); notif.attrs["type"] = "server_sync"; WaNode c1("collection"); c1.attrs["name"] = "critical_unblock_low"; WaNode c2("collection"); c2.attrs["name"] = "regular_high"; notif.content = WaNode::Children{c1, c2}; auto cols = WaClient::server_sync_collections(notif); check(cols.size() == 2 && cols[0] == "critical_unblock_low" && cols[1] == "regular_high", "server_sync notification yields its collection names"); WaNode retry("receipt"); retry.attrs["type"] = "retry"; WaNode read("receipt"); read.attrs["type"] = "read"; WaNode ack("ack"); check(WaClient::is_retry_receipt(retry) && !WaClient::is_retry_receipt(read) && !WaClient::is_retry_receipt(ack), "only a retry receipt reads as one"); } // Empty key shares (openclaw/wacli PR 443): the phone answers with keys that carry no key data. The share // still parses, so the client can tell "answered empty" from "never answered". static void test_empty_key_share() { std::printf("[appstate: empty key share]\n"); ProtoWriter key; { ProtoWriter keyid; keyid.bytes(1, Bytes{0x7c, 0x57}); key.message(1, keyid.data()); ProtoWriter data; // AppStateSyncKeyData without keyData: the phone has none data.varint(3, 1767225600); key.message(2, data.data()); } ProtoWriter share; share.message(1, key.data()); ProtoWriter pm; pm.varint(2, 6); // APP_STATE_SYNC_KEY_SHARE pm.message(7, share.data()); ProtoWriter m; m.message(12, pm.data()); WaProtocolMessage out = parse_protocol_message(m.take()); check(out.type == 6 && out.key_share.size() == 1 && out.key_share[0].key_data.empty(), "an empty key share parses with empty key data (answered, but nothing to store)"); } // Push-name learning: every incoming message carries the sender's self-reported name in `notify` // (whatsmeow message.go). A sender we have no name for is learned; saved, imported and aliased names win. static void test_push_name_learning() { std::printf("[contacts: push-name learning]\n"); const std::string me = "31630045827", my_lid = "173735822786802"; auto msg = [](const std::string& from, const std::string& notify) { WaNode m("message"); m.attrs["from"] = from; if (!notify.empty()) m.attrs["notify"] = notify; return m; }; auto cand = [&](const WaNode& m) { return WaClient::push_name_candidate(m, me, my_lid); }; { auto c = cand(msg("31611111111@s.whatsapp.net", "Nigel Dokter")); check(c && c->first == "31611111111@s.whatsapp.net" && c->second == "Nigel Dokter", "1:1 message learns the sender's push name"); } { WaNode m = msg("179000000000001@lid", "Anne"); m.attrs["sender_pn"] = "31622222222@s.whatsapp.net"; auto c = cand(m); check(c && c->first == "31622222222@s.whatsapp.net" && c->second == "Anne", "a LID sender with a PN alternate is learned under the sendable PN"); } { auto c = cand(msg("179000000000001@lid", "Anne")); check(c && c->first == "179000000000001@lid", "a LID sender without alternate is learned under its LID"); } { WaNode m = msg("120363000000000@g.us", "Joop"); m.attrs["participant"] = "31633333333@s.whatsapp.net"; auto c = cand(m); check(c && c->first == "31633333333@s.whatsapp.net" && c->second == "Joop", "a group message learns the participant, not the group"); } { WaNode m = msg("120363000000000@g.us", "Joop"); m.attrs["participant"] = "179000000000002@lid"; m.attrs["participant_pn"] = "31633333333@s.whatsapp.net"; auto c = cand(m); check(c && c->first == "31633333333@s.whatsapp.net", "a group LID participant prefers its PN alternate"); } check(!cand(msg("31630045827@s.whatsapp.net", "Me")), "our own messages teach nothing"); check(!cand(msg("31611111111@s.whatsapp.net", "-")), "a '-' push name is not learned"); check(!cand(msg("31611111111@s.whatsapp.net", "")), "a missing push name is not learned"); check(!cand(msg("1234567890@newsletter", "Channel")), "a newsletter sender is not learned"); { auto c = cand(msg("31611111111@s.whatsapp.net", std::string(300, 'x'))); check(c && c->second.size() == 128, "an over-long push name is truncated, not refused"); } std::string home = make_temp_home(); WaStore store(home, "push"); check(store.put_contact_if_absent("31611111111@s.whatsapp.net", "Nigel Dokter"), "first sighting of a sender is stored"); check(!store.put_contact_if_absent("31611111111@s.whatsapp.net", "Nigel Changed"), "a second sighting does not overwrite"); check(store.contacts().size() == 1 && store.contacts()[0].second == "Nigel Dokter", "the first learned name sticks"); auto hits = store.search_contacts("Niggel", 3, 0.3); check(!hits.empty() && hits[0].name == "Nigel Dokter", "a learned name is searchable with typos"); store.put_contact("31611111111@s.whatsapp.net", "Nigel Dokter (work)"); // app-state-grade name wins check(store.contacts()[0].second == "Nigel Dokter (work)", "a saved name overwrites a learned push name"); check(!store.put_contact_if_absent("31611111111@s.whatsapp.net", "Nigel Again"), "a push name never overwrites a saved name"); std::filesystem::remove_all(home); } // VCF fallback (problem.md): a .vcf exported on the phone feeds the same contact search the app-state sync feeds. static void test_vcf_import() { std::printf("[contacts: vcf import]\n"); const char* vcf = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nigel Dokter\r\nTEL;TYPE=CELL:+31 6 1234 5001\r\nEND:VCARD\r\n" "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smit;Nina;;;\r\nTEL:+31612345002\r\nTEL:0031612345003\r\nEND:VCARD\r\n" "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Long \r\n Name\r\nTEL;TYPE=HOME:+31-6-1234-5004\r\nEND:VCARD\r\n" "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Number\r\nEND:VCARD\r\n" "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Local Only\r\nTEL:0612345\r\nEND:VCARD\r\n"; int skipped = 0; auto contacts = WaVcf::parse(vcf, skipped); check(contacts.size() == 4, "four usable numbers parse from five cards (one numberless, one local)"); check(skipped == 2, "two cards skipped: no number, and a national number without country code"); bool nigel = false, nina = false, unfolded = false; for (const auto& c : contacts) { if (c.name == "Nigel Dokter" && c.number == "31612345001") nigel = true; if (c.name == "Nina Smit" && (c.number == "31612345002" || c.number == "31612345003")) nina = true; if (c.name == "Long Name") unfolded = true; } check(nigel, "FN plus an international TEL with spaces"); check(nina, "N fallback name plus two TELs (00 form normalized)"); check(unfolded, "a folded FN line is unfolded"); std::string home = make_temp_home(); WaStore store(home, "vcf"); WaVcf::Report rep = WaVcf::import_text(store, vcf); check(rep.imported == 4 && rep.skipped == 2 && rep.capped == 0, "import stores 4 numbers, skips 2, caps none"); auto hits = store.search_contacts("Niggel", 3, 0.3); check(!hits.empty() && hits[0].name == "Nigel Dokter", "an imported contact is found by the typo-tolerant search"); WaVcf::Report empty = WaVcf::import_text(store, "not a vcard at all"); check(empty.imported == 0, "garbage imports nothing and throws nothing"); std::filesystem::remove_all(home); } // M2 store: replays the op sequence of tests/whatsapp_data/store_oracle.go // (which runs pinned whatsmeow itself) against WaStore/WaLidMap/WaJid and // compares every observable outcome to tests/whatsapp_data/store_vectors.json. // Signal addresses use the detz form ("user.device") where the oracle uses // whatsmeow's ("user:device"); the vectors record outcomes, never addresses. // JID strings are compared byte-for-byte. static void test_store_m2() { std::printf("[store: M2 oracle differential]\\n"); Value v = Value::loads(slurp(path("store_vectors.json"))); auto kb = [&](const std::string& k) { return v.get(k).as_bool(); }; auto ki = [&](const std::string& k) { return v.get(k).as_int(); }; auto ks = [&](const std::string& k) { return v.get(k).str(); }; auto key = [](uint8_t fill) { return Bytes(32, fill); }; check(ki("muted_forever_unix") == WaConsts::kWaMutedForever, "kWaMutedForever equals whatsmeow MutedForever.Unix()"); // JID normalization (pure WaJid checks against the oracle's types.JID). bool jids_ok = true; for (const auto& m : v.get("jid_non_ad").members()) { if (WaJid::parse(m.first).non_ad().str() != m.second.str()) jids_ok = false; } check(jids_ok, "ToNonAD strings match whatsmeow ParseJID+ToNonAD byte-for-byte"); bool sig_ok = true; for (const auto& m : v.members()) { if (m.first.rfind("signal_user:", 0) == 0 && WaJid::parse(m.first.substr(12)).signal_user() != m.second.str()) sig_ok = false; } check(sig_ok, "signal users match whatsmeow SignalAddressUser byte-for-byte"); bool parse_ok = true; for (const auto& m : v.get("jid_parse_fails").members()) { bool threw = false; try { WaJid::parse(m.first); } catch (const std::invalid_argument&) { threw = true; } if (threw != m.second.as_bool()) parse_ok = false; } check(parse_ok, "WaJid::parse fails exactly where whatsmeow ParseJID fails"); std::string home = make_temp_home(); WaStore store(home, "m2"); // Miss-safe readers: a lookup that should hit but misses yields "" // (a loud FAIL), never an operator* on a disengaged optional. auto sess_hex = [&](const std::string& a) { auto o = store.load_session(a); return o ? tohex(*o) : std::string(""); }; auto sec_hex = [&](const std::string& c, const std::string& s, const std::string& id) { auto o = store.message_secret(c, s, id); return o ? tohex(o->secret) : std::string(""); }; auto tok_hex = [&](const std::string& u) { auto o = store.privacy_token(u); return o ? tohex(o->token) : std::string(""); }; // Identities with trust tracking. Detz addresses use '.', upstream ':'. check(store.is_trusted_identity("15551234567.0", key(1)) == kb("trust_unknown"), "unknown identity is trusted (TOFU)"); store.put_identity("15551234567.0", key(1)); check(store.is_trusted_identity("15551234567.0", key(1)) == kb("trust_same"), "stored identity is trusted for its key"); check(store.is_trusted_identity("15551234567.0", key(2)) == kb("trust_other"), "stored identity is not trusted for another key"); store.put_identity("15551234567.0", key(2)); check(!store.is_trusted_identity("15551234567.0", key(1)), "overwritten identity distrusts the old key"); check(store.is_trusted_identity("15551234567.0", key(2)), "overwritten identity trusts the new key"); store.put_identity("15551234567.5", key(3)); store.delete_identity("15551234567.0"); check(store.is_trusted_identity("15551234567.0", key(2)) == kb("trust_after_delete"), "deleted identity is unknown again"); check(store.is_trusted_identity("15551234567.5", key(3)) == kb("trust_sibling_after_delete"), "deleting one device keeps the sibling"); store.delete_all_identities("15551234567"); check(store.is_trusted_identity("15551234567.5", key(3)) == kb("trust_sibling_after_delete_all"), "delete-all clears every device of the phone"); // Upstream DeleteIdentity uses the LIKE query by accident, so deleting // "9%9:0" also wiped "999:0" (oracle trust_pct_sibling_after_pct_delete is // true, i.e. unknown). detz deletes exactly: the sibling row survives. check(kb("trust_pct_sibling_after_pct_delete"), "oracle records the upstream LIKE over-delete"); store.put_identity("9%9.0", key(4)); store.put_identity("999.0", key(5)); store.delete_identity("9%9.0"); check(store.is_trusted_identity("9%9.0", key(4)), "the deleted wildcard address is unknown again"); check(store.is_trusted_identity("999.0", key(5)) && !store.is_trusted_identity("999.0", key(1)), "delete_identity is exact: a LIKE-special address deletes one row, not the pattern"); // Sessions. check(store.has_session("15550001111.0") == kb("has_session_missing"), "no session for an unknown address"); store.save_session("15550001111.0", Bytes{'s', 'e', 's', 's', '-', 'a', 'a', 'a'}); store.save_session("15550001111.2", Bytes{'s', 'e', 's', 's', '-', 'b', 'b', 'b'}); check(store.has_session("15550001111.0") == kb("has_session_present"), "a stored session is found"); check(sess_hex("15550001111.0") == ks("get_session"), "session bytes round-trip"); check(!store.load_session("15550009999.0").has_value() == kb("get_session_missing_null"), "missing session is empty"); auto many = store.get_many_sessions({"15550001111.0", "15550009999.0", "15550001111.2"}); auto many_it = many.find("15550001111.0"); check(many_it != many.end() && many_it->second && tohex(*many_it->second) == ks("get_many_present"), "get-many returns the stored blob"); check(!many["15550009999.0"].has_value() == kb("get_many_missing_null"), "get-many reports the miss as empty"); check((many.size() == 3) == kb("get_many_all_keys"), "get-many holds every requested address"); check(store.get_many_sessions({}).empty() == kb("get_many_empty_null"), "get-many of nothing is empty"); store.put_many_sessions({{"15550002222.0", Bytes{'s', 'e', 's', 's', '-', 'c', 'c', 'c'}}}); check(sess_hex("15550002222.0") == ks("get_many_put"), "put-many stores"); store.delete_session("15550001111.2"); check(store.has_session("15550001111.2") == kb("has_session_after_delete"), "delete removes one session"); store.delete_all_sessions("15550001111"); check(store.has_session("15550001111.0") == kb("has_session_after_delete_all"), "delete-all clears the phone"); check(store.has_session("15550002222.0") == kb("has_other_after_delete_all"), "delete-all keeps other phones"); // PN -> LID migration across sessions, identities and sender keys. Sender // keys have no M2 API (M4 owns them); the row is planted with raw SQL and // read back the same way, so the migrate arm is still proven. store.save_session("15550003333.0", Bytes{'s', 'e', 's', 's', '-', 'm', 'i', 'g'}); store.put_identity("15550003333.0", key(6)); { detz::store::SqlDb raw(WaStore::db_path(home, "m2")); raw.execute("INSERT INTO wa_sender_key (chat_id, sender_id, key) VALUES ('group@g.us', '15550003333.0', '73656e6465722d6d6967')"); raw.commit(); } auto sender_key = [&](const std::string& chat, const std::string& sender) -> std::optional { detz::store::SqlDb raw(WaStore::db_path(home, "m2")); auto cur = raw.execute("SELECT key FROM wa_sender_key WHERE chat_id=? AND sender_id=?", {detz::store::Cell(chat), detz::store::Cell(sender)}); const auto* row = cur.first(); std::optional out; if (row != nullptr) out = (*row)[0].text(); return out; }; store.migrate_pn_to_lid("15550003333@s.whatsapp.net", "100000000000001@lid"); check(ks("mig_lid_addr") == "100000000000001_1:0", "oracle pins the upstream agent-suffixed LID address"); check(sess_hex("100000000000001.0") == ks("mig_session_moved"), "migrate moves the session to the plain-user LID"); check(!store.load_session("15550003333.0").has_value() == kb("mig_session_gone"), "migrate deletes the PN session"); check(store.is_trusted_identity("100000000000001.0", key(6)) == kb("mig_identity_moved"), "migrate moves the identity"); check(store.is_trusted_identity("15550003333.0", key(6)) == kb("mig_identity_gone"), "migrate deletes the PN identity"); check(sender_key("group@g.us", "100000000000001.0").value_or("") == ks("mig_sender_moved"), "migrate moves the sender key to the LID"); check(!sender_key("group@g.us", "15550003333.0").has_value() == kb("mig_sender_gone"), "migrate deletes the PN sender key"); store.migrate_pn_to_lid("15550003333@s.whatsapp.net", "100000000000001@lid"); check(sess_hex("100000000000001.0") == ks("mig_repeat_still_moved"), "a repeated migrate is a no-op"); // LID map. WaLidMap& lids = store.lid_map(); check(!lids.lid_for_pn("15551112222@s.whatsapp.net").has_value() == kb("lid_miss_empty"), "unmapped PN misses without error"); lids.put_mapping("100000000000002@lid", "15551112222@s.whatsapp.net"); check(lids.lid_for_pn("15551112222@s.whatsapp.net").value_or("") == ks("lid_hit"), "mapped PN hits its LID"); check(lids.lid_for_pn("15551112222:3@s.whatsapp.net").value_or("") == ks("lid_hit_device"), "the device survives the PN->LID lookup"); check(lids.pn_for_lid("100000000000002@lid").value_or("") == ks("pn_hit"), "the reverse lookup hits the PN"); bool lid_rej = false, pn_rej = false, swap_rej = false; try { lids.lid_for_pn("100000000000002@lid"); } catch (const std::invalid_argument&) { lid_rej = true; } try { lids.pn_for_lid("15551112222@s.whatsapp.net"); } catch (const std::invalid_argument&) { pn_rej = true; } try { lids.put_mapping("15551112222@s.whatsapp.net", "100000000000002@lid"); } catch (const std::invalid_argument&) { swap_rej = true; } check(lid_rej == kb("lid_for_pn_rejects_lid"), "LID-for-PN rejects a LID input"); check(pn_rej == kb("pn_for_lid_rejects_pn"), "PN-for-LID rejects a PN input"); check(swap_rej == kb("put_mapping_rejects_swapped"), "put-mapping rejects swapped servers"); lids.put_many_mappings({{"100000000000003@lid", "15551113333@s.whatsapp.net"}, {"100000000000003@lid", "15551113333@s.whatsapp.net"}, {"15551112222@s.whatsapp.net", "100000000000002@lid"}}); auto ml = lids.many_lids_for_pns({"15551112222@s.whatsapp.net", "15551113333@s.whatsapp.net", "19998887777@s.whatsapp.net", "100000000000002@lid"}); check(ml["15551112222@s.whatsapp.net"] == ks("many_lids_hit"), "batch lookup hits the cached PN"); check(ml["15551113333@s.whatsapp.net"] == ks("many_lids_new"), "batch lookup hits the new mapping"); check((ml.count("19998887777@s.whatsapp.net") == 0) == kb("many_lids_missing_absent"), "batch lookup omits the miss"); check((int)ml.size() == ki("many_lids_size"), "batch lookup holds exactly the two mapped PNs"); lids.put_mapping("100000000000004@lid", "15551112222@s.whatsapp.net"); check(lids.lid_for_pn("15551112222@s.whatsapp.net").value_or("") == ks("lid_remapped"), "remapping moves the PN"); check(!lids.pn_for_lid("100000000000002@lid").has_value() == kb("lid_remap_old_gone"), "remapping drops the stale reverse mapping"); check(lids.alt_jid("15551112222@s.whatsapp.net").value_or("") == ks("alt_pn"), "alt-JID maps a PN to its LID"); check(lids.alt_jid("100000000000004@lid").value_or("") == ks("alt_lid"), "alt-JID maps a LID to its PN"); check(!lids.alt_jid("120363000000@g.us").has_value() == kb("alt_group_empty"), "alt-JID of a group is empty"); lids.fill_cache(); // Contacts: push, business, saved, batch and redacted names. auto [pc, pp] = store.put_push_name("15552223333@s.whatsapp.net", "Pushee"); check(pc == kb("push_first_changed") && pp == ks("push_first_prev"), "first push name is stored"); auto [pc2, pp2] = store.put_push_name("15552223333@s.whatsapp.net", "Pushee"); (void)pp2; check(pc2 == kb("push_same_changed"), "an unchanged push name skips the write"); auto [pc3, pp3] = store.put_push_name("15552223333@s.whatsapp.net", "Pushed"); check(pc3 == kb("push_change_changed") && pp3 == ks("push_change_prev"), "a changed push name reports the previous one"); auto [bc, bp] = store.put_business_name("15552223333@s.whatsapp.net", "Biz Co"); (void)bp; check(bc == kb("biz_first_changed"), "first business name is stored"); store.put_contact_name("15552223333@s.whatsapp.net", "First", "First Last"); WaContactInfo ci = store.get_contact("15552223333@s.whatsapp.net"); check(ci.found == kb("contact_found") && ci.first_name == ks("contact_first") && ci.full_name == ks("contact_full") && ci.push_name == ks("contact_push") && ci.business_name == ks("contact_biz"), "the contact row holds saved, push and business names"); check(store.get_contact("15550004444@s.whatsapp.net").found == kb("contact_missing_found"), "an unknown contact is not found"); store.put_all_contact_names({{"15550005555@s.whatsapp.net", "A", "A A"}, {"15550005555@s.whatsapp.net", "B", "B B"}, {"15550006666@s.whatsapp.net", "C", "C C"}}); check(store.get_contact("15550005555@s.whatsapp.net").first_name == ks("contact_dedup_first"), "batch contact names deduplicate, first rows win"); store.put_many_redacted_phones({{"100000000000005@lid", "+1········80"}}); check(store.get_contact("100000000000005@lid").redacted_phone == ks("contact_redacted"), "redacted phones are stored"); auto all = store.all_contacts(); check(all["15552223333@s.whatsapp.net"].push_name == "Pushed", "all-contacts holds the push name"); check((all.size() >= 4) == kb("contact_all_count_ge"), "all-contacts holds every row"); // Chat settings. WaChatSettings cs0 = store.chat_settings("15553334444@s.whatsapp.net"); // Not-muted is represented differently (detz 0, whatsmeow zero time.Time) // but means the same on both sides; the stored row (0) is identical. check(cs0.found == kb("chat_missing_found") && cs0.muted_until == 0 && kb("chat_missing_muted_zero"), "an unknown chat has no settings"); store.put_muted_until("15553334444@s.whatsapp.net", 1700000000); store.put_pinned("15553334444@s.whatsapp.net", true); store.put_archived("15553334444@s.whatsapp.net", true); WaChatSettings cs1 = store.chat_settings("15553334444@s.whatsapp.net"); check(cs1.muted_until == ki("chat_muted") && cs1.pinned == kb("chat_pinned") && cs1.archived == kb("chat_archived") && cs1.found == kb("chat_found"), "mute, pin and archive are stored"); store.put_muted_until("15553334444@s.whatsapp.net", WaConsts::kWaMutedForever); check(store.chat_settings("15553334444@s.whatsapp.net").muted_until == WaConsts::kWaMutedForever, "muted-forever round-trips through the -1 row"); check(kb("chat_forever"), "oracle confirms whatsmeow reads the -1 row back as MutedForever"); store.put_muted_until("15553334444@s.whatsapp.net", 0); check(store.chat_settings("15553334444@s.whatsapp.net").muted_until == 0 && kb("chat_unmuted_zero"), "zero clears the mute"); store.put_pinned("15553334444@s.whatsapp.net", false); WaChatSettings cs2 = store.chat_settings("15553334444@s.whatsapp.net"); check(!cs2.pinned && cs2.archived, "unpinning keeps the archive flag"); check(kb("chat_unpinned"), "oracle confirms whatsmeow keeps the archive flag too"); store.put_wasa_root_secret_id("120363000001@g.us", "wasa-id-1"); check(store.wasa_root_secret_id("120363000001@g.us").value_or("") == ks("wasa_hit"), "the WASA root secret id is stored"); check(!store.wasa_root_secret_id("15553334444@s.whatsapp.net").has_value() == kb("wasa_missing_empty"), "a chat without a WASA id misses"); // Message secrets, including the alternate LID/PN lookup. lids.put_mapping("100000000000006@lid", "15554445555@s.whatsapp.net"); Bytes sec(32, 0x42); store.put_message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1", sec); auto got_sec = store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1"); check(got_sec && tohex(got_sec->secret) == ks("secret_hit"), "the message secret is stored"); check(got_sec && got_sec->sender == ks("secret_sender"), "the lookup reports the stored sender"); store.put_message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1", Bytes(32, 0x43)); check(sec_hex("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1") == ks("secret_dup_keeps_first"), "a duplicate secret keeps the first row"); auto alt_sec = store.message_secret("100000000000006@lid", "100000000000006@lid", "MSGID1"); check(alt_sec && tohex(alt_sec->secret) == ks("secret_alt_hit"), "the LID form finds the PN secret"); check(alt_sec && alt_sec->sender == ks("secret_alt_sender"), "the alternate lookup reports the PN sender"); check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "NOPE").has_value() == kb("secret_miss_null"), "an unknown secret id misses"); store.put_message_secrets({{"15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID2", Bytes{'s', '2'}}, {"15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID3", Bytes{}}, {"", "15554445555@s.whatsapp.net", "MSGID4", Bytes{'s', '4'}}}); check(sec_hex("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID2") == ks("secret_batch_hit"), "batch secrets store the valid rows"); check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID3").has_value() == kb("secret_batch_skipped"), "batch secrets skip the empty rows"); // Privacy tokens: newer wins, sender timestamps coalesce, expiry deletes. store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '1'}, 1700000000, std::nullopt}}); auto tok = store.privacy_token("15554445555@s.whatsapp.net"); check(tok && tohex(tok->token) == ks("token_hit") && tok->timestamp == ki("token_ts") && !tok->sender_timestamp.has_value() == kb("token_sender_ts_zero"), "the privacy token is stored without a sender timestamp"); store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'o', 'l', 'd'}, 1600000000, std::nullopt}}); check(tok_hex("15554445555@s.whatsapp.net") == ks("token_older_ignored"), "an older token leaves the row alone"); store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '2'}, 1700000001, 1700000002}}); tok = store.privacy_token("15554445555@s.whatsapp.net"); check(tok && tohex(tok->token) == ks("token_newer_wins") && tok->sender_timestamp.value_or(0) == ki("token_sender_ts"), "a newer token wins and carries its sender timestamp"); store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '3'}, 1700000003, std::nullopt}}); auto tok3 = store.privacy_token("15554445555@s.whatsapp.net"); check(tok3 && tok3->sender_timestamp.value_or(0) == ki("token_sender_ts_kept"), "an update without a sender timestamp keeps the stored one"); auto tok_alt = store.privacy_token("100000000000006@lid"); check(tok_alt && tohex(tok_alt->token) == ks("token_alt_hit"), "the LID form finds the PN token"); check(tok_alt && tok_alt->user == ks("token_alt_user"), "the alternate token reports the PN user"); store.put_privacy_tokens({{"15550007777@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'x'}, 1500000000, std::nullopt}, {"15550008888@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'y'}, 1800000000, std::nullopt}}); check(store.delete_expired_privacy_tokens(1600000000) == ki("token_expired_deleted"), "expiry deletes one token"); check(!store.privacy_token("15550007777@s.whatsapp.net").has_value() == kb("token_expired_gone"), "the expired token is gone, the fresh one survives"); bool empty_tokens_threw = false; try { store.put_privacy_tokens({}); } catch (const std::invalid_argument&) { empty_tokens_threw = true; } check(empty_tokens_threw == kb("put_tokens_empty_err"), "empty token input fails loudly on both sides"); // Unlink integration: device-scoped rows go, contacts and the LID map stay. store.clear_device(); check(!store.has_session("100000000000001.0"), "clear_device drops the sessions"); check(store.is_trusted_identity("100000000000001.0", key(6)), "clear_device drops the identities"); check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1").has_value(), "clear_device drops the message secrets"); check(!store.privacy_token("15554445555@s.whatsapp.net").has_value(), "clear_device drops the privacy tokens"); check(!store.chat_settings("15553334444@s.whatsapp.net").found, "clear_device drops the chat settings"); check(store.get_contact("15552223333@s.whatsapp.net").found, "clear_device keeps the contacts"); check(lids.lid_for_pn("15551112222@s.whatsapp.net").has_value(), "clear_device keeps the LID map"); std::filesystem::remove_all(home); } // M2 hostile input: corrupt database rows, overlong keys, malformed JIDs and a // fuzz loop over the string-taking store surface. Every case must throw a // handled exception (never std::length_error) or succeed; surviving is proof. static void test_store_hostile() { std::printf("[store: hostile input]\\n"); std::string home = make_temp_home(); WaStore store(home, "hostile"); auto throws_handled = [](auto fn) { try { fn(); } catch (const std::length_error&) { return false; } catch (const std::exception&) { return true; } return false; }; check(throws_handled([&] { store.put_identity("1.0", Bytes(31, 1)); }), "a 31-byte identity key is rejected"); check(throws_handled([&] { store.put_identity("1.0", Bytes(33, 1)); }), "a 33-byte identity key is rejected"); check(throws_handled([&] { store.put_identity("1.0", Bytes()); }), "an empty identity key is rejected"); std::string huge_addr(2000, '5'); check(throws_handled([&] { store.save_session(huge_addr, Bytes{1}); }), "an overlong session address is rejected"); check(throws_handled([&] { store.put_identity(huge_addr, Bytes(32, 1)); }), "an overlong identity address is rejected"); check(throws_handled([&] { store.lid_map().lid_for_pn("a.b.c@s.whatsapp.net"); }), "a malformed PN JID is rejected"); std::string huge_jid(2000, '1'); huge_jid += "@s.whatsapp.net"; check(throws_handled([&] { store.lid_map().lid_for_pn(huge_jid); }), "an overlong JID is rejected"); check(throws_handled([&] { store.put_privacy_tokens({}); }), "empty token input throws"); check(throws_handled([&] { store.message_secret("u:x@y", "s@s.whatsapp.net", "id"); }), "a malformed secret chat is rejected"); // Corrupt rows planted with raw SQL: a short identity key and a session blob // past the cap must throw a handled error, never allocate blindly. store.put_identity("15550001111.0", Bytes(32, 7)); store.save_session("15550001111.0", Bytes{9}); { detz::store::SqlDb raw(WaStore::db_path(home, "hostile")); raw.execute("UPDATE wa_identity SET key='ab' WHERE addr='15550001111.0'"); raw.execute("UPDATE wa_session SET blob='" + std::string(3 * 1024 * 1024, 'a') + "' WHERE addr='15550001111.0'"); raw.commit(); } check(throws_handled([&] { store.is_trusted_identity("15550001111.0", Bytes(32, 7)); }), "a corrupt identity length throws, never length_error"); check(throws_handled([&] { store.load_session("15550001111.0"); }), "an oversized session blob throws before allocating"); // Wide inputs: a 2000-address get-many and a 650-row batch (chunked 300/300/50). std::vector addrs; for (int i = 0; i < 2000; ++i) addrs.push_back("1999000" + std::to_string(i) + ".0"); check(store.get_many_sessions(addrs).size() == 2000, "get-many serves 2000 addresses"); std::vector bulk; for (int i = 0; i < 650; ++i) bulk.push_back({"1999888" + std::to_string(1000 + i) + "@s.whatsapp.net", "N" + std::to_string(i), "N" + std::to_string(i)}); store.put_all_contact_names(bulk); check(store.get_contact("19998881000@s.whatsapp.net").first_name == "N0" && store.get_contact("19998881649@s.whatsapp.net").first_name == "N649", "a 650-row batch survives chunking"); // Fuzz: hostile strings through every string-taking entry point. bool ok = true; uint64_t st = 0x12345678; auto rnd = [&]() { st = st * 6364136223846793005ull + 1442695040888963407ull; return st >> 33; }; const char* alpha = "@.:%_0123456789abLIDs.whatsapp.net"; for (int i = 0; i < 3000 && ok; ++i) { size_t n = 1 + rnd() % 60; std::string s; for (size_t k = 0; k < n; ++k) s.push_back(alpha[rnd() % 33]); if (rnd() % 16 == 0) s.append(1200, '9'); // sometimes overlong try { store.lid_map().lid_for_pn(s); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { store.lid_map().pn_for_lid(s); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { store.lid_map().alt_jid(s); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { store.put_contact_name(s, "f", "g"); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { store.get_contact(s); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } try { store.chat_settings(s); } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } // WaJid strings round-trip: parse(str()) is str() again. try { WaJid j = WaJid::parse(s); if (WaJid::parse(j.str()).str() != j.str()) ok = false; } catch (const std::length_error&) { ok = false; } catch (const std::exception&) { } } check(ok, "3000 hostile strings: handled or accepted, never length_error, JIDs round-trip"); std::filesystem::remove_all(home); // ---- M1: token dictionary freshness (whatsmeow token_oracle.go) ---- static void test_tokens() { std::printf("[tokens]\n"); Value d = Value::loads(slurp(path("token_vectors.json"))); check(d.get("dict_version").as_int(0) == kDictVersion, "dict version is whatsmeow DictVersion (3)"); const Value& single = d.get("single"); bool sok = single.is_array() && single.size() == kSingleByteTokens.size(); for (size_t i = 0; sok && i < kSingleByteTokens.size(); ++i) sok = single.at(i).str() == std::string(kSingleByteTokens[i]); check(sok, "236 single-byte tokens match whatsmeow verbatim"); const Value& dbl = d.get("double"); bool dok = dbl.is_array() && dbl.size() == kDoubleByteTokens.size(); for (size_t x = 0; dok && x < kDoubleByteTokens.size(); ++x) { dok = dbl.at(x).is_array() && dbl.at(x).size() == kDoubleByteTokens[x].size(); for (size_t i = 0; dok && i < kDoubleByteTokens[x].size(); ++i) dok = dbl.at(x).at(i).str() == std::string(kDoubleByteTokens[x][i]); } check(dok, "4x256 double-byte tokens match whatsmeow verbatim"); const Value& c = d.get("consts"); bool cok = c.get("ListEmpty").as_int(-1) == WaBinary::LIST_EMPTY && c.get("Dictionary0").as_int(-1) == WaBinary::DICTIONARY_0 && c.get("Dictionary1").as_int(-1) == WaBinary::DICTIONARY_1 && c.get("Dictionary2").as_int(-1) == WaBinary::DICTIONARY_2 && c.get("Dictionary3").as_int(-1) == WaBinary::DICTIONARY_3 && c.get("InteropJID").as_int(-1) == WaBinary::INTEROP_JID && c.get("FBJID").as_int(-1) == WaBinary::FB_JID && c.get("ADJID").as_int(-1) == WaBinary::AD_JID && c.get("List8").as_int(-1) == WaBinary::LIST_8 && c.get("List16").as_int(-1) == WaBinary::LIST_16 && c.get("JIDPair").as_int(-1) == WaBinary::JID_PAIR && c.get("Hex8").as_int(-1) == WaBinary::HEX_8 && c.get("Binary8").as_int(-1) == WaBinary::BINARY_8 && c.get("Binary20").as_int(-1) == WaBinary::BINARY_20 && c.get("Binary32").as_int(-1) == WaBinary::BINARY_32 && c.get("Nibble8").as_int(-1) == WaBinary::NIBBLE_8 && c.get("PackedMax").as_int(-1) == WaBinary::PACKED_MAX && c.get("SingleByteMax").as_int(-1) == WaBinary::SINGLE_BYTE_MAX; check(cok, "wire tag consts equal token.* (18 values)"); // Probes: bare-tag nodes are deterministic, so our encoder must match // whatsmeow's marshal byte-for-byte; every probe also decodes back. int ok = 0, n = 0; for (const auto& p : d.get("probes").items()) { ++n; const std::string s = p.get("string").str(); WaNode back = WaBinary::decode(WaBinary::unpack(fromhex(p.get("wire_hex").str()))); if (back.tag != s) continue; Bytes raw; raw.push_back(0); Bytes enc = WaBinary::encode(WaNode(s)); raw.insert(raw.end(), enc.begin(), enc.end()); if (tohex(raw) == p.get("wire_hex").str()) ++ok; } check(ok == n && n > 10, "token probe nodes encode byte-exact (" + std::to_string(ok) + "/" + std::to_string(n) + ", single + all 4 dicts + literal)"); } // ---- M1: iq request/response (whatsmeow iq_oracle.go) ---- static void test_iq() { std::printf("[iq]\n"); Value d = Value::loads(slurp(path("iq_vectors.json"))); // Decode direction: whatsmeow wrote the bytes, we read them. int ok = 0, n = 0; for (const auto& v : d.get("decode").items()) { ++n; WaNode node = WaBinary::decode(WaBinary::unpack(fromhex(v.get("wire_hex").str()))); bool good = node.tag == v.get("tag").str(); for (const auto& m : v.get("attrs").members()) good = good && node.attr(m.first) == m.second.str(); good = good && node.attrs.size() == v.get("attrs").size(); const Value& kids = v.get("children"); for (size_t i = 0; good && i < kids.size(); ++i) good = node.child(kids.at(i).str()) != nullptr; WaIqError err = WaIq::parse_error(node); good = good && err.code() == (int)v.get("code").as_int(0) && err.text() == v.get("text").str() && err.has_error_node() == v.get("has_error").as_bool(); if (good) ++ok; } check(ok == n && n == 9, "iq nodes decode + classify like whatsmeow (" + std::to_string(ok) + "/" + std::to_string(n) + ": ping, count, 404, 500, bare, result, 3 disconnects)"); // Encode direction: deterministic nodes must match whatsmeow's marshal bytes. int eok = 0, en = 0; for (const auto& v : d.get("encode").items()) { ++en; WaNode node(v.get("tag").str()); for (const auto& m : v.get("attrs").members()) node.attrs[m.first] = m.second.str(); Bytes raw; raw.push_back(0); Bytes enc = WaBinary::encode(node); raw.insert(raw.end(), enc.begin(), enc.end()); const std::string got = tohex(raw); if (v.has("wire_hex")) { if (got == v.get("wire_hex").str()) ++eok; } else { for (const auto& w : v.get("wire_set").items()) if (got == w.str()) { ++eok; break; } } } check(eok == en && en == 4, "iq nodes encode within whatsmeow's bytes (" + std::to_string(eok) + "/" + std::to_string(en) + ": exact x2, order-set x2)"); // JID-valued attrs have two valid wire forms: whatsmeow writes its typed // JID as JID_PAIR, we write the same string as its single-byte token; the // decode checks above prove both read back to "s.whatsapp.net". WaNode to_form("iq"); to_form.attrs["to"] = "s.whatsapp.net"; Bytes to_raw = WaBinary::encode(to_form); check(to_raw.size() == 5 && to_raw[3] == 0x11 && to_raw[4] == 0x03, "our server-JID attr encodes as the 1-byte token (compact valid form)"); // WaIq::build assembles the ping exactly as whatsmeow sendIQAsyncAndGetData does. WaIqQuery q; q.ns = "w:p"; q.type = "get"; q.to = WaConsts::kServerJid; q.id = "42.7-9"; WaNode ping = WaIq::build(q); check(ping.tag == "iq" && ping.attr("id") == "42.7-9" && ping.attr("xmlns") == "w:p" && ping.attr("type") == "get" && ping.attr("to") == "s.whatsapp.net" && !ping.has_attr("smax_id") && !ping.has_attr("target"), "WaIq::build shapes the keepalive ping (id/xmlns/type/to, no smax/target)"); WaIqQuery qs = q; qs.smax_id = "99"; qs.target = "31611111111@s.whatsapp.net"; WaNode with_extra = WaIq::build(qs); check(with_extra.attr("smax_id") == "99" && with_extra.attr("target") == "31611111111@s.whatsapp.net", "WaIq::build carries smax_id + target when set"); // Request ids mirror whatsmeow ".-". check(WaIq::generate_id("42.7-", 9) == "42.7-9", "request id is prefix + counter"); std::string pfx = WaIq::new_prefix(); check(pfx.size() >= 5 && pfx.back() == '-' && pfx.find('.') != std::string::npos, "request prefix has the .- shape"); check(WaIq::new_prefix().size() >= 5, "a second prefix draws cleanly"); // Disconnect predicates on the oracle's disconnect nodes. const Value& dec = d.get("decode"); auto by_name = [&](const std::string& name) { for (const auto& v : dec.items()) if (v.get("name").str() == name) return WaBinary::decode(WaBinary::unpack(fromhex(v.get("wire_hex").str()))); return WaNode("missing"); }; WaNode e401 = by_name("stream_error_401"), conf = by_name("stream_error_conflict"), e515 = by_name("stream_error_515"), pong = by_name("iq_result_ping"); check(WaIq::is_disconnect_node(e401) && WaIq::is_disconnect_node(conf) && WaIq::is_disconnect_node(e515) && !WaIq::is_disconnect_node(pong), "disconnect nodes recognized (stream:error yes, iq result no)"); check(WaIq::is_auth_error_disconnect(e401) && WaIq::is_auth_error_disconnect(conf) && !WaIq::is_auth_error_disconnect(e515) && !WaIq::is_auth_error_disconnect(pong), "auth disconnects (401, replaced) separated from the 515 restart"); WaNode xml_end("xmlstreamend"); check(WaIq::is_disconnect_node(xml_end) && !WaIq::is_auth_error_disconnect(xml_end), "xmlstreamend disconnects without being an auth error"); // Error taxonomy mirrors whatsmeow errors.go. WaIqError e404 = WaIq::parse_error(by_name("iq_error_404")); check(e404.is(WaIqError::not_found()) && !e404.is(WaIqError::internal_server_error()), "parsed 404 is ErrIQNotFound, not ErrIQInternalServerError"); check(WaIqError::timed_out().message() == "info query timed out", "timeout message matches ErrIQTimedOut"); check(WaIqError::not_found().message() == "info query returned status 404: item-not-found", "status message matches IQError.Error"); check(WaDisconnectedError::for_iq().message() == "websocket disconnected before info query returned response", "disconnect message matches DisconnectedError.Error"); check(WaDisconnectedError::for_iq().is(WaDisconnectedError("info query")) && !WaDisconnectedError::for_iq().is(WaDisconnectedError("message send")), "disconnect equivalence is by action"); WaIqError bare = WaIq::parse_error(by_name("iq_error_bare")); check(bare.code() == 0 && !bare.has_error_node(), "a result without parses to code 0"); // Hostile shapes: never throw, never overflow, code reads 0. WaNode evil("iq"); evil.content = WaNode::Children{WaNode("error")}; WaNode* child = const_cast(evil.child("error")); child->attrs["code"] = "not-a-number"; check(WaIq::parse_error(evil).code() == 0, "a non-numeric error code reads 0"); child->attrs["code"] = "99999999999999999999999"; check(WaIq::parse_error(evil).code() == 0, "an overflowing error code reads 0, no UB"); child->attrs.erase("code"); child->attrs["text"] = std::string(70000, 'x'); check(WaIq::parse_error(evil).code() == 0, "a missing code with a huge text reads 0"); // Socket guards fail loudly without any network. bool threw = false; try { WaSocket s; s.send_frame(Bytes{1, 2, 3}); } catch (const WaSocketError&) { threw = true; } check(threw, "sending on an unconnected socket throws WaSocketError"); WaSocketStatusError with_status("connect: refused", 403); check(with_status.status() == 403 && std::string(with_status.what()).find("403") != std::string::npos, "the status-carrying socket error keeps its code (ErrWithStatusCode role)"); } // ---- M1: keepalive schedule (deterministic clock) ---- static void test_keepalive() { std::printf("[keepalive]\n"); check(WaConsts::kWaKeepAliveResponseDeadline == 10 && WaConsts::kWaKeepAliveIntervalMin == 20 && WaConsts::kWaKeepAliveIntervalMax == 30 && WaConsts::kWaKeepAliveMaxFailTime == 180, "keepalive caps equal whatsmeow (10s deadline, 20-30s jitter, 3min horizon)"); WaKeepAlive ka([]() { return 25000; }); ka.start(100000); check(!ka.due(100000) && !ka.due(124999) && ka.due(125000), "first ping due after the interval"); check(ka.error_count() == 0, "no errors at start"); ka.record_pong(125000); check(ka.error_count() == 0 && ka.last_success_ms() == 125000 && !ka.due(149999) && ka.due(150000), "a pong clears errors and advances the schedule"); check(!ka.record_timeout(150000) && ka.error_count() == 1, "one unanswered ping counts, no reconnect yet"); check(!ka.record_timeout(175000) && ka.error_count() == 2, "errors accumulate across pings"); ka.record_pong(200000); check(ka.error_count() == 0 && ka.last_success_ms() == 200000, "a later pong recovers the streak"); // Failures spanning more than 3 minutes force a reconnect (whatsmeow KeepAliveMaxFailTime). WaKeepAlive dying([]() { return 25000; }); dying.start(0); bool forced = false; for (int64_t t = 25000; t <= 400000 && !forced; t += 25000) forced = dying.record_timeout(t); check(forced && dying.error_count() == 8, "8 unanswered pings (200s of failure) force a reconnect"); WaKeepAlive edge([]() { return 25000; }); edge.start(0); bool early = true; for (int i = 0; i < 7; ++i) early = early && !edge.record_timeout(25000 + i * 25000); check(early && edge.record_timeout(200000), "the horizon trips past 180s, not before"); // The default jitter always stays inside 20-30s. bool in_range = true; for (int i = 0; i < 200; ++i) { WaKeepAlive probe; probe.start(0); int64_t wait = probe.next_due_ms(); in_range = in_range && wait >= 20000 && wait <= 30000; } check(in_range, "default jitter stays inside 20-30s over 200 draws"); check(WaConsts::kWaIqDefaultTimeout == 75, "default iq wait is whatsmeow defaultRequestTimeout (75s)"); } // ---- M1: Noise parity + server certificate (whatsmeow cert_oracle.go) ---- static void test_server_cert() { std::printf("[server-cert]\n"); Value d = Value::loads(slurp(path("cert_vectors.json"))); Value n = Value::loads(slurp(path("noise.json"))); // Re-run the handshake over the wa-go trace: our decrypted bytes must equal // whatsmeow's own NoiseHandshake output from the oracle. auto ephPriv = a32(fromhex(n.get("ephemeralKeyPriv").str())); auto ephPub = a32(fromhex(n.get("ephemeralKeyPub").str())); auto stPriv = a32(fromhex(n.get("authStaticKeyPriv").str())); auto stPub = a32(fromhex(n.get("authStaticKeyPub").str())); Bytes shFrame = fromhex(n.get("serverHelloFrameHex").str()); size_t pos = 0; auto sh = WaFraming::unframe(shFrame, pos); WaNoise noise(ephPub); auto res = noise.client_handshake(ephPriv, ephPub, stPriv, stPub, *sh, Bytes{1, 2, 3, 4}); check(res.has_value(), "handshake completes (mix_shared_secret refactor)"); check(res && tohex(res->server_static) == d.get("server_static_hex").str(), "server static equals whatsmeow NoiseHandshake output"); check(res && tohex(res->server_payload) == d.get("server_payload_hex").str(), "server payload equals whatsmeow NoiseHandshake output (241 bytes)"); // Details fields match whatsmeow's waCert parse field by field. auto chain = parse_cert_chain(fromhex(d.get("server_payload_hex").str())); auto im = parse_cert_details(chain->intermediate_details); auto leaf = parse_cert_details(chain->leaf_details); const Value& oi = d.get("intermediate"); const Value& ol = d.get("leaf"); check(im && im->serial == (uint32_t)oi.get("serial").as_int(0) && im->issuer_serial == (uint32_t)oi.get("issuer_serial").as_int(0) && tohex(im->key) == oi.get("key_hex").str() && im->not_before == (uint64_t)oi.get("not_before").as_int(0) && im->not_after == (uint64_t)oi.get("not_after").as_int(0), "intermediate details match whatsmeow (serial 3, issuer 0, key, window)"); check(leaf && leaf->serial == (uint32_t)ol.get("serial").as_int(0) && leaf->issuer_serial == (uint32_t)ol.get("issuer_serial").as_int(0) && tohex(leaf->key) == ol.get("key_hex").str() && leaf->not_before == (uint64_t)ol.get("not_before").as_int(0) && leaf->not_after == (uint64_t)ol.get("not_after").as_int(0), "leaf details match whatsmeow (serial 354, issuer 3, key, window)"); Bytes pubkey(WaConsts::kWaCertPubKey.begin(), WaConsts::kWaCertPubKey.end()); check(tohex(pubkey) == d.get("wacert_pubkey_hex").str(), "pinned key equals whatsmeow WACertPubKey"); check(WaConsts::kWaCertIssuerSerial == 0, "pinned issuer serial equals whatsmeow WACertIssuerSerial"); // The captured leaf is expired today, so travel inside its window: every // check must pass there (the oracle confirms the signatures are genuine). Bytes payload = fromhex(d.get("server_payload_hex").str()); Bytes server_static = fromhex(d.get("server_static_hex").str()); int64_t mid = (int64_t)(((uint64_t)ol.get("not_before").as_int(0) + (uint64_t)ol.get("not_after").as_int(0)) / 2); bool accepted = false; try { WaServerCert::verify(payload, server_static, mid); accepted = true; } catch (const std::exception&) { } check(accepted, "a genuine chain verifies inside its validity window"); bool expired = false; try { WaServerCert::verify(payload, server_static); } catch (const WaCertError& e) { expired = std::string(e.what()).find("expired") != std::string::npos; } check(expired, "the same chain is refused as expired today"); // Tamper with every verified property: each must fail closed with a reason. auto tampers = [&](const std::string& name, const Bytes& bad_payload, const Bytes& bad_static) { bool threw = false; try { WaServerCert::verify(bad_payload, bad_static, mid); } catch (const WaCertError&) { threw = true; } catch (const std::length_error&) { threw = false; } check(threw, "tampered chain refused: " + name); }; Bytes bad_sig = payload; bad_sig[bad_sig.size() - 1] ^= 0x01; tampers("flipped signature byte", bad_sig, server_static); Bytes bad_static = server_static; bad_static[0] ^= 0x01; tampers("wrong server static", payload, bad_static); tampers("truncated chain", Bytes(payload.begin(), payload.begin() + payload.size() / 2), server_static); tampers("empty chain", Bytes{}, server_static); tampers("empty static", payload, Bytes{}); tampers("garbage chain", Bytes(241, 0xab), server_static); // Hostile protobuf inside the chain: oversized lengths and overlong varints // must throw a handled error, never length_error, never UB. bool hostile_ok = true; for (const Bytes& evil : {Bytes{0x0a, 0xff, 0xff, 0xff, 0xff, 0x0f}, Bytes{0x12, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x7f}, Bytes{0x0a}, Bytes{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f}}) { try { WaServerCert::verify(evil, server_static, mid); } catch (const std::length_error&) { hostile_ok = false; } catch (const std::exception&) { } } check(hostile_ok, "hostile chain bytes rejected without length_error"); // A swapped leaf/intermediate must not verify (regression: the fields were swapped). WaCertChainParsed swapped; swapped.leaf_details = chain->intermediate_details; swapped.leaf_signature = chain->intermediate_signature; swapped.intermediate_details = chain->leaf_details; swapped.intermediate_signature = chain->leaf_signature; // Re-encode the swap through the writer to feed verify() bytes. ProtoWriter wl, wi, wc; wl.message(1, swapped.leaf_details); wl.bytes(2, swapped.leaf_signature); wi.message(1, swapped.intermediate_details); wi.bytes(2, swapped.intermediate_signature); wc.message(1, wl.take()); wc.message(2, wi.take()); bool swap_threw = false; try { WaServerCert::verify(wc.take(), server_static, mid); } catch (const WaCertError&) { swap_threw = true; } check(swap_threw, "a leaf/intermediate swap is refused"); } int main(int argc, char** argv) { DIR = argc > 1 ? argv[1] : "tests/whatsapp_data"; test_tokens(); test_iq(); test_keepalive(); test_server_cert(); 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(); test_key_request_rollback(); test_peer_helpers(); test_empty_key_share(); test_vcf_import(); test_push_name_learning(); test_store_m2(); test_store_hostile(); std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES"); return g_fail == 0 ? 0 : 1; }