Replaying the captured failing frame proved the std::vector max_size() abort was
never a parser bug. It was an object-lifetime bug in WaDevice::serialize():
put_blob(b, Bytes((jid ? *jid : "").begin(), (jid ? *jid : "").end()));
(jid ? *jid : "") builds a fresh temporary std::string for each evaluation, so
begin() and end() came from two different, already-destroyed objects; the range
constructor computed a garbage length and vector threw. It only misfired once
jid was set (after the phone scanned), which is why every parser test and the
initial unpaired save passed. Fixed by binding the value to a named const
std::string before taking iterators.
- src/whatsapp/wa_device.cpp: bind jid/lid before iterating; audited the tree
for the same two-temporaries pattern (no other instances).
- tests/whatsapp_test.cpp: test_device round-trips a PAIRED device (jid+lid set,
synthetic identifiers), the populated case that triggers the bug; runs under
tools/san-whatsapp.sh.
- docs/whatsapp.md 13.7: records the real root cause and closes the open item.
- docs/cpp-safety.md: adds the object-lifetime rule (never take iterators from a
temporary you rebuild) as a distinct bug class from size overflow.
Verified: replaying the captured pair-success frame now serializes, saves, and
encodes the response with no throw; build, whatsapp_test, make lint, and
tools/san-whatsapp.sh all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
377 lines
14 KiB
C++
377 lines
14 KiB
C++
// 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 <cstdio>
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#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"
|
|
|
|
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<char>(f)), std::istreambuf_iterator<char>());
|
|
}
|
|
static std::array<uint8_t, 32> a32(const Bytes& b) {
|
|
std::array<uint8_t, 32> 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<WaNode(const Value&)> 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<bool(const WaNode&, const WaNode&)> 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<Bytes>{{}, {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");
|
|
}
|
|
|
|
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_device();
|
|
test_hostile();
|
|
std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|