Files
tai/taicpp/tests/whatsapp_test.cpp
T
2026-10-28 03:54:48 +01:00

1311 lines
63 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 <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <string>
#include "json.h"
#include "wa_binary.h"
#include "wa_client.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_pair_events.h"
#include "wa_pairing.h"
#include "wa_passkey.h"
#include "wa_qr_channel.h"
#include "wa_receiver.h"
#include "wa_store.h"
#include "wa_vcf.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");
}
// 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<Bytes> 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<Bytes> { 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<std::string> 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<std::string, std::string> got(contacts.begin(), contacts.end());
std::map<std::string, std::string> 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);
}
// ---- pairing oracle differentials (M3): every byte against pairing_vectors.json ----
static void test_pairing_oracle() {
std::printf("[pairing: Go oracle differentials]\n");
Value v = Value::loads(slurp(path("pairing_vectors.json")));
for (int n : {1, 2, 3, 4, 5, 6, 10}) {
std::string key = "b32_" + std::to_string(n);
check(WaPairing::linking_base32_encode(fromhex(v.get(key + "_hex").str())) == v.get(key).str(),
"linking base32 of " + std::to_string(n) + " bytes matches Go (pad included)");
}
std::string hello_code = v.get("hello_code").str();
check(WaPairing::format_linking_code(hello_code) == hello_code.substr(0, 4) + "-" + hello_code.substr(4),
"linking code formats XXXX-XXXX");
check(WaPairing::normalize_phone("+31 (6) 12-34-56-78") == "31612345678", "phone strips to digits");
try {
WaPairing::normalize_phone("12345");
check(false, "short phone throws");
} catch (const std::exception& e) {
check(std::string(e.what()).find("phone number too short") != std::string::npos, "short phone says too short");
}
try {
WaPairing::normalize_phone("06123456789");
check(false, "leading-zero phone throws");
} catch (const std::exception& e) {
check(std::string(e.what()).find("must not start with 0") != std::string::npos,
"leading-zero phone demands international");
}
const std::pair<WaPairClientType, char> chars[] = {
{WaPairClientType::kUnknown, '0'}, {WaPairClientType::kChrome, '1'},
{WaPairClientType::kEdge, '2'}, {WaPairClientType::kFirefox, '3'},
{WaPairClientType::kIE, '4'}, {WaPairClientType::kOpera, '5'},
{WaPairClientType::kSafari, '6'}, {WaPairClientType::kElectron, '7'},
{WaPairClientType::kUwp, '8'}, {WaPairClientType::kOtherWebClient, '9'},
{WaPairClientType::kMacOs, 'c'}, {WaPairClientType::kAndroid, 'e'},
};
bool chars_ok = true;
for (const auto& [t, c] : chars) chars_ok = chars_ok && wa_pair_client_char(t) == c;
check(chars_ok, "12 client-type wire characters match pair-code.go");
check(wa_default_qr_client_type(1, 0) == WaPairClientType::kChrome, "default client: device CHROME");
check(wa_default_qr_client_type(2, 0) == WaPairClientType::kFirefox, "default client: device FIREFOX");
check(wa_default_qr_client_type(6, 0) == WaPairClientType::kEdge, "default client: device EDGE");
check(wa_default_qr_client_type(16, 0) == WaPairClientType::kAndroid, "default client: device ANDROID_PHONE");
check(wa_default_qr_client_type(0, 14) == WaPairClientType::kOtherWebClient, "default client: WEB agent");
check(wa_default_qr_client_type(0, 24) == WaPairClientType::kMacOs, "default client: MACOS agent");
check(wa_default_qr_client_type(0, 0) == WaPairClientType::kUnknown, "default client: unknown stays unknown");
Bytes noise_pub = fromhex(v.get("qr_noise_hex").str());
Bytes ident_pub = fromhex(v.get("qr_ident_hex").str());
Bytes adv = fromhex(v.get("qr_adv_hex").str());
check(WaPairing::make_qr_data(fromhex(v.get("qr_ref_hex").str()), noise_pub, ident_pub, adv, '1') ==
v.get("qr").str(),
"QR string matches makeQRData byte for byte");
check(WaPairing::linking_base32_encode(fromhex(v.get("hello_code_raw_hex").str())) == hello_code,
"hello linking code matches Go");
check(tohex(WaPairing::wrap_companion_ephemeral(fromhex(v.get("hello_salt_hex").str()),
fromhex(v.get("hello_iv_hex").str()), hello_code,
fromhex(v.get("hello_eph_pub_hex").str()))) ==
v.get("hello_ephemeral_hex").str(),
"hello ephemeral wrap (PBKDF2+CTR) matches Go (80 bytes)");
WaPairing::CodePairingResult fin = WaPairing::finish_code_pairing(
v.get("finish_code").str(), fromhex(v.get("finish_wrapped_primary_hex").str()),
fromhex(v.get("finish_primary_ident_pub_hex").str()), a32(fromhex(v.get("finish_own_eph_priv_hex").str())),
a32(fromhex(v.get("finish_own_ident_priv_hex").str())), fromhex(v.get("finish_adv_random_hex").str()),
fromhex(v.get("finish_bundle_salt_hex").str()), fromhex(v.get("finish_bundle_nonce_hex").str()));
check(tohex(fin.wrapped_key_bundle) == v.get("finish_wrapped_bundle_hex").str(),
"code-pairing key bundle matches Go");
check(tohex(fin.adv_secret) == v.get("finish_adv_secret_hex").str(), "code-pairing ADV secret matches Go");
std::string ref = v.get("pass_ref").str();
WaPasskey::Prologue pro = WaPasskey::build_prologue(fromhex(v.get("pass_eph_pub_hex").str()), 0, ref,
fromhex(v.get("pass_companion_nonce_hex").str()));
check(tohex(pro.ident) == v.get("proto_ident_hex").str(), "CompanionEphemeralIdentity bytes match Go");
check(tohex(pro.commitment) == v.get("proto_commitment_hex").str(), "prologue commitment matches Go");
check(tohex(pro.prologue) == v.get("proto_prologue_hex").str(), "ProloguePayload bytes match Go");
check(tohex(WaPasskey::derive_handoff_key(adv)) == v.get("pass_handoff_hex").str(), "handoff key matches Go");
check(tohex(WaPasskey::derive_encryption_key(fromhex(v.get("pass_shared_hex").str()), 0, ref)) ==
v.get("pass_enc_key_hex").str(),
"passkey encryption key matches Go");
check(WaPasskey::derive_confirmation_code(fromhex(v.get("pass_primary_nonce_hex").str()),
fromhex(v.get("pass_companion_nonce_hex").str()),
fromhex(v.get("pass_primary_pub_hex").str())) ==
v.get("pass_confirm_code").str(),
"passkey confirmation code matches Go");
check(tohex(WaPasskey::seal_pairing_request(fromhex(v.get("pass_enc_key_hex").str()),
fromhex(v.get("pass_req_iv_hex").str()), noise_pub, ident_pub, adv)) ==
v.get("proto_encrypted_req_hex").str(),
"sealed PairingRequest matches Go");
WaPairingRequest req;
req.companion_public_key = noise_pub;
req.companion_identity_key = ident_pub;
req.adv_secret = adv;
check(tohex(req.encode()) == v.get("proto_pairing_request_hex").str(), "PairingRequest bytes match Go");
WaCompanionCommitment cc;
cc.hash = fromhex(v.get("proto_commitment_hex").str());
check(tohex(cc.encode()) == v.get("proto_commitment_msg_hex").str(), "CompanionCommitment bytes match Go");
WaPrimaryEphemeralIdentity prim = parse_primary_ephemeral_identity(fromhex(v.get("proto_primary_ident_hex").str()));
check(tohex(prim.public_key) == v.get("pass_primary_pub_hex").str(), "PrimaryEphemeralIdentity pub parses");
check(tohex(prim.nonce) == v.get("pass_primary_nonce_hex").str(), "PrimaryEphemeralIdentity nonce parses");
WaClientPairingProps props = parse_client_pairing_props(fromhex(v.get("proto_props_hex").str()));
check(props.has_chat_db_lid_migrated && props.chat_db_lid_migrated, "props: lid-migrated true present");
check(props.has_syncd_pure_lid_session && !props.syncd_pure_lid_session, "props: pure-lid false present");
check(props.has_snapshot_recovery && props.snapshot_recovery, "props: snapshot-recovery true present");
check(!props.has_hs_thumbnail_sync, "props: thumbnail-sync absent stays absent");
check(tohex(props.subscription_sync_payload) == "09080706", "props: subscription payload parses");
// WebAuthn: Go's exact JSON out of our marshaler, Go's exact bytes out of our parser.
Value resp_json = Value::loads(v.get("webauthn_resp_json").str());
Value resp_data = resp_json.get("response");
WaWebAuthnResponse resp;
resp.id = resp_json.get("id").str();
resp.raw_id = WaPasskey::base64url_decode(resp_json.get("rawId").str());
resp.type = resp_json.get("type").str();
resp.client_data_json = WaPasskey::base64url_decode(resp_data.get("clientDataJSON").str());
resp.authenticator_data = WaPasskey::base64url_decode(resp_data.get("authenticatorData").str());
resp.signature = WaPasskey::base64url_decode(resp_data.get("signature").str());
check(WaPasskey::marshal_response(resp) == v.get("webauthn_resp_json").str(),
"WebAuthnResponse marshal matches Go byte for byte (null userHandle)");
resp.user_handle = WaPasskey::base64url_decode(
Value::loads(v.get("webauthn_resp_uh_json").str()).get("response").get("userHandle").str());
check(WaPasskey::marshal_response(resp) == v.get("webauthn_resp_uh_json").str(),
"WebAuthnResponse marshal matches Go with userHandle");
WaWebAuthnResponse nasty{"a\"b\\c<d>e&f", "public-key", {1}, {2}, {3}, {4}, std::nullopt};
check(WaPasskey::marshal_response(nasty) == v.get("webauthn_resp_nasty_json").str(),
"WebAuthnResponse escapes like Go's encoding/json");
std::string pub_json = v.get("webauthn_pub_json").str();
WaWebAuthnPublicKey pub = WaPasskey::parse_public_key(Bytes(pub_json.begin(), pub_json.end()));
check(tohex(pub.challenge) == v.get("webauthn_ch_hex").str(), "WebAuthnPublicKey challenge bytes match Go");
check(pub.timeout == 60000 && pub.rp_id == "whatsapp.com", "WebAuthnPublicKey timeout/rpId parse");
check(pub.allow.size() == 1 && pub.allow[0].type == "public-key", "WebAuthnPublicKey allowCredentials parse");
check(WaPasskey::base64url_encode(pub.allow[0].id) ==
Value::loads(pub_json).get("allowCredentials").at(0).get("id").str(),
"WebAuthnPublicKey credential id round-trips through base64url");
check(pub.allow[0].transports.size() == 2 && pub.user_verification == "preferred",
"WebAuthnPublicKey transports/userVerification parse");
check(!pub.extensions_json.empty(), "WebAuthnPublicKey extensions carried opaquely");
}
// ---- pairing node shapes and error texts (M3): structural, against pair*.go ----
static WaNode bytes_child(const std::string& tag, const Bytes& b) {
WaNode n(tag);
n.content = b;
return n;
}
static void test_pairing_nodes() {
std::printf("[pairing: node shapes and errors]\n");
Bytes eph80(80, 7), noise32(32, 1), ident32(32, 2);
WaNode hello = WaPairing::build_hello_iq("1.1", "31612345678@s.whatsapp.net", true, eph80, noise32, '1', "Chrome (Linux)");
check(hello.tag == "iq" && hello.attr("xmlns") == "md" && hello.attr("type") == "set", "hello iq envelope");
const WaNode* reg = hello.child("link_code_companion_reg");
check(reg != nullptr && reg->attr("stage") == "companion_hello", "hello stage + tag");
check(reg->attr("should_show_push_notification") == "true", "hello push flag renders true");
check(reg->child("link_code_pairing_wrapped_companion_ephemeral_pub") != nullptr, "hello carries wrapped key");
check(reg->child("companion_server_auth_key_pub")->bytes()->size() == 32, "hello carries noise pub");
check(reg->child("companion_platform_id")->text() == "1", "hello carries client type");
check(reg->child("link_code_pairing_nonce")->bytes()->size() == 1, "hello carries the zero nonce");
WaNode reply("iq");
reply.content = WaNode::Children{bytes_child("link_code_companion_reg", {})};
WaNode::Children reg_kids{bytes_child("link_code_pairing_ref", Bytes{'r', 'e', 'f'})};
const_cast<WaNode*>(reply.child("link_code_companion_reg"))->content = reg_kids;
check(WaPairing::parse_pairing_ref(reply) == "ref", "pairing ref parses");
try {
WaPairing::parse_pairing_ref(WaNode("iq"));
check(false, "missing reg throws");
} catch (const WaElementMissingError& e) {
check(std::string(e.what()) ==
"missing <link_code_companion_reg> element in code link registration response",
"ElementMissingError text matches Go");
}
WaNode fin = WaPairing::build_finish_iq("2.2", "316@s.whatsapp.net", Bytes(100, 9), ident32, "ref");
check(fin.child("link_code_companion_reg")->attr("stage") == "companion_finish", "finish stage");
check(fin.child("link_code_companion_reg")->child("companion_identity_public")->bytes()->size() == 32,
"finish carries identity");
WaNode note("notification");
WaNode::Children note_kids{bytes_child("link_code_pairing_ref", Bytes{'r'}),
bytes_child("link_code_pairing_wrapped_primary_ephemeral_pub", Bytes(80, 3)),
bytes_child("primary_identity_pub", Bytes(32, 4))};
WaNode reg_node("link_code_companion_reg");
reg_node.content = note_kids;
note.content = WaNode::Children{reg_node};
WaPairing::CodeNotification cn = WaPairing::parse_code_notification(note);
check(cn.pairing_ref == "r" && cn.wrapped_primary.size() == 80 && cn.primary_identity_pub.size() == 32,
"code notification parses");
try {
WaPairing::parse_code_notification(WaNode("notification"));
check(false, "bare notification throws");
} catch (const WaElementMissingError&) {
check(true, "bare notification throws ElementMissingError");
}
WaNode perr = WaPairing::build_pair_error("9.9", 401, "hmac-mismatch");
check(perr.attr("type") == "error" && perr.child("error")->attr("code") == "401", "pair-error node shape");
check(perr.child("error")->attr("text") == "hmac-mismatch", "pair-error text");
WaNode lout = WaPairing::build_logout_iq("3.3", "316:7@s.whatsapp.net");
const WaNode* rcd = lout.child("remove-companion-device");
check(lout.attr("xmlns") == "md" && rcd != nullptr && rcd->attr("reason") == "user_initiated",
"logout iq shape (Client.Logout)");
check(WaPasskey::build_ref_iq("4.4").child("ref") != nullptr, "companion ref iq");
check(WaPasskey::build_options_iq("5.5").child("passkey_request_options") != nullptr, "passkey options iq");
WaNode ref_reply("iq");
ref_reply.content = WaNode::Children{bytes_child("ref", Bytes{'a', 'b'})};
check(WaPasskey::parse_ref(ref_reply) == "ab", "companion ref parses");
WaNode pro = WaPasskey::build_prologue_iq("6.6", Bytes{1}, "{\"a\":1}", Bytes{2}, Bytes(32, 5));
const WaNode* pp = pro.child("passkey_prologue");
check(pp != nullptr && pp->children()->size() == 4, "prologue iq carries the handoff proof");
WaNode pro2 = WaPasskey::build_prologue_iq("6.6", Bytes{1}, "{\"a\":1}", Bytes{2}, Bytes{});
check(pro2.child("passkey_prologue")->children()->size() == 3, "prologue iq omits a missing proof");
check(WaPasskey::build_nonce_iq("7.7", Bytes(32, 6)).child("companion_nonce")->bytes()->size() == 32,
"companion nonce iq");
check(WaPasskey::build_encrypted_request_iq("8.8", Bytes{9}).child("encrypted_pairing_request") != nullptr,
"encrypted pairing request iq");
check(wa_pair_err_message(WaPairErr::kInvalidDeviceIdentityHmac) ==
"invalid device identity HMAC in pair success message",
"pair HMAC error text matches Go");
check(wa_pair_err_message(WaPairErr::kRejectedLocally) == "local PrePairCallback rejected pairing",
"pair reject error text matches Go");
WaPairProtoError pe("failed to parse x", "boom");
check(std::string(pe.what()) == "failed to parse x: boom" && pe.unwrap() == "boom", "PairProtoError Error+Unwrap");
WaPairDatabaseError de("failed to save x", "db gone");
check(std::string(de.what()) == "failed to save x: db gone" && de.unwrap() == "db gone",
"PairDatabaseError Error+Unwrap");
WaPasskeyHandoffKey hk(Bytes(32, 1), 1000);
check(hk.valid(1000) && hk.valid(1299) && !hk.valid(1300), "handoff key lives 5 minutes");
check(!hk.valid(999), "handoff key rejects a backwards clock");
check(!WaPasskeyHandoffKey(Bytes{}, 1000).valid(1000), "empty handoff key never valid");
}
// ---- QR channel behavior (M3): fake clock, no threads ----
static void test_qr_channel() {
std::printf("[pairing: QR channel]\n");
std::vector<WaQrChannel::Item> got;
WaQrChannel ch([&](const WaQrChannel::Item& i) { got.push_back(i); });
ch.poll(1000);
check(got.empty(), "silent before the first QR event");
ch.feed_qr({"c1", "c2", "c3", "c4", "c5", "c6"});
ch.poll(1000);
check(got.size() == 1 && got[0].code == "c1" && got[0].timeout_seconds == 60, "first of six lives 60s");
ch.poll(1059);
check(got.size() == 1, "no early re-emit");
ch.rotate_adv("c1", "c1x");
ch.poll(1059);
check(got.size() == 2 && got[1].code == "c1x" && got[1].timeout_seconds == 60,
"ADV rotation re-emits the rewritten code at once (fresh 60s on a full batch)");
ch.poll(1118);
check(got.size() == 2, "rotated code lives its fresh timeout");
ch.poll(1119);
check(got.size() == 3 && got[2].code == "c2" && got[2].timeout_seconds == 20, "second code follows after 20s");
check(!ch.closed(), "open while codes remain");
ch.feed_pair_success();
check(ch.closed() && got.back().event == "success", "pair success closes with success");
ch.feed_qr({"late"});
ch.poll(9999);
check(got.back().event == "success", "feeds after close are dropped");
std::vector<WaQrChannel::Item> got2;
WaQrChannel ch2([&](const WaQrChannel::Item& i) { got2.push_back(i); });
ch2.feed_qr({"only"});
ch2.poll(0);
check(got2.size() == 1 && got2[0].timeout_seconds == 20, "a lone code lives 20s");
ch2.poll(20);
check(ch2.closed() && got2.back().event == "timeout", "exhaustion closes with timeout");
std::vector<WaQrChannel::Item> got3;
WaQrChannel ch3([&](const WaQrChannel::Item& i) { got3.push_back(i); });
ch3.feed_scanned_without_multidevice();
check(!ch3.closed() && got3.back().event == "err-scanned-without-multidevice", "multidevice hint is not terminal");
ch3.feed_client_outdated();
check(ch3.closed() && got3.back().event == "err-client-outdated", "outdated closes the channel");
std::vector<WaQrChannel::Item> got4;
bool confirmed = false;
WaQrChannel ch4([&](const WaQrChannel::Item& i) { got4.push_back(i); });
ch4.set_auto_confirm([&]() { confirmed = true; });
WaPairPasskeyConfirmation skip{"AAAA-AAAA", true};
ch4.feed_passkey_confirmation(skip);
check(confirmed && got4.empty(), "skip-handoff confirmation auto-confirms without an item");
WaPairPasskeyConfirmation show{"BBBB-BBBB", false};
ch4.feed_passkey_confirmation(show);
check(!ch4.closed() && got4.size() == 1 && got4[0].event == WaQrChannel::kEventPasskeyResponse &&
got4[0].passkey_confirmation->code == "BBBB-BBBB",
"shown confirmation emits for the user");
ch4.feed_disconnected();
check(ch4.closed() && got4.back().event == "timeout", "disconnect mid-pairing is the timeout item");
check(WaQrChannel::err_unexpected_event().event == "err-unexpected-state", "unexpected-event item text");
}
// ---- pairing hostile input (M3): every new parser, same bar as test_hostile ----
static void test_pairing_hostile() {
std::printf("[pairing: hostile input]\n");
bool ok = true;
auto hostile = [&](auto&& f) {
try {
f();
} catch (const std::length_error&) {
ok = false;
} catch (const std::exception&) {
}
};
// pairing protos: oversized lengths, truncations, wrong field sizes.
hostile([]() { parse_client_pairing_props(Bytes{0x2a, 0xff, 0xff, 0xff, 0xff, 0x0f}); });
hostile([]() { parse_client_pairing_props(Bytes{0x0a}); });
hostile([]() { parse_client_pairing_props(Bytes{}); });
hostile([]() { parse_primary_ephemeral_identity(Bytes{}); });
hostile([]() { parse_primary_ephemeral_identity(Bytes{0x0a, 0x20}); });
hostile([]() {
Bytes bad{0x0a, 0x1f};
bad.insert(bad.end(), 31, 0x01);
bad.push_back(0x12);
bad.push_back(0x20);
bad.insert(bad.end(), 32, 0x02);
parse_primary_ephemeral_identity(bad); // 31-byte pub must throw, not slice
});
hostile([]() { parse_companion_commitment(Bytes{0x0a, 0xff, 0xff, 0xff, 0xff, 0x0f}); });
check(ok, "pairing protos reject hostile bytes without length_error");
// passkey JSON: truncation, over-cap blobs, bad base64url, wrong types.
ok = true;
hostile([]() { WaPasskey::parse_public_key(Bytes{0x7b, 0x22}); });
hostile([]() { WaPasskey::parse_public_key(Bytes(70000, 0x7b)); });
hostile([]() {
std::string j = "{\"challenge\":\"!!!not-base64!!!\",\"timeout\":1}";
WaPasskey::parse_public_key(Bytes(j.begin(), j.end()));
});
hostile([]() {
std::string j = "{\"challenge\":[1,2],\"timeout\":1}";
WaPasskey::parse_public_key(Bytes(j.begin(), j.end()));
});
hostile([]() {
std::string j = "{\"challenge\":\"AQ\",\"timeout\":\"soon\"}";
WaPasskey::parse_public_key(Bytes(j.begin(), j.end()));
});
hostile([]() {
std::string j = "{\"challenge\":\"AQ\",\"allowCredentials\":{}}";
WaPasskey::parse_public_key(Bytes(j.begin(), j.end()));
});
hostile([]() {
std::string j = "[1,2,3]";
WaPasskey::parse_public_key(Bytes(j.begin(), j.end()));
});
hostile([]() { WaPasskey::base64url_decode("a"); });
hostile([]() { WaPasskey::base64url_decode("a==="); });
hostile([]() { WaPasskey::base64url_decode("!!!!"); });
check(ok, "passkey JSON/base64url reject hostile bytes without length_error");
// pairing crypto inputs: short/long secrets and keys throw with a reason.
ok = true;
hostile([]() { WaPairing::finish_code_pairing("ABCDEFGH", Bytes(79, 1), Bytes(32, 2), a32(Bytes(32, 3)),
a32(Bytes(32, 4)), Bytes(32, 5), Bytes(32, 6), Bytes(12, 7)); });
hostile([]() { WaPairing::finish_code_pairing("ABCDEFGH", Bytes(80, 1), Bytes(31, 2), a32(Bytes(32, 3)),
a32(Bytes(32, 4)), Bytes(32, 5), Bytes(32, 6), Bytes(12, 7)); });
hostile([]() { WaPairing::wrap_companion_ephemeral(Bytes(31, 1), Bytes(16, 2), "ABCDEFGH", Bytes(32, 3)); });
hostile([]() { WaPairing::format_linking_code("ABCDEFG"); });
hostile([]() { WaPasskey::derive_handoff_key(Bytes(31, 1)); });
hostile([]() { WaPasskey::derive_encryption_key(Bytes(31, 1), 0, "ref"); });
hostile([]() { WaPasskey::seal_pairing_request(Bytes(32, 1), Bytes(11, 2), Bytes(32, 3), Bytes(32, 4), Bytes(32, 5)); });
hostile([]() { WaPairing::parse_code_notification(WaNode("notification")); });
hostile([]() { WaPasskey::parse_continuation_node(WaNode("notification")); });
hostile([]() { WaPasskey::parse_options_node(WaNode("notification")); });
hostile([]() { WaPairing::parse_pairing_ref(WaNode("iq")); });
hostile([]() { WaPasskey::parse_ref(WaNode("iq")); });
check(ok, "pairing crypto/node inputs reject bad sizes without length_error");
// fuzz: mutated oracle bytes through every new parser; surviving is the proof.
Value v = Value::loads(slurp(path("pairing_vectors.json")));
std::vector<Bytes> seeds = {
fromhex(v.get("proto_props_hex").str()),
fromhex(v.get("proto_primary_ident_hex").str()),
Bytes(v.get("webauthn_pub_json").str().begin(), v.get("webauthn_pub_json").str().end()),
fromhex(v.get("finish_wrapped_primary_hex").str()),
};
uint64_t rng = 0x12345678;
auto next = [&]() {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
return rng;
};
ok = true;
for (int i = 0; i < 3000; ++i) {
Bytes m = seeds[i % seeds.size()];
if (!m.empty()) {
switch (next() % 3) {
case 0:
m[next() % m.size()] ^= static_cast<uint8_t>(1 + next() % 255);
break;
case 1:
m.resize(next() % (m.size() + 1));
break;
default:
m.insert(m.begin() + (next() % (m.size() + 1)), static_cast<uint8_t>(next() % 256));
break;
}
}
hostile([&]() { parse_client_pairing_props(m); });
hostile([&]() { parse_primary_ephemeral_identity(m); });
hostile([&]() { parse_companion_commitment(m); });
hostile([&]() { WaPasskey::parse_public_key(m); });
hostile([&]() { WaPasskey::base64url_decode(std::string(m.begin(), m.end())); });
}
check(ok, "3000 mutated pairing inputs: no length_error, no crash");
}
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();
test_key_request_rollback();
test_peer_helpers();
test_empty_key_share();
test_vcf_import();
test_push_name_learning();
test_pairing_oracle();
test_pairing_nodes();
test_qr_channel();
test_pairing_hostile();
std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES");
return g_fail == 0 ? 0 : 1;
}