// 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_crypto.h" #include "wa_device.h" #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; 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"); } // ---- 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"); // 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; }