// 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 "json.h" #include "wa_binary.h" #include "wa_crypto.h" #include "wa_noise.h" #include "wa_proto.h" #include "wa_signal.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"); } int main(int argc, char** argv) { DIR = argc > 1 ? argv[1] : "tests/whatsapp_data"; test_crypto(); test_codec(); test_client_payload(); test_noise(); test_signal(); test_hostile(); std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES"); return g_fail == 0 ? 0 : 1; }