Files
tai/taicpp/tests/whatsapp_test.cpp
T
retoorandClaude Opus 4.8 79ae6f27da cpp-safety: safe-C++ playbook, fix a second UB, and a sanitizer net
Response to incident WA-2026-10-27 (the std::vector max_size() crash). Deep
research archived under research/safe-cpp-practices (8 Tier 1, 37 Tier 2, 2
conclusions) on production-safe C++ that keeps development speed: OpenSSF
compiler hardening, the C++ Core Guidelines bounds profile, integer-overflow
practice, libFuzzer/sanitizer CI, the sanitizer overhead ladder, clang-tidy
ratchets.

The research prescribed an ASan+UBSan+_GLIBCXX_ASSERTIONS build of the parsers.
Standing it up immediately caught a second, independent undefined-behavior bug
that two manual reviews had missed: a protobuf varint loop that shifted by up
to 70 bits (shift >= 64 is UB) on malformed input. Fixed and guarded by a
deterministic test and the sanitizer net.

- src/whatsapp/wa_proto.cpp: guard the wire-0 varint shift (the UB).
- tests/whatsapp_test.cpp: add an overlong-varint hostile case.
- tools/san-whatsapp.sh: the standing sanitizer net for detz::wa parsers.
- WaClient frame capture: a frame that fails to decode/dispatch has its exact
  bytes recorded to <home>/whatsapp/last-error.log (bounded), so the still
  unreproduced live <iq> failure becomes a one-paste deterministic fix. Wired
  from the wizard and the whatsapp_send tool.
- docs/cpp-safety.md: the project-wide playbook and design decisions (four
  layers: overflow-safe checked-reader parsing, cheap release hardening, the
  sanitizer test net, capture). docs/whatsapp.md section 13.6 records the
  follow-up and the honest open item. AGENTS.md makes it a standing rule;
  cap.md and mcp.md inherit it for their wire parsers.

Verified: build, whatsapp_test (incl. hostile+fuzz), make lint, and
tools/san-whatsapp.sh all green; research audit PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 19:03:32 +01:00

351 lines
13 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_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");
}
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;
}