2942 lines
146 KiB
C++
2942 lines
146 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 <functional>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
#include "json.h"
|
|
#include "wa_binary.h"
|
|
#include "wa_cert.h"
|
|
#include "wa_client.h"
|
|
#include "wa_consts.h"
|
|
#include "wa_crypto.h"
|
|
#include "wa_device.h"
|
|
#include "wa_iq.h"
|
|
#include "wa_keepalive.h"
|
|
#include "wa_noise.h"
|
|
#include "wa_proto.h"
|
|
#include "wa_signal.h"
|
|
#include "wa_appstate.h"
|
|
#include "wa_consts.h"
|
|
#include "wa_jid.h"
|
|
#include "wa_lidmap.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_mediaconn.h"
|
|
#include "wa_mediaerror.h"
|
|
#include "wa_mediatype.h"
|
|
#include "wa_sticker.h"
|
|
#include "wa_receiver.h"
|
|
#include "wa_store.h"
|
|
#include "wa_tokens_data.h"
|
|
#include "wa_vcf.h"
|
|
#include "sqldb.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");
|
|
|
|
// 5. M1: frame reassembly with hostile cursor/length values yields "no
|
|
// frame yet", never a crash and never a wraparound slice.
|
|
ok = true;
|
|
try {
|
|
Bytes buf = {0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o'};
|
|
size_t pos = 99; // cursor past the end: caller bug, still handled
|
|
if (WaFraming::unframe(buf, pos).has_value()) ok = false;
|
|
pos = 6; // 2 bytes left, fewer than the 3-byte length prefix
|
|
if (WaFraming::unframe(buf, pos).has_value() || pos != 6) ok = false;
|
|
Bytes huge = {0xff, 0xff, 0xff}; // length 16M-1 claimed, 0 bytes present
|
|
pos = 0;
|
|
if (WaFraming::unframe(huge, pos).has_value() || pos != 0) ok = false;
|
|
Bytes empty;
|
|
pos = 0;
|
|
if (WaFraming::unframe(empty, pos).has_value()) ok = false;
|
|
pos = 0; // exact frame still parses after the hostile attempts
|
|
auto fr = WaFraming::unframe(buf, pos);
|
|
if (!fr || fr->size() != 5 || pos != 8) ok = false;
|
|
} catch (const std::exception&) {
|
|
ok = false; // unframe never throws; incomplete input is a routine negative
|
|
}
|
|
check(ok, "hostile frame cursors/lengths yield no frame, never 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);
|
|
}
|
|
|
|
// M2 store: replays the op sequence of tests/whatsapp_data/store_oracle.go
|
|
// (which runs pinned whatsmeow itself) against WaStore/WaLidMap/WaJid and
|
|
// compares every observable outcome to tests/whatsapp_data/store_vectors.json.
|
|
// Signal addresses use the detz form ("user.device") where the oracle uses
|
|
// whatsmeow's ("user:device"); the vectors record outcomes, never addresses.
|
|
// JID strings are compared byte-for-byte.
|
|
static void test_store_m2() {
|
|
std::printf("[store: M2 oracle differential]\\n");
|
|
Value v = Value::loads(slurp(path("store_vectors.json")));
|
|
auto kb = [&](const std::string& k) { return v.get(k).as_bool(); };
|
|
auto ki = [&](const std::string& k) { return v.get(k).as_int(); };
|
|
auto ks = [&](const std::string& k) { return v.get(k).str(); };
|
|
auto key = [](uint8_t fill) { return Bytes(32, fill); };
|
|
|
|
check(ki("muted_forever_unix") == WaConsts::kWaMutedForever, "kWaMutedForever equals whatsmeow MutedForever.Unix()");
|
|
|
|
// JID normalization (pure WaJid checks against the oracle's types.JID).
|
|
bool jids_ok = true;
|
|
for (const auto& m : v.get("jid_non_ad").members()) {
|
|
if (WaJid::parse(m.first).non_ad().str() != m.second.str()) jids_ok = false;
|
|
}
|
|
check(jids_ok, "ToNonAD strings match whatsmeow ParseJID+ToNonAD byte-for-byte");
|
|
bool sig_ok = true;
|
|
for (const auto& m : v.members()) {
|
|
if (m.first.rfind("signal_user:", 0) == 0 && WaJid::parse(m.first.substr(12)).signal_user() != m.second.str())
|
|
sig_ok = false;
|
|
}
|
|
check(sig_ok, "signal users match whatsmeow SignalAddressUser byte-for-byte");
|
|
bool parse_ok = true;
|
|
for (const auto& m : v.get("jid_parse_fails").members()) {
|
|
bool threw = false;
|
|
try {
|
|
WaJid::parse(m.first);
|
|
} catch (const std::invalid_argument&) {
|
|
threw = true;
|
|
}
|
|
if (threw != m.second.as_bool()) parse_ok = false;
|
|
}
|
|
check(parse_ok, "WaJid::parse fails exactly where whatsmeow ParseJID fails");
|
|
|
|
std::string home = make_temp_home();
|
|
WaStore store(home, "m2");
|
|
// Miss-safe readers: a lookup that should hit but misses yields "<miss>"
|
|
// (a loud FAIL), never an operator* on a disengaged optional.
|
|
auto sess_hex = [&](const std::string& a) {
|
|
auto o = store.load_session(a);
|
|
return o ? tohex(*o) : std::string("<miss>");
|
|
};
|
|
auto sec_hex = [&](const std::string& c, const std::string& s, const std::string& id) {
|
|
auto o = store.message_secret(c, s, id);
|
|
return o ? tohex(o->secret) : std::string("<miss>");
|
|
};
|
|
auto tok_hex = [&](const std::string& u) {
|
|
auto o = store.privacy_token(u);
|
|
return o ? tohex(o->token) : std::string("<miss>");
|
|
};
|
|
|
|
// Identities with trust tracking. Detz addresses use '.', upstream ':'.
|
|
check(store.is_trusted_identity("15551234567.0", key(1)) == kb("trust_unknown"), "unknown identity is trusted (TOFU)");
|
|
store.put_identity("15551234567.0", key(1));
|
|
check(store.is_trusted_identity("15551234567.0", key(1)) == kb("trust_same"), "stored identity is trusted for its key");
|
|
check(store.is_trusted_identity("15551234567.0", key(2)) == kb("trust_other"), "stored identity is not trusted for another key");
|
|
store.put_identity("15551234567.0", key(2));
|
|
check(!store.is_trusted_identity("15551234567.0", key(1)), "overwritten identity distrusts the old key");
|
|
check(store.is_trusted_identity("15551234567.0", key(2)), "overwritten identity trusts the new key");
|
|
store.put_identity("15551234567.5", key(3));
|
|
store.delete_identity("15551234567.0");
|
|
check(store.is_trusted_identity("15551234567.0", key(2)) == kb("trust_after_delete"), "deleted identity is unknown again");
|
|
check(store.is_trusted_identity("15551234567.5", key(3)) == kb("trust_sibling_after_delete"),
|
|
"deleting one device keeps the sibling");
|
|
store.delete_all_identities("15551234567");
|
|
check(store.is_trusted_identity("15551234567.5", key(3)) == kb("trust_sibling_after_delete_all"),
|
|
"delete-all clears every device of the phone");
|
|
// Upstream DeleteIdentity uses the LIKE query by accident, so deleting
|
|
// "9%9:0" also wiped "999:0" (oracle trust_pct_sibling_after_pct_delete is
|
|
// true, i.e. unknown). detz deletes exactly: the sibling row survives.
|
|
check(kb("trust_pct_sibling_after_pct_delete"), "oracle records the upstream LIKE over-delete");
|
|
store.put_identity("9%9.0", key(4));
|
|
store.put_identity("999.0", key(5));
|
|
store.delete_identity("9%9.0");
|
|
check(store.is_trusted_identity("9%9.0", key(4)), "the deleted wildcard address is unknown again");
|
|
check(store.is_trusted_identity("999.0", key(5)) && !store.is_trusted_identity("999.0", key(1)),
|
|
"delete_identity is exact: a LIKE-special address deletes one row, not the pattern");
|
|
|
|
// Sessions.
|
|
check(store.has_session("15550001111.0") == kb("has_session_missing"), "no session for an unknown address");
|
|
store.save_session("15550001111.0", Bytes{'s', 'e', 's', 's', '-', 'a', 'a', 'a'});
|
|
store.save_session("15550001111.2", Bytes{'s', 'e', 's', 's', '-', 'b', 'b', 'b'});
|
|
check(store.has_session("15550001111.0") == kb("has_session_present"), "a stored session is found");
|
|
check(sess_hex("15550001111.0") == ks("get_session"), "session bytes round-trip");
|
|
check(!store.load_session("15550009999.0").has_value() == kb("get_session_missing_null"), "missing session is empty");
|
|
auto many = store.get_many_sessions({"15550001111.0", "15550009999.0", "15550001111.2"});
|
|
auto many_it = many.find("15550001111.0");
|
|
check(many_it != many.end() && many_it->second && tohex(*many_it->second) == ks("get_many_present"),
|
|
"get-many returns the stored blob");
|
|
check(!many["15550009999.0"].has_value() == kb("get_many_missing_null"), "get-many reports the miss as empty");
|
|
check((many.size() == 3) == kb("get_many_all_keys"), "get-many holds every requested address");
|
|
check(store.get_many_sessions({}).empty() == kb("get_many_empty_null"), "get-many of nothing is empty");
|
|
store.put_many_sessions({{"15550002222.0", Bytes{'s', 'e', 's', 's', '-', 'c', 'c', 'c'}}});
|
|
check(sess_hex("15550002222.0") == ks("get_many_put"), "put-many stores");
|
|
store.delete_session("15550001111.2");
|
|
check(store.has_session("15550001111.2") == kb("has_session_after_delete"), "delete removes one session");
|
|
store.delete_all_sessions("15550001111");
|
|
check(store.has_session("15550001111.0") == kb("has_session_after_delete_all"), "delete-all clears the phone");
|
|
check(store.has_session("15550002222.0") == kb("has_other_after_delete_all"), "delete-all keeps other phones");
|
|
|
|
// PN -> LID migration across sessions, identities and sender keys. Sender
|
|
// keys have no M2 API (M4 owns them); the row is planted with raw SQL and
|
|
// read back the same way, so the migrate arm is still proven.
|
|
store.save_session("15550003333.0", Bytes{'s', 'e', 's', 's', '-', 'm', 'i', 'g'});
|
|
store.put_identity("15550003333.0", key(6));
|
|
{
|
|
detz::store::SqlDb raw(WaStore::db_path(home, "m2"));
|
|
raw.execute("INSERT INTO wa_sender_key (chat_id, sender_id, key) VALUES ('group@g.us', '15550003333.0', '73656e6465722d6d6967')");
|
|
raw.commit();
|
|
}
|
|
auto sender_key = [&](const std::string& chat, const std::string& sender) -> std::optional<std::string> {
|
|
detz::store::SqlDb raw(WaStore::db_path(home, "m2"));
|
|
auto cur = raw.execute("SELECT key FROM wa_sender_key WHERE chat_id=? AND sender_id=?",
|
|
{detz::store::Cell(chat), detz::store::Cell(sender)});
|
|
const auto* row = cur.first();
|
|
std::optional<std::string> out;
|
|
if (row != nullptr) out = (*row)[0].text();
|
|
return out;
|
|
};
|
|
store.migrate_pn_to_lid("15550003333@s.whatsapp.net", "100000000000001@lid");
|
|
check(ks("mig_lid_addr") == "100000000000001_1:0", "oracle pins the upstream agent-suffixed LID address");
|
|
check(sess_hex("100000000000001.0") == ks("mig_session_moved"), "migrate moves the session to the plain-user LID");
|
|
check(!store.load_session("15550003333.0").has_value() == kb("mig_session_gone"), "migrate deletes the PN session");
|
|
check(store.is_trusted_identity("100000000000001.0", key(6)) == kb("mig_identity_moved"), "migrate moves the identity");
|
|
check(store.is_trusted_identity("15550003333.0", key(6)) == kb("mig_identity_gone"), "migrate deletes the PN identity");
|
|
check(sender_key("group@g.us", "100000000000001.0").value_or("") == ks("mig_sender_moved"),
|
|
"migrate moves the sender key to the LID");
|
|
check(!sender_key("group@g.us", "15550003333.0").has_value() == kb("mig_sender_gone"), "migrate deletes the PN sender key");
|
|
store.migrate_pn_to_lid("15550003333@s.whatsapp.net", "100000000000001@lid");
|
|
check(sess_hex("100000000000001.0") == ks("mig_repeat_still_moved"), "a repeated migrate is a no-op");
|
|
|
|
// LID map.
|
|
WaLidMap& lids = store.lid_map();
|
|
check(!lids.lid_for_pn("15551112222@s.whatsapp.net").has_value() == kb("lid_miss_empty"), "unmapped PN misses without error");
|
|
lids.put_mapping("100000000000002@lid", "15551112222@s.whatsapp.net");
|
|
check(lids.lid_for_pn("15551112222@s.whatsapp.net").value_or("") == ks("lid_hit"), "mapped PN hits its LID");
|
|
check(lids.lid_for_pn("15551112222:3@s.whatsapp.net").value_or("") == ks("lid_hit_device"),
|
|
"the device survives the PN->LID lookup");
|
|
check(lids.pn_for_lid("100000000000002@lid").value_or("") == ks("pn_hit"), "the reverse lookup hits the PN");
|
|
bool lid_rej = false, pn_rej = false, swap_rej = false;
|
|
try {
|
|
lids.lid_for_pn("100000000000002@lid");
|
|
} catch (const std::invalid_argument&) {
|
|
lid_rej = true;
|
|
}
|
|
try {
|
|
lids.pn_for_lid("15551112222@s.whatsapp.net");
|
|
} catch (const std::invalid_argument&) {
|
|
pn_rej = true;
|
|
}
|
|
try {
|
|
lids.put_mapping("15551112222@s.whatsapp.net", "100000000000002@lid");
|
|
} catch (const std::invalid_argument&) {
|
|
swap_rej = true;
|
|
}
|
|
check(lid_rej == kb("lid_for_pn_rejects_lid"), "LID-for-PN rejects a LID input");
|
|
check(pn_rej == kb("pn_for_lid_rejects_pn"), "PN-for-LID rejects a PN input");
|
|
check(swap_rej == kb("put_mapping_rejects_swapped"), "put-mapping rejects swapped servers");
|
|
lids.put_many_mappings({{"100000000000003@lid", "15551113333@s.whatsapp.net"},
|
|
{"100000000000003@lid", "15551113333@s.whatsapp.net"},
|
|
{"15551112222@s.whatsapp.net", "100000000000002@lid"}});
|
|
auto ml = lids.many_lids_for_pns({"15551112222@s.whatsapp.net", "15551113333@s.whatsapp.net",
|
|
"19998887777@s.whatsapp.net", "100000000000002@lid"});
|
|
check(ml["15551112222@s.whatsapp.net"] == ks("many_lids_hit"), "batch lookup hits the cached PN");
|
|
check(ml["15551113333@s.whatsapp.net"] == ks("many_lids_new"), "batch lookup hits the new mapping");
|
|
check((ml.count("19998887777@s.whatsapp.net") == 0) == kb("many_lids_missing_absent"), "batch lookup omits the miss");
|
|
check((int)ml.size() == ki("many_lids_size"), "batch lookup holds exactly the two mapped PNs");
|
|
lids.put_mapping("100000000000004@lid", "15551112222@s.whatsapp.net");
|
|
check(lids.lid_for_pn("15551112222@s.whatsapp.net").value_or("") == ks("lid_remapped"), "remapping moves the PN");
|
|
check(!lids.pn_for_lid("100000000000002@lid").has_value() == kb("lid_remap_old_gone"),
|
|
"remapping drops the stale reverse mapping");
|
|
check(lids.alt_jid("15551112222@s.whatsapp.net").value_or("") == ks("alt_pn"), "alt-JID maps a PN to its LID");
|
|
check(lids.alt_jid("100000000000004@lid").value_or("") == ks("alt_lid"), "alt-JID maps a LID to its PN");
|
|
check(!lids.alt_jid("120363000000@g.us").has_value() == kb("alt_group_empty"), "alt-JID of a group is empty");
|
|
lids.fill_cache();
|
|
|
|
// Contacts: push, business, saved, batch and redacted names.
|
|
auto [pc, pp] = store.put_push_name("15552223333@s.whatsapp.net", "Pushee");
|
|
check(pc == kb("push_first_changed") && pp == ks("push_first_prev"), "first push name is stored");
|
|
auto [pc2, pp2] = store.put_push_name("15552223333@s.whatsapp.net", "Pushee");
|
|
(void)pp2;
|
|
check(pc2 == kb("push_same_changed"), "an unchanged push name skips the write");
|
|
auto [pc3, pp3] = store.put_push_name("15552223333@s.whatsapp.net", "Pushed");
|
|
check(pc3 == kb("push_change_changed") && pp3 == ks("push_change_prev"), "a changed push name reports the previous one");
|
|
auto [bc, bp] = store.put_business_name("15552223333@s.whatsapp.net", "Biz Co");
|
|
(void)bp;
|
|
check(bc == kb("biz_first_changed"), "first business name is stored");
|
|
store.put_contact_name("15552223333@s.whatsapp.net", "First", "First Last");
|
|
WaContactInfo ci = store.get_contact("15552223333@s.whatsapp.net");
|
|
check(ci.found == kb("contact_found") && ci.first_name == ks("contact_first") && ci.full_name == ks("contact_full") &&
|
|
ci.push_name == ks("contact_push") && ci.business_name == ks("contact_biz"),
|
|
"the contact row holds saved, push and business names");
|
|
check(store.get_contact("15550004444@s.whatsapp.net").found == kb("contact_missing_found"), "an unknown contact is not found");
|
|
store.put_all_contact_names({{"15550005555@s.whatsapp.net", "A", "A A"},
|
|
{"15550005555@s.whatsapp.net", "B", "B B"},
|
|
{"15550006666@s.whatsapp.net", "C", "C C"}});
|
|
check(store.get_contact("15550005555@s.whatsapp.net").first_name == ks("contact_dedup_first"),
|
|
"batch contact names deduplicate, first rows win");
|
|
store.put_many_redacted_phones({{"100000000000005@lid", "+1········80"}});
|
|
check(store.get_contact("100000000000005@lid").redacted_phone == ks("contact_redacted"), "redacted phones are stored");
|
|
auto all = store.all_contacts();
|
|
check(all["15552223333@s.whatsapp.net"].push_name == "Pushed", "all-contacts holds the push name");
|
|
check((all.size() >= 4) == kb("contact_all_count_ge"), "all-contacts holds every row");
|
|
|
|
// Chat settings.
|
|
WaChatSettings cs0 = store.chat_settings("15553334444@s.whatsapp.net");
|
|
// Not-muted is represented differently (detz 0, whatsmeow zero time.Time)
|
|
// but means the same on both sides; the stored row (0) is identical.
|
|
check(cs0.found == kb("chat_missing_found") && cs0.muted_until == 0 && kb("chat_missing_muted_zero"),
|
|
"an unknown chat has no settings");
|
|
store.put_muted_until("15553334444@s.whatsapp.net", 1700000000);
|
|
store.put_pinned("15553334444@s.whatsapp.net", true);
|
|
store.put_archived("15553334444@s.whatsapp.net", true);
|
|
WaChatSettings cs1 = store.chat_settings("15553334444@s.whatsapp.net");
|
|
check(cs1.muted_until == ki("chat_muted") && cs1.pinned == kb("chat_pinned") && cs1.archived == kb("chat_archived") &&
|
|
cs1.found == kb("chat_found"),
|
|
"mute, pin and archive are stored");
|
|
store.put_muted_until("15553334444@s.whatsapp.net", WaConsts::kWaMutedForever);
|
|
check(store.chat_settings("15553334444@s.whatsapp.net").muted_until == WaConsts::kWaMutedForever,
|
|
"muted-forever round-trips through the -1 row");
|
|
check(kb("chat_forever"), "oracle confirms whatsmeow reads the -1 row back as MutedForever");
|
|
store.put_muted_until("15553334444@s.whatsapp.net", 0);
|
|
check(store.chat_settings("15553334444@s.whatsapp.net").muted_until == 0 && kb("chat_unmuted_zero"), "zero clears the mute");
|
|
store.put_pinned("15553334444@s.whatsapp.net", false);
|
|
WaChatSettings cs2 = store.chat_settings("15553334444@s.whatsapp.net");
|
|
check(!cs2.pinned && cs2.archived, "unpinning keeps the archive flag");
|
|
check(kb("chat_unpinned"), "oracle confirms whatsmeow keeps the archive flag too");
|
|
store.put_wasa_root_secret_id("120363000001@g.us", "wasa-id-1");
|
|
check(store.wasa_root_secret_id("120363000001@g.us").value_or("") == ks("wasa_hit"), "the WASA root secret id is stored");
|
|
check(!store.wasa_root_secret_id("15553334444@s.whatsapp.net").has_value() == kb("wasa_missing_empty"),
|
|
"a chat without a WASA id misses");
|
|
|
|
// Message secrets, including the alternate LID/PN lookup.
|
|
lids.put_mapping("100000000000006@lid", "15554445555@s.whatsapp.net");
|
|
Bytes sec(32, 0x42);
|
|
store.put_message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1", sec);
|
|
auto got_sec = store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1");
|
|
check(got_sec && tohex(got_sec->secret) == ks("secret_hit"), "the message secret is stored");
|
|
check(got_sec && got_sec->sender == ks("secret_sender"), "the lookup reports the stored sender");
|
|
store.put_message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1", Bytes(32, 0x43));
|
|
check(sec_hex("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1") == ks("secret_dup_keeps_first"),
|
|
"a duplicate secret keeps the first row");
|
|
auto alt_sec = store.message_secret("100000000000006@lid", "100000000000006@lid", "MSGID1");
|
|
check(alt_sec && tohex(alt_sec->secret) == ks("secret_alt_hit"), "the LID form finds the PN secret");
|
|
check(alt_sec && alt_sec->sender == ks("secret_alt_sender"), "the alternate lookup reports the PN sender");
|
|
check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "NOPE").has_value() ==
|
|
kb("secret_miss_null"),
|
|
"an unknown secret id misses");
|
|
store.put_message_secrets({{"15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID2", Bytes{'s', '2'}},
|
|
{"15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID3", Bytes{}},
|
|
{"", "15554445555@s.whatsapp.net", "MSGID4", Bytes{'s', '4'}}});
|
|
check(sec_hex("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID2") == ks("secret_batch_hit"),
|
|
"batch secrets store the valid rows");
|
|
check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID3").has_value() ==
|
|
kb("secret_batch_skipped"),
|
|
"batch secrets skip the empty rows");
|
|
|
|
// Privacy tokens: newer wins, sender timestamps coalesce, expiry deletes.
|
|
store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '1'}, 1700000000, std::nullopt}});
|
|
auto tok = store.privacy_token("15554445555@s.whatsapp.net");
|
|
check(tok && tohex(tok->token) == ks("token_hit") && tok->timestamp == ki("token_ts") &&
|
|
!tok->sender_timestamp.has_value() == kb("token_sender_ts_zero"),
|
|
"the privacy token is stored without a sender timestamp");
|
|
store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'o', 'l', 'd'}, 1600000000, std::nullopt}});
|
|
check(tok_hex("15554445555@s.whatsapp.net") == ks("token_older_ignored"), "an older token leaves the row alone");
|
|
store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '2'}, 1700000001, 1700000002}});
|
|
tok = store.privacy_token("15554445555@s.whatsapp.net");
|
|
check(tok && tohex(tok->token) == ks("token_newer_wins") && tok->sender_timestamp.value_or(0) == ki("token_sender_ts"),
|
|
"a newer token wins and carries its sender timestamp");
|
|
store.put_privacy_tokens({{"15554445555@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', '3'}, 1700000003, std::nullopt}});
|
|
auto tok3 = store.privacy_token("15554445555@s.whatsapp.net");
|
|
check(tok3 && tok3->sender_timestamp.value_or(0) == ki("token_sender_ts_kept"),
|
|
"an update without a sender timestamp keeps the stored one");
|
|
auto tok_alt = store.privacy_token("100000000000006@lid");
|
|
check(tok_alt && tohex(tok_alt->token) == ks("token_alt_hit"), "the LID form finds the PN token");
|
|
check(tok_alt && tok_alt->user == ks("token_alt_user"), "the alternate token reports the PN user");
|
|
store.put_privacy_tokens({{"15550007777@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'x'}, 1500000000, std::nullopt},
|
|
{"15550008888@s.whatsapp.net", Bytes{'t', 'o', 'k', '-', 'y'}, 1800000000, std::nullopt}});
|
|
check(store.delete_expired_privacy_tokens(1600000000) == ki("token_expired_deleted"), "expiry deletes one token");
|
|
check(!store.privacy_token("15550007777@s.whatsapp.net").has_value() == kb("token_expired_gone"),
|
|
"the expired token is gone, the fresh one survives");
|
|
bool empty_tokens_threw = false;
|
|
try {
|
|
store.put_privacy_tokens({});
|
|
} catch (const std::invalid_argument&) {
|
|
empty_tokens_threw = true;
|
|
}
|
|
check(empty_tokens_threw == kb("put_tokens_empty_err"), "empty token input fails loudly on both sides");
|
|
|
|
// Unlink integration: device-scoped rows go, contacts and the LID map stay.
|
|
store.clear_device();
|
|
check(!store.has_session("100000000000001.0"), "clear_device drops the sessions");
|
|
check(store.is_trusted_identity("100000000000001.0", key(6)), "clear_device drops the identities");
|
|
check(!store.message_secret("15554445555@s.whatsapp.net", "15554445555@s.whatsapp.net", "MSGID1").has_value(),
|
|
"clear_device drops the message secrets");
|
|
check(!store.privacy_token("15554445555@s.whatsapp.net").has_value(), "clear_device drops the privacy tokens");
|
|
check(!store.chat_settings("15553334444@s.whatsapp.net").found, "clear_device drops the chat settings");
|
|
check(store.get_contact("15552223333@s.whatsapp.net").found, "clear_device keeps the contacts");
|
|
check(lids.lid_for_pn("15551112222@s.whatsapp.net").has_value(), "clear_device keeps the LID map");
|
|
std::filesystem::remove_all(home);
|
|
}
|
|
|
|
// M2 hostile input: corrupt database rows, overlong keys, malformed JIDs and a
|
|
// fuzz loop over the string-taking store surface. Every case must throw a
|
|
// handled exception (never std::length_error) or succeed; surviving is proof.
|
|
static void test_store_hostile() {
|
|
std::printf("[store: hostile input]\\n");
|
|
std::string home = make_temp_home();
|
|
WaStore store(home, "hostile");
|
|
|
|
auto throws_handled = [](auto fn) {
|
|
try {
|
|
fn();
|
|
} catch (const std::length_error&) {
|
|
return false;
|
|
} catch (const std::exception&) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
check(throws_handled([&] { store.put_identity("1.0", Bytes(31, 1)); }), "a 31-byte identity key is rejected");
|
|
check(throws_handled([&] { store.put_identity("1.0", Bytes(33, 1)); }), "a 33-byte identity key is rejected");
|
|
check(throws_handled([&] { store.put_identity("1.0", Bytes()); }), "an empty identity key is rejected");
|
|
std::string huge_addr(2000, '5');
|
|
check(throws_handled([&] { store.save_session(huge_addr, Bytes{1}); }), "an overlong session address is rejected");
|
|
check(throws_handled([&] { store.put_identity(huge_addr, Bytes(32, 1)); }), "an overlong identity address is rejected");
|
|
check(throws_handled([&] { store.lid_map().lid_for_pn("a.b.c@s.whatsapp.net"); }), "a malformed PN JID is rejected");
|
|
std::string huge_jid(2000, '1');
|
|
huge_jid += "@s.whatsapp.net";
|
|
check(throws_handled([&] { store.lid_map().lid_for_pn(huge_jid); }), "an overlong JID is rejected");
|
|
check(throws_handled([&] { store.put_privacy_tokens({}); }), "empty token input throws");
|
|
check(throws_handled([&] { store.message_secret("u:x@y", "s@s.whatsapp.net", "id"); }), "a malformed secret chat is rejected");
|
|
|
|
// Corrupt rows planted with raw SQL: a short identity key and a session blob
|
|
// past the cap must throw a handled error, never allocate blindly.
|
|
store.put_identity("15550001111.0", Bytes(32, 7));
|
|
store.save_session("15550001111.0", Bytes{9});
|
|
{
|
|
detz::store::SqlDb raw(WaStore::db_path(home, "hostile"));
|
|
raw.execute("UPDATE wa_identity SET key='ab' WHERE addr='15550001111.0'");
|
|
raw.execute("UPDATE wa_session SET blob='" + std::string(3 * 1024 * 1024, 'a') + "' WHERE addr='15550001111.0'");
|
|
raw.commit();
|
|
}
|
|
check(throws_handled([&] { store.is_trusted_identity("15550001111.0", Bytes(32, 7)); }),
|
|
"a corrupt identity length throws, never length_error");
|
|
check(throws_handled([&] { store.load_session("15550001111.0"); }), "an oversized session blob throws before allocating");
|
|
|
|
// Wide inputs: a 2000-address get-many and a 650-row batch (chunked 300/300/50).
|
|
std::vector<std::string> addrs;
|
|
for (int i = 0; i < 2000; ++i) addrs.push_back("1999000" + std::to_string(i) + ".0");
|
|
check(store.get_many_sessions(addrs).size() == 2000, "get-many serves 2000 addresses");
|
|
std::vector<WaContactEntry> bulk;
|
|
for (int i = 0; i < 650; ++i)
|
|
bulk.push_back({"1999888" + std::to_string(1000 + i) + "@s.whatsapp.net", "N" + std::to_string(i), "N" + std::to_string(i)});
|
|
store.put_all_contact_names(bulk);
|
|
check(store.get_contact("19998881000@s.whatsapp.net").first_name == "N0" &&
|
|
store.get_contact("19998881649@s.whatsapp.net").first_name == "N649",
|
|
"a 650-row batch survives chunking");
|
|
|
|
// Fuzz: hostile strings through every string-taking entry point.
|
|
bool ok = true;
|
|
uint64_t st = 0x12345678;
|
|
auto rnd = [&]() {
|
|
st = st * 6364136223846793005ull + 1442695040888963407ull;
|
|
return st >> 33;
|
|
};
|
|
const char* alpha = "@.:%_0123456789abLIDs.whatsapp.net";
|
|
for (int i = 0; i < 3000 && ok; ++i) {
|
|
size_t n = 1 + rnd() % 60;
|
|
std::string s;
|
|
for (size_t k = 0; k < n; ++k) s.push_back(alpha[rnd() % 33]);
|
|
if (rnd() % 16 == 0) s.append(1200, '9'); // sometimes overlong
|
|
try {
|
|
store.lid_map().lid_for_pn(s);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
store.lid_map().pn_for_lid(s);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
store.lid_map().alt_jid(s);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
store.put_contact_name(s, "f", "g");
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
store.get_contact(s);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
store.chat_settings(s);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
// WaJid strings round-trip: parse(str()) is str() again.
|
|
try {
|
|
WaJid j = WaJid::parse(s);
|
|
if (WaJid::parse(j.str()).str() != j.str()) ok = false;
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
check(ok, "3000 hostile strings: handled or accepted, never length_error, JIDs round-trip");
|
|
std::filesystem::remove_all(home);
|
|
}
|
|
|
|
// ---- M1: token dictionary freshness (whatsmeow token_oracle.go) ----
|
|
static void test_tokens() {
|
|
std::printf("[tokens]\n");
|
|
Value d = Value::loads(slurp(path("token_vectors.json")));
|
|
check(d.get("dict_version").as_int(0) == kDictVersion, "dict version is whatsmeow DictVersion (3)");
|
|
const Value& single = d.get("single");
|
|
bool sok = single.is_array() && single.size() == kSingleByteTokens.size();
|
|
for (size_t i = 0; sok && i < kSingleByteTokens.size(); ++i)
|
|
sok = single.at(i).str() == std::string(kSingleByteTokens[i]);
|
|
check(sok, "236 single-byte tokens match whatsmeow verbatim");
|
|
const Value& dbl = d.get("double");
|
|
bool dok = dbl.is_array() && dbl.size() == kDoubleByteTokens.size();
|
|
for (size_t x = 0; dok && x < kDoubleByteTokens.size(); ++x) {
|
|
dok = dbl.at(x).is_array() && dbl.at(x).size() == kDoubleByteTokens[x].size();
|
|
for (size_t i = 0; dok && i < kDoubleByteTokens[x].size(); ++i)
|
|
dok = dbl.at(x).at(i).str() == std::string(kDoubleByteTokens[x][i]);
|
|
}
|
|
check(dok, "4x256 double-byte tokens match whatsmeow verbatim");
|
|
const Value& c = d.get("consts");
|
|
bool cok = c.get("ListEmpty").as_int(-1) == WaBinary::LIST_EMPTY &&
|
|
c.get("Dictionary0").as_int(-1) == WaBinary::DICTIONARY_0 &&
|
|
c.get("Dictionary1").as_int(-1) == WaBinary::DICTIONARY_1 &&
|
|
c.get("Dictionary2").as_int(-1) == WaBinary::DICTIONARY_2 &&
|
|
c.get("Dictionary3").as_int(-1) == WaBinary::DICTIONARY_3 &&
|
|
c.get("InteropJID").as_int(-1) == WaBinary::INTEROP_JID &&
|
|
c.get("FBJID").as_int(-1) == WaBinary::FB_JID && c.get("ADJID").as_int(-1) == WaBinary::AD_JID &&
|
|
c.get("List8").as_int(-1) == WaBinary::LIST_8 && c.get("List16").as_int(-1) == WaBinary::LIST_16 &&
|
|
c.get("JIDPair").as_int(-1) == WaBinary::JID_PAIR && c.get("Hex8").as_int(-1) == WaBinary::HEX_8 &&
|
|
c.get("Binary8").as_int(-1) == WaBinary::BINARY_8 &&
|
|
c.get("Binary20").as_int(-1) == WaBinary::BINARY_20 &&
|
|
c.get("Binary32").as_int(-1) == WaBinary::BINARY_32 &&
|
|
c.get("Nibble8").as_int(-1) == WaBinary::NIBBLE_8 &&
|
|
c.get("PackedMax").as_int(-1) == WaBinary::PACKED_MAX &&
|
|
c.get("SingleByteMax").as_int(-1) == WaBinary::SINGLE_BYTE_MAX;
|
|
check(cok, "wire tag consts equal token.* (18 values)");
|
|
// Probes: bare-tag nodes are deterministic, so our encoder must match
|
|
// whatsmeow's marshal byte-for-byte; every probe also decodes back.
|
|
int ok = 0, n = 0;
|
|
for (const auto& p : d.get("probes").items()) {
|
|
++n;
|
|
const std::string s = p.get("string").str();
|
|
WaNode back = WaBinary::decode(WaBinary::unpack(fromhex(p.get("wire_hex").str())));
|
|
if (back.tag != s) continue;
|
|
Bytes raw;
|
|
raw.push_back(0);
|
|
Bytes enc = WaBinary::encode(WaNode(s));
|
|
raw.insert(raw.end(), enc.begin(), enc.end());
|
|
if (tohex(raw) == p.get("wire_hex").str()) ++ok;
|
|
}
|
|
check(ok == n && n > 10, "token probe nodes encode byte-exact (" + std::to_string(ok) + "/" +
|
|
std::to_string(n) + ", single + all 4 dicts + literal)");
|
|
}
|
|
|
|
// ---- M1: iq request/response (whatsmeow iq_oracle.go) ----
|
|
static void test_iq() {
|
|
std::printf("[iq]\n");
|
|
Value d = Value::loads(slurp(path("iq_vectors.json")));
|
|
// Decode direction: whatsmeow wrote the bytes, we read them.
|
|
int ok = 0, n = 0;
|
|
for (const auto& v : d.get("decode").items()) {
|
|
++n;
|
|
WaNode node = WaBinary::decode(WaBinary::unpack(fromhex(v.get("wire_hex").str())));
|
|
bool good = node.tag == v.get("tag").str();
|
|
for (const auto& m : v.get("attrs").members()) good = good && node.attr(m.first) == m.second.str();
|
|
good = good && node.attrs.size() == v.get("attrs").size();
|
|
const Value& kids = v.get("children");
|
|
for (size_t i = 0; good && i < kids.size(); ++i) good = node.child(kids.at(i).str()) != nullptr;
|
|
WaIqError err = WaIq::parse_error(node);
|
|
good = good && err.code() == (int)v.get("code").as_int(0) && err.text() == v.get("text").str() &&
|
|
err.has_error_node() == v.get("has_error").as_bool();
|
|
if (good) ++ok;
|
|
}
|
|
check(ok == n && n == 9, "iq nodes decode + classify like whatsmeow (" + std::to_string(ok) + "/" +
|
|
std::to_string(n) + ": ping, count, 404, 500, bare, result, 3 disconnects)");
|
|
// Encode direction: deterministic nodes must match whatsmeow's marshal bytes.
|
|
int eok = 0, en = 0;
|
|
for (const auto& v : d.get("encode").items()) {
|
|
++en;
|
|
WaNode node(v.get("tag").str());
|
|
for (const auto& m : v.get("attrs").members()) node.attrs[m.first] = m.second.str();
|
|
Bytes raw;
|
|
raw.push_back(0);
|
|
Bytes enc = WaBinary::encode(node);
|
|
raw.insert(raw.end(), enc.begin(), enc.end());
|
|
const std::string got = tohex(raw);
|
|
if (v.has("wire_hex")) {
|
|
if (got == v.get("wire_hex").str()) ++eok;
|
|
} else {
|
|
for (const auto& w : v.get("wire_set").items())
|
|
if (got == w.str()) {
|
|
++eok;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
check(eok == en && en == 4, "iq nodes encode within whatsmeow's bytes (" + std::to_string(eok) + "/" +
|
|
std::to_string(en) + ": exact x2, order-set x2)");
|
|
// JID-valued attrs have two valid wire forms: whatsmeow writes its typed
|
|
// JID as JID_PAIR, we write the same string as its single-byte token; the
|
|
// decode checks above prove both read back to "s.whatsapp.net".
|
|
WaNode to_form("iq");
|
|
to_form.attrs["to"] = "s.whatsapp.net";
|
|
Bytes to_raw = WaBinary::encode(to_form);
|
|
check(to_raw.size() == 5 && to_raw[3] == 0x11 && to_raw[4] == 0x03,
|
|
"our server-JID attr encodes as the 1-byte token (compact valid form)");
|
|
// WaIq::build assembles the ping exactly as whatsmeow sendIQAsyncAndGetData does.
|
|
WaIqQuery q;
|
|
q.ns = "w:p";
|
|
q.type = "get";
|
|
q.to = WaConsts::kServerJid;
|
|
q.id = "42.7-9";
|
|
WaNode ping = WaIq::build(q);
|
|
check(ping.tag == "iq" && ping.attr("id") == "42.7-9" && ping.attr("xmlns") == "w:p" &&
|
|
ping.attr("type") == "get" && ping.attr("to") == "s.whatsapp.net" &&
|
|
!ping.has_attr("smax_id") && !ping.has_attr("target"),
|
|
"WaIq::build shapes the keepalive ping (id/xmlns/type/to, no smax/target)");
|
|
WaIqQuery qs = q;
|
|
qs.smax_id = "99";
|
|
qs.target = "31611111111@s.whatsapp.net";
|
|
WaNode with_extra = WaIq::build(qs);
|
|
check(with_extra.attr("smax_id") == "99" && with_extra.attr("target") == "31611111111@s.whatsapp.net",
|
|
"WaIq::build carries smax_id + target when set");
|
|
// Request ids mirror whatsmeow "<byte0>.<byte1>-<counter>".
|
|
check(WaIq::generate_id("42.7-", 9) == "42.7-9", "request id is prefix + counter");
|
|
std::string pfx = WaIq::new_prefix();
|
|
check(pfx.size() >= 5 && pfx.back() == '-' && pfx.find('.') != std::string::npos,
|
|
"request prefix has the <n>.<m>- shape");
|
|
check(WaIq::new_prefix().size() >= 5, "a second prefix draws cleanly");
|
|
// Disconnect predicates on the oracle's disconnect nodes.
|
|
const Value& dec = d.get("decode");
|
|
auto by_name = [&](const std::string& name) {
|
|
for (const auto& v : dec.items())
|
|
if (v.get("name").str() == name) return WaBinary::decode(WaBinary::unpack(fromhex(v.get("wire_hex").str())));
|
|
return WaNode("missing");
|
|
};
|
|
WaNode e401 = by_name("stream_error_401"), conf = by_name("stream_error_conflict"),
|
|
e515 = by_name("stream_error_515"), pong = by_name("iq_result_ping");
|
|
check(WaIq::is_disconnect_node(e401) && WaIq::is_disconnect_node(conf) && WaIq::is_disconnect_node(e515) &&
|
|
!WaIq::is_disconnect_node(pong),
|
|
"disconnect nodes recognized (stream:error yes, iq result no)");
|
|
check(WaIq::is_auth_error_disconnect(e401) && WaIq::is_auth_error_disconnect(conf) &&
|
|
!WaIq::is_auth_error_disconnect(e515) && !WaIq::is_auth_error_disconnect(pong),
|
|
"auth disconnects (401, replaced) separated from the 515 restart");
|
|
WaNode xml_end("xmlstreamend");
|
|
check(WaIq::is_disconnect_node(xml_end) && !WaIq::is_auth_error_disconnect(xml_end),
|
|
"xmlstreamend disconnects without being an auth error");
|
|
// Error taxonomy mirrors whatsmeow errors.go.
|
|
WaIqError e404 = WaIq::parse_error(by_name("iq_error_404"));
|
|
check(e404.is(WaIqError::not_found()) && !e404.is(WaIqError::internal_server_error()),
|
|
"parsed 404 is ErrIQNotFound, not ErrIQInternalServerError");
|
|
check(WaIqError::timed_out().message() == "info query timed out", "timeout message matches ErrIQTimedOut");
|
|
check(WaIqError::not_found().message() == "info query returned status 404: item-not-found",
|
|
"status message matches IQError.Error");
|
|
check(WaDisconnectedError::for_iq().message() == "websocket disconnected before info query returned response",
|
|
"disconnect message matches DisconnectedError.Error");
|
|
check(WaDisconnectedError::for_iq().is(WaDisconnectedError("info query")) &&
|
|
!WaDisconnectedError::for_iq().is(WaDisconnectedError("message send")),
|
|
"disconnect equivalence is by action");
|
|
WaIqError bare = WaIq::parse_error(by_name("iq_error_bare"));
|
|
check(bare.code() == 0 && !bare.has_error_node(), "a result without <error> parses to code 0");
|
|
// Hostile shapes: never throw, never overflow, code reads 0.
|
|
WaNode evil("iq");
|
|
evil.content = WaNode::Children{WaNode("error")};
|
|
WaNode* child = const_cast<WaNode*>(evil.child("error"));
|
|
child->attrs["code"] = "not-a-number";
|
|
check(WaIq::parse_error(evil).code() == 0, "a non-numeric error code reads 0");
|
|
child->attrs["code"] = "99999999999999999999999";
|
|
check(WaIq::parse_error(evil).code() == 0, "an overflowing error code reads 0, no UB");
|
|
child->attrs.erase("code");
|
|
child->attrs["text"] = std::string(70000, 'x');
|
|
check(WaIq::parse_error(evil).code() == 0, "a missing code with a huge text reads 0");
|
|
// Socket guards fail loudly without any network.
|
|
bool threw = false;
|
|
try {
|
|
WaSocket s;
|
|
s.send_frame(Bytes{1, 2, 3});
|
|
} catch (const WaSocketError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "sending on an unconnected socket throws WaSocketError");
|
|
WaSocketStatusError with_status("connect: refused", 403);
|
|
check(with_status.status() == 403 && std::string(with_status.what()).find("403") != std::string::npos,
|
|
"the status-carrying socket error keeps its code (ErrWithStatusCode role)");
|
|
}
|
|
|
|
// ---- M1: keepalive schedule (deterministic clock) ----
|
|
static void test_keepalive() {
|
|
std::printf("[keepalive]\n");
|
|
check(WaConsts::kWaKeepAliveResponseDeadline == 10 && WaConsts::kWaKeepAliveIntervalMin == 20 &&
|
|
WaConsts::kWaKeepAliveIntervalMax == 30 && WaConsts::kWaKeepAliveMaxFailTime == 180,
|
|
"keepalive caps equal whatsmeow (10s deadline, 20-30s jitter, 3min horizon)");
|
|
WaKeepAlive ka([]() { return 25000; });
|
|
ka.start(100000);
|
|
check(!ka.due(100000) && !ka.due(124999) && ka.due(125000), "first ping due after the interval");
|
|
check(ka.error_count() == 0, "no errors at start");
|
|
ka.record_pong(125000);
|
|
check(ka.error_count() == 0 && ka.last_success_ms() == 125000 && !ka.due(149999) && ka.due(150000),
|
|
"a pong clears errors and advances the schedule");
|
|
check(!ka.record_timeout(150000) && ka.error_count() == 1, "one unanswered ping counts, no reconnect yet");
|
|
check(!ka.record_timeout(175000) && ka.error_count() == 2, "errors accumulate across pings");
|
|
ka.record_pong(200000);
|
|
check(ka.error_count() == 0 && ka.last_success_ms() == 200000, "a later pong recovers the streak");
|
|
// Failures spanning more than 3 minutes force a reconnect (whatsmeow KeepAliveMaxFailTime).
|
|
WaKeepAlive dying([]() { return 25000; });
|
|
dying.start(0);
|
|
bool forced = false;
|
|
for (int64_t t = 25000; t <= 400000 && !forced; t += 25000) forced = dying.record_timeout(t);
|
|
check(forced && dying.error_count() == 8, "8 unanswered pings (200s of failure) force a reconnect");
|
|
WaKeepAlive edge([]() { return 25000; });
|
|
edge.start(0);
|
|
bool early = true;
|
|
for (int i = 0; i < 7; ++i) early = early && !edge.record_timeout(25000 + i * 25000);
|
|
check(early && edge.record_timeout(200000), "the horizon trips past 180s, not before");
|
|
// The default jitter always stays inside 20-30s.
|
|
bool in_range = true;
|
|
for (int i = 0; i < 200; ++i) {
|
|
WaKeepAlive probe;
|
|
probe.start(0);
|
|
int64_t wait = probe.next_due_ms();
|
|
in_range = in_range && wait >= 20000 && wait <= 30000;
|
|
}
|
|
check(in_range, "default jitter stays inside 20-30s over 200 draws");
|
|
check(WaConsts::kWaIqDefaultTimeout == 75, "default iq wait is whatsmeow defaultRequestTimeout (75s)");
|
|
}
|
|
|
|
// ---- M1: Noise parity + server certificate (whatsmeow cert_oracle.go) ----
|
|
static void test_server_cert() {
|
|
std::printf("[server-cert]\n");
|
|
Value d = Value::loads(slurp(path("cert_vectors.json")));
|
|
Value n = Value::loads(slurp(path("noise.json")));
|
|
// Re-run the handshake over the wa-go trace: our decrypted bytes must equal
|
|
// whatsmeow's own NoiseHandshake output from the oracle.
|
|
auto ephPriv = a32(fromhex(n.get("ephemeralKeyPriv").str()));
|
|
auto ephPub = a32(fromhex(n.get("ephemeralKeyPub").str()));
|
|
auto stPriv = a32(fromhex(n.get("authStaticKeyPriv").str()));
|
|
auto stPub = a32(fromhex(n.get("authStaticKeyPub").str()));
|
|
Bytes shFrame = fromhex(n.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 completes (mix_shared_secret refactor)");
|
|
check(res && tohex(res->server_static) == d.get("server_static_hex").str(),
|
|
"server static equals whatsmeow NoiseHandshake output");
|
|
check(res && tohex(res->server_payload) == d.get("server_payload_hex").str(),
|
|
"server payload equals whatsmeow NoiseHandshake output (241 bytes)");
|
|
// Details fields match whatsmeow's waCert parse field by field.
|
|
auto chain = parse_cert_chain(fromhex(d.get("server_payload_hex").str()));
|
|
auto im = parse_cert_details(chain->intermediate_details);
|
|
auto leaf = parse_cert_details(chain->leaf_details);
|
|
const Value& oi = d.get("intermediate");
|
|
const Value& ol = d.get("leaf");
|
|
check(im && im->serial == (uint32_t)oi.get("serial").as_int(0) &&
|
|
im->issuer_serial == (uint32_t)oi.get("issuer_serial").as_int(0) &&
|
|
tohex(im->key) == oi.get("key_hex").str() &&
|
|
im->not_before == (uint64_t)oi.get("not_before").as_int(0) &&
|
|
im->not_after == (uint64_t)oi.get("not_after").as_int(0),
|
|
"intermediate details match whatsmeow (serial 3, issuer 0, key, window)");
|
|
check(leaf && leaf->serial == (uint32_t)ol.get("serial").as_int(0) &&
|
|
leaf->issuer_serial == (uint32_t)ol.get("issuer_serial").as_int(0) &&
|
|
tohex(leaf->key) == ol.get("key_hex").str() &&
|
|
leaf->not_before == (uint64_t)ol.get("not_before").as_int(0) &&
|
|
leaf->not_after == (uint64_t)ol.get("not_after").as_int(0),
|
|
"leaf details match whatsmeow (serial 354, issuer 3, key, window)");
|
|
Bytes pubkey(WaConsts::kWaCertPubKey.begin(), WaConsts::kWaCertPubKey.end());
|
|
check(tohex(pubkey) == d.get("wacert_pubkey_hex").str(), "pinned key equals whatsmeow WACertPubKey");
|
|
check(WaConsts::kWaCertIssuerSerial == 0, "pinned issuer serial equals whatsmeow WACertIssuerSerial");
|
|
// The captured leaf is expired today, so travel inside its window: every
|
|
// check must pass there (the oracle confirms the signatures are genuine).
|
|
Bytes payload = fromhex(d.get("server_payload_hex").str());
|
|
Bytes server_static = fromhex(d.get("server_static_hex").str());
|
|
int64_t mid = (int64_t)(((uint64_t)ol.get("not_before").as_int(0) + (uint64_t)ol.get("not_after").as_int(0)) / 2);
|
|
bool accepted = false;
|
|
try {
|
|
WaServerCert::verify(payload, server_static, mid);
|
|
accepted = true;
|
|
} catch (const std::exception&) {
|
|
}
|
|
check(accepted, "a genuine chain verifies inside its validity window");
|
|
bool expired = false;
|
|
try {
|
|
WaServerCert::verify(payload, server_static);
|
|
} catch (const WaCertError& e) {
|
|
expired = std::string(e.what()).find("expired") != std::string::npos;
|
|
}
|
|
check(expired, "the same chain is refused as expired today");
|
|
// Tamper with every verified property: each must fail closed with a reason.
|
|
auto tampers = [&](const std::string& name, const Bytes& bad_payload, const Bytes& bad_static) {
|
|
bool threw = false;
|
|
try {
|
|
WaServerCert::verify(bad_payload, bad_static, mid);
|
|
} catch (const WaCertError&) {
|
|
threw = true;
|
|
} catch (const std::length_error&) {
|
|
threw = false;
|
|
}
|
|
check(threw, "tampered chain refused: " + name);
|
|
};
|
|
Bytes bad_sig = payload;
|
|
bad_sig[bad_sig.size() - 1] ^= 0x01;
|
|
tampers("flipped signature byte", bad_sig, server_static);
|
|
Bytes bad_static = server_static;
|
|
bad_static[0] ^= 0x01;
|
|
tampers("wrong server static", payload, bad_static);
|
|
tampers("truncated chain", Bytes(payload.begin(), payload.begin() + payload.size() / 2), server_static);
|
|
tampers("empty chain", Bytes{}, server_static);
|
|
tampers("empty static", payload, Bytes{});
|
|
tampers("garbage chain", Bytes(241, 0xab), server_static);
|
|
// Hostile protobuf inside the chain: oversized lengths and overlong varints
|
|
// must throw a handled error, never length_error, never UB.
|
|
bool hostile_ok = true;
|
|
for (const Bytes& evil : {Bytes{0x0a, 0xff, 0xff, 0xff, 0xff, 0x0f}, Bytes{0x12, 0x80, 0x80, 0x80, 0x80,
|
|
0x80, 0x80, 0x80, 0x80, 0x80, 0x7f},
|
|
Bytes{0x0a}, Bytes{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f}}) {
|
|
try {
|
|
WaServerCert::verify(evil, server_static, mid);
|
|
} catch (const std::length_error&) {
|
|
hostile_ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
check(hostile_ok, "hostile chain bytes rejected without length_error");
|
|
// A swapped leaf/intermediate must not verify (regression: the fields were swapped).
|
|
WaCertChainParsed swapped;
|
|
swapped.leaf_details = chain->intermediate_details;
|
|
swapped.leaf_signature = chain->intermediate_signature;
|
|
swapped.intermediate_details = chain->leaf_details;
|
|
swapped.intermediate_signature = chain->leaf_signature;
|
|
// Re-encode the swap through the writer to feed verify() bytes.
|
|
ProtoWriter wl, wi, wc;
|
|
wl.message(1, swapped.leaf_details);
|
|
wl.bytes(2, swapped.leaf_signature);
|
|
wi.message(1, swapped.intermediate_details);
|
|
wi.bytes(2, swapped.intermediate_signature);
|
|
wc.message(1, wl.take());
|
|
wc.message(2, wi.take());
|
|
bool swap_threw = false;
|
|
try {
|
|
WaServerCert::verify(wc.take(), server_static, mid);
|
|
} catch (const WaCertError&) {
|
|
swap_threw = true;
|
|
}
|
|
check(swap_threw, "a leaf/intermediate swap is refused");
|
|
}
|
|
|
|
// ---- 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");
|
|
}
|
|
|
|
// ---- M5 media (docs/whatsapp.md section 21) ----
|
|
// Oracle: tests/whatsapp_data/media_vectors_m5.json, generated by media_oracle.go with whatsmeow's
|
|
// own hkdfutil, cbcutil, GetMediaType, error sentinels, StickerPack struct and retryafter package.
|
|
// The mms-type table and URL shapes are read from the whatsmeow source (no exported accessor);
|
|
// the tests below pin them as exact strings so any drift fails loudly.
|
|
|
|
// The oracle's test-data pattern (its generator, not the code under test).
|
|
static Bytes m5_pattern(size_t n) {
|
|
Bytes b;
|
|
for (size_t i = 0; i < n; ++i) b.push_back(static_cast<uint8_t>((i * 31 + 7) % 251));
|
|
return b;
|
|
}
|
|
|
|
static FILE* m5_tmp_with(const Bytes& b) {
|
|
FILE* f = ::tmpfile();
|
|
if (f == nullptr) return nullptr;
|
|
if (!b.empty()) std::fwrite(b.data(), 1, b.size(), f);
|
|
std::rewind(f);
|
|
return f;
|
|
}
|
|
|
|
static Bytes m5_slurp_file(FILE* f) {
|
|
std::rewind(f);
|
|
Bytes out;
|
|
char buf[4096];
|
|
size_t n = 0;
|
|
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.insert(out.end(), buf, buf + n);
|
|
return out;
|
|
}
|
|
|
|
static WaMediaConn m5_conn(const std::string& h1, const std::string& h2 = "") {
|
|
WaMediaConn c;
|
|
c.auth = "test-auth";
|
|
c.ttl = 3600;
|
|
c.auth_ttl = 3600;
|
|
c.max_buckets = 5;
|
|
c.fetched_at = 1700000000;
|
|
c.hosts.push_back(WaMediaConnHost{h1});
|
|
if (!h2.empty()) c.hosts.push_back(WaMediaConnHost{h2});
|
|
return c;
|
|
}
|
|
|
|
// Scripted media HTTP client: scripted results (or throws) per call, full request log.
|
|
struct FakeMediaHttp : WaMedia::HttpClient {
|
|
struct Req {
|
|
std::string method, url;
|
|
Bytes body;
|
|
std::vector<std::pair<std::string, std::string>> headers;
|
|
};
|
|
std::vector<Req> requests;
|
|
std::vector<std::function<WaMedia::HttpResult()>> gets, posts, post_files, dells;
|
|
size_t gi{0}, pi{0}, fi{0}, di{0};
|
|
WaMedia::HttpResult get(const std::string& url, size_t max_bytes) override {
|
|
(void)max_bytes;
|
|
requests.push_back({"GET", url, {}, {}});
|
|
if (gi >= gets.size()) throw WaMediaError(WaMediaErrorCode::Network, "fake: no more GETs");
|
|
return gets[gi++]();
|
|
}
|
|
WaMedia::HttpResult post(const std::string& url, const Bytes& body, size_t max_response) override {
|
|
(void)max_response;
|
|
requests.push_back({"POST", url, body, {}});
|
|
if (pi >= posts.size()) throw WaMediaError(WaMediaErrorCode::Network, "fake: no more POSTs");
|
|
return posts[pi++]();
|
|
}
|
|
WaMedia::HttpResult post_file(const std::string& url, WaMediaFile* body, uint64_t size,
|
|
size_t max_response) override {
|
|
(void)max_response;
|
|
Bytes content(static_cast<size_t>(size), 0);
|
|
size_t n = std::fread(content.data(), 1, content.size(), body);
|
|
content.resize(n);
|
|
requests.push_back({"POST_FILE", url, content, {}});
|
|
if (fi >= post_files.size()) throw WaMediaError(WaMediaErrorCode::Network, "fake: no more POST_FILEs");
|
|
return post_files[fi++]();
|
|
}
|
|
WaMedia::HttpResult del(const std::string& url,
|
|
const std::vector<std::pair<std::string, std::string>>& headers) override {
|
|
requests.push_back({"DELETE", url, {}, headers});
|
|
if (di >= dells.size()) throw WaMediaError(WaMediaErrorCode::Network, "fake: no more DELETEs");
|
|
return dells[di++]();
|
|
}
|
|
};
|
|
|
|
static WaMedia::HttpResult m5_http(long status, const Bytes& body, const std::string& retry_after = "") {
|
|
WaMedia::HttpResult r;
|
|
r.status = status;
|
|
r.body = body;
|
|
r.retry_after = retry_after;
|
|
return r;
|
|
}
|
|
|
|
static std::function<WaMedia::HttpResult()> m5_net_err(const std::string& what) {
|
|
return [what]() -> WaMedia::HttpResult { throw WaMediaError(WaMediaErrorCode::Network, what); };
|
|
}
|
|
|
|
static void test_media_m5() {
|
|
std::printf("[media M5]\n");
|
|
WaMedia::reset_test_hooks();
|
|
FILE* probe = ::tmpfile();
|
|
check(probe != nullptr, "tmpfile works");
|
|
if (probe != nullptr) std::fclose(probe);
|
|
|
|
Value v = Value::loads(slurp(path("media_vectors_m5.json")));
|
|
|
|
// App-info strings and the mms-type table.
|
|
const std::pair<WaMediaType, const char*> types[8] = {
|
|
{WaMediaType::Image, "Image"}, {WaMediaType::Video, "Video"},
|
|
{WaMediaType::Audio, "Audio"}, {WaMediaType::Document, "Document"},
|
|
{WaMediaType::History, "History"}, {WaMediaType::AppState, "AppState"},
|
|
{WaMediaType::StickerPack, "StickerPack"}, {WaMediaType::LinkThumbnail, "LinkThumbnail"},
|
|
};
|
|
bool infos_ok = true;
|
|
for (const auto& [t, name] : types) {
|
|
if (WaMediaTypes::app_info(t) != v.get("app_infos").get(name).str()) infos_ok = false;
|
|
if (WaMediaTypes::parse_app_info(v.get("app_infos").get(name).str()) != t) infos_ok = false;
|
|
}
|
|
check(infos_ok, "8 app-info strings match the MediaType constants, parse round-trips");
|
|
check(WaMediaTypes::mms_type(WaMediaType::Image) == "image", "mms image");
|
|
check(WaMediaTypes::mms_type(WaMediaType::Video) == "video", "mms video");
|
|
check(WaMediaTypes::mms_type(WaMediaType::Audio) == "audio", "mms audio");
|
|
check(WaMediaTypes::mms_type(WaMediaType::Document) == "document", "mms document");
|
|
check(WaMediaTypes::mms_type(WaMediaType::History) == "md-msg-hist", "mms history");
|
|
check(WaMediaTypes::mms_type(WaMediaType::AppState) == "md-app-state", "mms app-state");
|
|
check(WaMediaTypes::mms_type(WaMediaType::StickerPack) == "sticker-pack", "mms sticker-pack");
|
|
check(WaMediaTypes::mms_type(WaMediaType::LinkThumbnail) == "thumbnail-link", "mms link-thumbnail");
|
|
|
|
// Per-type encrypt vectors plus a decrypt round-trip through decrypt_blob.
|
|
Bytes media_key = fromhex(v.get("media_key_hex").str());
|
|
int ok = 0, total = 0;
|
|
for (const auto& c : v.get("encrypt").items()) {
|
|
std::string info = v.get("app_infos").get(c.get("type").str()).str();
|
|
Bytes plain = fromhex(c.get("plaintextHex").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 round_ok = false;
|
|
try {
|
|
Bytes file(e.data_to_upload.begin(), e.data_to_upload.end() - 10);
|
|
Bytes mac(e.data_to_upload.end() - 10, e.data_to_upload.end());
|
|
WaMediaType t = WaMediaTypes::parse_app_info(info);
|
|
round_ok = WaMedia::decrypt_blob(file, mac, media_key, t, e.file_enc_sha256, e.file_sha256) == plain;
|
|
} catch (const std::exception&) {
|
|
}
|
|
++total;
|
|
if (keys_ok && blob_ok && hash_ok && round_ok && e.file_length == plain.size()) ++ok;
|
|
}
|
|
check(total == 16 && ok == total, "per-type encrypt matches oracle (16/16) with decrypt round-trip");
|
|
|
|
// Streaming sidecars.
|
|
Bytes siv = fromhex(v.get("sidecar_iv").str()), smac = fromhex(v.get("sidecar_mac_key").str());
|
|
int sok = 0;
|
|
for (const auto& c : v.get("sidecars").items()) {
|
|
Bytes blob = fromhex(c.get("blobHex").str());
|
|
if (tohex(WaMedia::streaming_sidecar(siv, smac, blob)) == c.get("sidecarHex").str()) ++sok;
|
|
}
|
|
check(sok == 4, "video sidecars match oracle (100B, 64K, 64K+21, 150000B)");
|
|
|
|
// cbc_encrypt_stream byte parity.
|
|
int stok = 0;
|
|
for (const auto& c : v.get("streams").items()) {
|
|
Bytes pt = fromhex(c.get("plaintextHex").str());
|
|
FILE* in = m5_tmp_with(pt);
|
|
FILE* out = m5_tmp_with(Bytes{});
|
|
bool good = false;
|
|
if (in != nullptr && out != nullptr) {
|
|
try {
|
|
WaMedia::StreamEncryptOut se = WaMedia::cbc_encrypt_stream(
|
|
in, out, Bytes(32, 0x11), Bytes(16, 0x22), Bytes(32, 0x33));
|
|
good = tohex(se.file_sha256) == c.get("fileSha256").str() &&
|
|
tohex(se.file_enc_sha256) == c.get("fileEncSha256").str() &&
|
|
se.file_length == static_cast<uint64_t>(c.get("fileLength").as_int()) &&
|
|
se.upload_size == static_cast<uint64_t>(c.get("uploadSize").as_int()) &&
|
|
tohex(m5_slurp_file(out)) == c.get("ciphertextHex").str();
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
if (in != nullptr) std::fclose(in);
|
|
if (out != nullptr) std::fclose(out);
|
|
if (good) ++stok;
|
|
}
|
|
check(stok == 5, "cbc_encrypt_stream matches cbcutil.EncryptStream (empty..100000B)");
|
|
|
|
// cbc_decrypt_file byte parity plus the bad-size error text.
|
|
int dok = 0;
|
|
for (const auto& c : v.get("decrypt_files").items()) {
|
|
Bytes ct = fromhex(c.get("ciphertextHex").str());
|
|
FILE* f = m5_tmp_with(ct);
|
|
bool good = false;
|
|
if (f != nullptr) {
|
|
try {
|
|
WaMedia::cbc_decrypt_file(f, Bytes(32, 0x11), Bytes(16, 0x22));
|
|
good = tohex(m5_slurp_file(f)) == c.get("plaintextHex").str();
|
|
} catch (const std::exception&) {
|
|
}
|
|
std::fclose(f);
|
|
}
|
|
if (good) ++dok;
|
|
}
|
|
check(dok == 5, "cbc_decrypt_file matches cbcutil.DecryptFile (empty..40000B)");
|
|
{
|
|
FILE* f = m5_tmp_with(m5_pattern(20));
|
|
std::string msg;
|
|
try {
|
|
WaMedia::cbc_decrypt_file(f, Bytes(32, 0x11), Bytes(16, 0x22));
|
|
} catch (const WaMediaError& e) {
|
|
msg = e.what();
|
|
}
|
|
if (f != nullptr) std::fclose(f);
|
|
check(msg == "wa media: " + v.get("decrypt_file_bad_size_error").str(),
|
|
"decrypt_file bad size mirrors upstream text");
|
|
}
|
|
|
|
// GetMediaType mapping.
|
|
bool gok = true;
|
|
for (const auto& m : v.get("get_media_type").members()) {
|
|
try {
|
|
if (WaMediaTypes::app_info(WaMediaTypes::for_message(m.first)) != m.second.str()) gok = false;
|
|
} catch (const std::exception&) {
|
|
gok = false;
|
|
}
|
|
}
|
|
bool gthrow = false;
|
|
try {
|
|
WaMediaTypes::for_message("PollCreationMessage");
|
|
} catch (const WaMediaError& e) {
|
|
gthrow = e.code() == WaMediaErrorCode::UnknownMediaType;
|
|
}
|
|
bool tthrow = false;
|
|
try {
|
|
WaMediaTypes::for_thumbnail("ImageMessage");
|
|
} catch (const WaMediaError&) {
|
|
tthrow = true;
|
|
}
|
|
check(gok && gthrow && tthrow, "for_message matches GetMediaType (10 classes), unknown throws");
|
|
check(WaMediaTypes::for_thumbnail("ExtendedTextMessage") == WaMediaType::LinkThumbnail,
|
|
"for_thumbnail maps ExtendedTextMessage to LinkThumbnail");
|
|
|
|
// Error taxonomy strings.
|
|
const Value& errs = v.get("errors");
|
|
auto msg_is = [&](WaMediaErrorCode code, const std::string& key) {
|
|
try {
|
|
throw WaMediaError(code);
|
|
} catch (const WaMediaError& e) {
|
|
return std::string(e.what()) == "wa media: " + errs.get(key).str();
|
|
}
|
|
return false;
|
|
};
|
|
check(msg_is(WaMediaErrorCode::NoUrl, "no_url"), "err no url present");
|
|
check(msg_is(WaMediaErrorCode::TooShort, "too_short"), "err file too short");
|
|
check(msg_is(WaMediaErrorCode::InvalidHmac, "invalid_hmac"), "err invalid media hmac");
|
|
check(msg_is(WaMediaErrorCode::InvalidEncSha256, "invalid_enc_sha"), "err invalid media enc sha");
|
|
check(msg_is(WaMediaErrorCode::InvalidSha256, "invalid_sha"), "err invalid media sha");
|
|
check(msg_is(WaMediaErrorCode::InvalidUnencryptedSha256, "invalid_unenc_sha"), "err invalid unencrypted sha");
|
|
check(msg_is(WaMediaErrorCode::UnknownMediaType, "unknown_media_type"), "err unknown media type");
|
|
check(msg_is(WaMediaErrorCode::NothingDownloadable, "nothing_downloadable"), "err nothing downloadable");
|
|
check(msg_is(WaMediaErrorCode::FileLengthMismatch, "file_length_mismatch"), "err file length mismatch");
|
|
check(std::string(WaMediaError(403).what()) == "wa media: " + errs.get("http_403").str(), "err http 403");
|
|
check(std::string(WaMediaError(404).what()) == "wa media: " + errs.get("http_404").str(), "err http 404");
|
|
check(std::string(WaMediaError(410).what()) == "wa media: " + errs.get("http_410").str(), "err http 410");
|
|
check(std::string(WaMediaError(500).what()) == "wa media: " + errs.get("http_500").str(), "err http 500");
|
|
check(WaMediaError(404).http_status() == 404, "http error carries its status");
|
|
check(WaDownloadHttpError{404}.is(404) == (errs.get("is_404_404").str() == "true"), "DownloadHTTPError Is 404/404");
|
|
check(WaDownloadHttpError{404}.is(403) == (errs.get("is_404_403").str() == "true"), "DownloadHTTPError Is 404/403");
|
|
check(WaDownloadHttpError{500}.is(404) == (errs.get("is_500_404").str() == "true"), "DownloadHTTPError Is 500/404");
|
|
check(WaMediaError(500, "upload").what() == std::string("wa media: upload failed with status code 500"),
|
|
"upload http error names the operation");
|
|
check(WaMediaError(500, "media delete").what() ==
|
|
std::string("wa media: media delete failed with status code 500"),
|
|
"delete http error names the operation");
|
|
|
|
// Failover and retry classification.
|
|
check(WaMediaError(WaMediaErrorCode::InvalidSha256).terminal_for_host_failover(), "plain-sha is terminal");
|
|
check(WaMediaError(403).terminal_for_host_failover(), "403 is terminal");
|
|
check(WaMediaError(404).terminal_for_host_failover(), "404 is terminal");
|
|
check(WaMediaError(410).terminal_for_host_failover(), "410 is terminal");
|
|
check(!WaMediaError(500).terminal_for_host_failover(), "500 fails over");
|
|
check(!WaMediaError(WaMediaErrorCode::InvalidHmac).terminal_for_host_failover(), "bad HMAC fails over");
|
|
bool rok = true;
|
|
for (const auto& m : v.get("retry_should").members()) {
|
|
bool want = m.second.as_bool();
|
|
if (WaMediaError(std::stoi(m.first)).retryable() != want) rok = false;
|
|
}
|
|
rok = rok && WaMediaError(WaMediaErrorCode::Network).retryable() &&
|
|
!WaMediaError(WaMediaErrorCode::InvalidSha256).retryable();
|
|
check(rok, "retryable() matches retryafter.Should (429/502/503/504 + network)");
|
|
check(WaMedia::parse_retry_after("120", 5, 1700000000) == v.get("retry_parse_seconds").as_int(),
|
|
"Retry-After seconds");
|
|
check(WaMedia::parse_retry_after("", 5, 1700000000) == v.get("retry_parse_empty").as_int(), "Retry-After empty");
|
|
check(WaMedia::parse_retry_after("garbage", 5, 1700000000) == v.get("retry_parse_garbage").as_int(),
|
|
"Retry-After garbage");
|
|
check(WaMedia::parse_retry_after("Wed, 21 Oct 2030 07:28:00 GMT", 5, 1918798020) == 60, "Retry-After date");
|
|
check(WaMedia::parse_retry_after("Wed, 21 Oct 2020 07:28:00 GMT", 5, 1918798020) == 0, "Retry-After past date");
|
|
|
|
// Sticker pack JSON (oracle-marshalled from the real struct).
|
|
{
|
|
WaStickerPack pack = WaStickerPack::parse_json(v.get("sticker_pack_json").str());
|
|
bool pok = pack.id == "pack-9" && pack.name == "Cats" && pack.publisher == "detz" &&
|
|
pack.description == "fine cats" && pack.file_size == "1234" && pack.image_data_hash == "aabbcc" &&
|
|
pack.animated == 0 && pack.lottie == 1 && pack.preview_image_ids.size() == 2 &&
|
|
pack.tray_image_id == "tray-1" && pack.tray_image_preview == "preview-b64" &&
|
|
pack.stickers.size() == 2;
|
|
const WaStickerPackItem& a = pack.stickers[0];
|
|
pok = pok && a.media_key == m5_pattern(32) && a.enc_file_hash == m5_pattern(32) &&
|
|
a.file_hash == Bytes(32, 9) && a.direct_path == "/v/t62.123/abc?auth=1" &&
|
|
a.url == "https://example.invalid/s1.webp" && a.file_size == 4242 && a.mime_type == "image/webp" &&
|
|
a.height == 512 && a.width == 512 && a.emojis.size() == 2 && a.emojis[0] == "=^.^=" &&
|
|
a.accessibility_text == "a cat" && a.handle == "h1" && a.sticker_hash_without_meta == Bytes(32, 7) &&
|
|
a.preview_webp_id == "w1";
|
|
pok = pok && a.get_direct_path() == a.direct_path && a.get_media_key() == a.media_key &&
|
|
a.get_file_sha256() == a.file_hash && a.get_file_enc_sha256() == a.enc_file_hash &&
|
|
a.get_file_size_bytes() == 4242;
|
|
const WaStickerPackItem& b = pack.stickers[1];
|
|
pok = pok && b.media_key == Bytes(32, 1) && b.file_size == 100 && b.emojis.empty() && b.handle.empty();
|
|
check(pok, "sticker pack JSON parses with every field and getter");
|
|
}
|
|
|
|
// MediaConn parse.
|
|
{
|
|
WaNode mc("media_conn");
|
|
mc.attrs["auth"] = "auth-token";
|
|
mc.attrs["ttl"] = "900";
|
|
mc.attrs["auth_ttl"] = "1800";
|
|
mc.attrs["max_buckets"] = "5";
|
|
WaNode h1("host"), h2("host");
|
|
h1.attrs["hostname"] = "mmg.whatsapp.net";
|
|
h2.attrs["hostname"] = "mmg2.whatsapp.net";
|
|
mc.content = WaNode::Children{h1, h2};
|
|
WaMediaConn c = WaMediaConn::parse(mc, 1700000000);
|
|
check(c.auth == "auth-token" && c.ttl == 900 && c.auth_ttl == 1800 && c.max_buckets == 5 &&
|
|
c.hosts.size() == 2 && c.first_host() == "mmg.whatsapp.net",
|
|
"media_conn parses auth/ttls/hosts");
|
|
check(c.expiry() == 1700000900, "media_conn expiry is fetched_at + ttl");
|
|
check(!c.expired(1700000900) && c.expired(1700000901), "media_conn expiry boundary");
|
|
int bad = 0;
|
|
WaNode wrong("iq");
|
|
try {
|
|
WaMediaConn::parse(wrong, 0);
|
|
} catch (const WaMediaError&) {
|
|
++bad;
|
|
}
|
|
WaNode noauth("media_conn");
|
|
noauth.attrs["ttl"] = "1";
|
|
noauth.attrs["auth_ttl"] = "1";
|
|
noauth.attrs["max_buckets"] = "1";
|
|
try {
|
|
WaMediaConn::parse(noauth, 0);
|
|
} catch (const WaMediaError&) {
|
|
++bad;
|
|
}
|
|
WaNode badttl("media_conn");
|
|
badttl.attrs["auth"] = "a";
|
|
badttl.attrs["ttl"] = "soon";
|
|
badttl.attrs["auth_ttl"] = "1";
|
|
badttl.attrs["max_buckets"] = "1";
|
|
try {
|
|
WaMediaConn::parse(badttl, 0);
|
|
} catch (const WaMediaError&) {
|
|
++bad;
|
|
}
|
|
check(bad == 3, "media_conn rejects wrong tag, missing auth, bad ttl");
|
|
WaMediaConn empty;
|
|
bool threw = false;
|
|
try {
|
|
empty.first_host();
|
|
} catch (const WaMediaError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "first_host throws on an empty set");
|
|
}
|
|
|
|
// URL shapes (exact strings; formulas read from the whatsmeow call sites).
|
|
{
|
|
Bytes h(32, 0xab);
|
|
check(WaMedia::download_url("mmg.whatsapp.net", "/v/x?auth=1", h, "image") ==
|
|
"https://mmg.whatsapp.net/v/x?auth=1&hash=" + WaMedia::token(h) + "&mms-type=image&__wa-mms=",
|
|
"download URL shape");
|
|
check(WaMedia::upload_url("mmg.whatsapp.net", "mms", "image", "tok==", "a&b") ==
|
|
"https://mmg.whatsapp.net/mms/image/tok==?auth=a%26b&token=tok%3D%3D",
|
|
"upload URL shape (sorted query, escaped values)");
|
|
check(WaMedia::delete_url("mmg.whatsapp.net", "image", "tok==", "/v/x", "a b", "e+h") ==
|
|
"https://mmg.whatsapp.net/mms/image/tok==?auth=a%20b&d_md=L3YveA&e_handle=e%2Bh&token=tok%3D%3D",
|
|
"delete URL shape (d_md raw base64url, sorted query)");
|
|
}
|
|
|
|
// Upload-response JSON.
|
|
{
|
|
WaMedia::UploadResponse r = WaMedia::UploadResponse::parse_json(
|
|
R"({"url":"https://u","direct_path":"/v/d","handle":"h","object_id":"o"})");
|
|
check(r.url == "https://u" && r.direct_path == "/v/d" && r.handle == "h" && r.object_id == "o",
|
|
"upload response parses url/path/handle/object");
|
|
int bad = 0;
|
|
try {
|
|
WaMedia::UploadResponse::parse_json(R"({"direct_path":"/v/d"})");
|
|
} catch (const WaMediaError&) {
|
|
++bad;
|
|
}
|
|
try {
|
|
WaMedia::UploadResponse::parse_json("not json");
|
|
} catch (const WaMediaError&) {
|
|
++bad;
|
|
}
|
|
check(bad == 2, "upload response rejects missing url and bad JSON");
|
|
}
|
|
|
|
// Download plumbing over a fake transport (crypto already pinned above).
|
|
{
|
|
Bytes key = fromhex(v.get("media_key_hex").str());
|
|
Bytes plain(1000, 0x41);
|
|
WaMedia::Encrypted enc = WaMedia::encrypt(plain, "WhatsApp Image Keys", key);
|
|
WaMedia::Downloadable img{"/v/img?auth=1", key, enc.file_sha256, enc.file_enc_sha256};
|
|
auto fake = std::make_shared<FakeMediaHttp>();
|
|
WaMedia::set_http_client(fake);
|
|
std::vector<int> sleeps;
|
|
WaMedia::set_sleeper([&](int s) { sleeps.push_back(s); });
|
|
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
Bytes got = WaMedia::download(img, "ImageMessage", m5_conn("h1.example"), "", false, 1 << 20);
|
|
check(got == plain, "download returns the decrypted plaintext");
|
|
check(fake->requests.size() == 1 && fake->requests[0].url.find("https://h1.example/v/img?auth=1&hash=") == 0 &&
|
|
fake->requests[0].url.find("&mms-type=image&__wa-mms=") != std::string::npos,
|
|
"download URL carries host, path, hash and mms-type");
|
|
|
|
// Host failover then success: 5 exhausted retries on host 1, then host 2.
|
|
for (int i = 0; i < WaConsts::kWaMediaDownloadRetries; ++i) fake->gets.push_back(m5_net_err("conn reset"));
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
sleeps.clear();
|
|
got = WaMedia::download(img, "ImageMessage", m5_conn("h1.example", "h2.example"), "", false, 1 << 20);
|
|
check(got == plain && fake->requests.size() == 7 && fake->requests.back().url.find("https://h2.example/") == 0 &&
|
|
sleeps.size() == 4,
|
|
"exhausted retries fail over to the next host");
|
|
bool no_url = false;
|
|
try {
|
|
WaMedia::Downloadable bad = img;
|
|
bad.direct_path.clear();
|
|
WaMedia::download(bad, "ImageMessage", m5_conn("h1.example"), "", false, 1 << 20);
|
|
} catch (const WaMediaError& e) {
|
|
no_url = e.code() == WaMediaErrorCode::NoUrl;
|
|
}
|
|
check(no_url, "empty direct path raises NoUrl");
|
|
|
|
// Terminal errors do not fail over.
|
|
size_t before = fake->requests.size();
|
|
bool t404 = false;
|
|
fake->gets.push_back([&]() { return m5_http(404, Bytes{}); });
|
|
try {
|
|
WaMedia::download(img, "ImageMessage", m5_conn("h1.example", "h2.example"), "", false, 1 << 20);
|
|
} catch (const WaMediaError& e) {
|
|
t404 = e.code() == WaMediaErrorCode::Http && e.http_status() == 404;
|
|
}
|
|
check(t404 && fake->requests.size() == before + 1, "404 throws bare without touching host 2");
|
|
|
|
// Retry inside one host with backoff sleeps, then success.
|
|
fake->gets.push_back([&]() { return m5_http(503, Bytes{}); });
|
|
fake->gets.push_back([&]() { return m5_http(503, Bytes{}, "2"); });
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
sleeps.clear();
|
|
got = WaMedia::download(img, "ImageMessage", m5_conn("h1.example"), "", false, 1 << 20);
|
|
check(got == plain && sleeps.size() == 2 && sleeps[0] == 1 && sleeps[1] == 2,
|
|
"503 retries twice honoring Retry-After (1s, 2s)");
|
|
check(fake->requests.size() == before + 4, "retries stay on the same host");
|
|
|
|
// The last host wraps its failure with the trail.
|
|
fake->gets.push_back([&]() { return m5_http(500, Bytes{}); });
|
|
fake->gets.push_back([&]() { return m5_http(500, Bytes{}); });
|
|
std::string wrap;
|
|
try {
|
|
WaMedia::download(img, "ImageMessage", m5_conn("h1.example", "h2.example"), "", false, 1 << 20);
|
|
} catch (const WaMediaError& e) {
|
|
wrap = e.what();
|
|
}
|
|
check(wrap.find("failed to download media from last host") != std::string::npos &&
|
|
wrap.find("h1.example") != std::string::npos && wrap.find("h2.example") != std::string::npos,
|
|
"last-host failure quotes the per-host trail");
|
|
|
|
// download_any order, thumbnails, FB transport, unencrypted media.
|
|
WaMedia::AnyDownloadable any;
|
|
any.video = img;
|
|
any.image = img;
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
WaMedia::download_any(any, m5_conn("h1.example"), 1 << 20);
|
|
check(fake->requests.back().url.find("mms-type=image") != std::string::npos,
|
|
"download_any prefers image over video");
|
|
WaMedia::AnyDownloadable none;
|
|
bool nada = false;
|
|
try {
|
|
WaMedia::download_any(none, m5_conn("h1.example"), 1 << 20);
|
|
} catch (const WaMediaError& e) {
|
|
nada = e.code() == WaMediaErrorCode::NothingDownloadable;
|
|
}
|
|
check(nada, "download_any with no parts raises NothingDownloadable");
|
|
WaMedia::Encrypted tenc = WaMedia::encrypt(plain, "WhatsApp Link Thumbnail Keys", key);
|
|
WaMedia::DownloadableThumb thumb{"/v/t?auth=1", key, tenc.file_sha256, tenc.file_enc_sha256};
|
|
fake->gets.push_back([&]() { return m5_http(200, tenc.data_to_upload); });
|
|
Bytes tgot = WaMedia::download_thumbnail(thumb, "ExtendedTextMessage", m5_conn("h1.example"), 1 << 20);
|
|
check(tgot == plain && fake->requests.back().url.find("mms-type=thumbnail-link") != std::string::npos,
|
|
"thumbnails download as thumbnail-link");
|
|
bool thumb_bad = false;
|
|
try {
|
|
WaMedia::download_thumbnail(thumb, "ImageMessage", m5_conn("h1.example"), 1 << 20);
|
|
} catch (const WaMediaError& e) {
|
|
thumb_bad = e.code() == WaMediaErrorCode::UnknownMediaType;
|
|
}
|
|
check(thumb_bad, "thumbnail with a non-thumbnail class raises UnknownMediaType");
|
|
WaMedia::Encrypted vence = WaMedia::encrypt(plain, "WhatsApp Video Keys", key);
|
|
WaMedia::FbTransport fb{"/v/f?auth=1", key, vence.file_sha256, vence.file_enc_sha256, 123};
|
|
fake->gets.push_back([&]() { return m5_http(200, vence.data_to_upload); });
|
|
got = WaMedia::download_fb(fb, WaMediaType::Video, m5_conn("h1.example"), 1 << 20);
|
|
check(got == plain && fake->requests.back().url.find("mms-type=video") != std::string::npos,
|
|
"FB transport downloads with the caller type");
|
|
Bytes raw(200, 0x77);
|
|
Bytes rawhash = WaCrypto::sha256(raw);
|
|
WaMedia::Downloadable unenc{"/v/u?auth=1", Bytes{}, rawhash, Bytes{}};
|
|
fake->gets.push_back([&]() { return m5_http(200, raw); });
|
|
check(WaMedia::download(unenc, "DocumentMessage", m5_conn("h1.example"), "", false, 1 << 20) == raw,
|
|
"unencrypted media verifies its hash");
|
|
fake->gets.push_back([&]() { return m5_http(200, raw); });
|
|
check(WaMedia::download_media_with_only_path(m5_conn("h1.example"), "/v/u?auth=1", 1 << 20) == raw,
|
|
"only-path fetch skips every check");
|
|
WaMedia::reset_test_hooks();
|
|
}
|
|
|
|
// To-file downloads.
|
|
{
|
|
Bytes key = fromhex(v.get("media_key_hex").str());
|
|
Bytes plain = m5_pattern(5000);
|
|
WaMedia::Encrypted enc = WaMedia::encrypt(plain, "WhatsApp Audio Keys", key);
|
|
WaMedia::Downloadable aud{"/v/a?auth=1", key, enc.file_sha256, enc.file_enc_sha256};
|
|
auto fake = std::make_shared<FakeMediaHttp>();
|
|
WaMedia::set_http_client(fake);
|
|
WaMedia::set_sleeper([](int) {});
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
FILE* out = m5_tmp_with(Bytes{});
|
|
WaMedia::download_to_file(aud, "AudioMessage", m5_conn("h1.example"), out, 1 << 20);
|
|
check(m5_slurp_file(out) == plain, "download_to_file writes the plaintext");
|
|
std::fclose(out);
|
|
Bytes raw(300, 0x55);
|
|
fake->gets.push_back([&]() { return m5_http(200, raw); });
|
|
out = m5_tmp_with(Bytes{});
|
|
WaMedia::download_media_with_only_path_to_file(m5_conn("h1.example"), "/v/u?auth=1", out, 1 << 20);
|
|
check(m5_slurp_file(out) == raw, "only-path to-file writes the raw body");
|
|
std::fclose(out);
|
|
WaMedia::FbTransport fb{"/v/f?auth=1", key, enc.file_sha256, enc.file_enc_sha256, 0};
|
|
fake->gets.push_back([&]() { return m5_http(200, enc.data_to_upload); });
|
|
out = m5_tmp_with(Bytes{});
|
|
WaMedia::download_fb_to_file(fb, WaMediaType::Audio, m5_conn("h1.example"), out, 1 << 20);
|
|
check(m5_slurp_file(out) == plain, "FB to-file writes the plaintext");
|
|
std::fclose(out);
|
|
WaMedia::reset_test_hooks();
|
|
}
|
|
|
|
// Uploads and deletes over a fake transport.
|
|
{
|
|
auto fake = std::make_shared<FakeMediaHttp>();
|
|
WaMedia::set_http_client(fake);
|
|
WaMedia::set_sleeper([](int) {});
|
|
Bytes key = fromhex(v.get("media_key_hex").str());
|
|
Bytes plain(500, 0x42);
|
|
WaMedia::Encrypted enc = WaMedia::encrypt(plain, "WhatsApp Image Keys", key);
|
|
fake->posts.push_back([&]() {
|
|
std::string j = R"({"url":"https://u","direct_path":"/v/d","handle":"h"})";
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
WaMedia::UploadResponse r =
|
|
WaMedia::upload_encrypted(m5_conn("up.example"), WaMediaType::Image, plain, key);
|
|
check(r.url == "https://u" && r.direct_path == "/v/d" && r.handle == "h" && r.media_key == key &&
|
|
r.file_enc_sha256 == enc.file_enc_sha256 && r.file_sha256 == enc.file_sha256 &&
|
|
r.file_length == 500 && r.streaming_sidecar.empty(),
|
|
"upload_encrypted posts the blob and fills the response");
|
|
check(fake->requests.back().method == "POST" && fake->requests.back().body == enc.data_to_upload &&
|
|
fake->requests.back().url.find("https://up.example/mms/image/") == 0,
|
|
"upload POST carries the ciphertext to /mms/<mms>/<token>");
|
|
// Video gains a sidecar; the reader arm streams the same bytes.
|
|
fake->posts.push_back([&]() {
|
|
std::string j = R"({"url":"https://u"})";
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
r = WaMedia::upload_encrypted(m5_conn("up.example"), WaMediaType::Video, plain, key);
|
|
WaMedia::Keys k = WaMedia::expand(key, "WhatsApp Video Keys");
|
|
WaMedia::Encrypted vence = WaMedia::encrypt(plain, "WhatsApp Video Keys", key);
|
|
check(r.streaming_sidecar == WaMedia::streaming_sidecar(k.iv, k.mac_key, vence.data_to_upload) &&
|
|
!r.streaming_sidecar.empty(),
|
|
"video upload attaches the streaming sidecar");
|
|
FILE* pin = m5_tmp_with(plain);
|
|
fake->post_files.push_back([&]() {
|
|
std::string j = R"({"url":"https://u","direct_path":"/v/r"})";
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
r = WaMedia::upload_reader(m5_conn("up.example"), WaMediaType::Document, pin);
|
|
std::fclose(pin);
|
|
WaMedia::Encrypted dence = WaMedia::encrypt(plain, "WhatsApp Document Keys", r.media_key);
|
|
check(r.direct_path == "/v/r" && r.file_length == 500 && r.media_key.size() == 32 &&
|
|
fake->requests.back().method == "POST_FILE" && fake->requests.back().body == dence.data_to_upload,
|
|
"upload_reader streams ciphertext equal to the one-shot encrypt");
|
|
// Newsletter uploads are unencrypted with the newsletter- prefix.
|
|
fake->posts.push_back([&]() {
|
|
std::string j = R"({"url":"https://n","direct_path":"/v/n"})";
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
r = WaMedia::upload_newsletter(m5_conn("up.example"), WaMediaType::Image, plain);
|
|
check(r.url == "https://n" && r.file_sha256 == WaCrypto::sha256(plain) && r.media_key.empty() &&
|
|
r.file_enc_sha256.empty() &&
|
|
fake->requests.back().url.find("/newsletter/newsletter-image/") != std::string::npos &&
|
|
fake->requests.back().body == plain,
|
|
"newsletter upload posts plaintext to /newsletter/newsletter-<mms>/");
|
|
FILE* nin = m5_tmp_with(plain);
|
|
fake->post_files.push_back([&]() {
|
|
std::string j = R"({"url":"https://n"})";
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
r = WaMedia::upload_newsletter_reader(m5_conn("up.example"), WaMediaType::Image, nin);
|
|
std::fclose(nin);
|
|
check(r.file_length == 500 && r.file_sha256 == WaCrypto::sha256(plain) &&
|
|
fake->requests.back().method == "POST_FILE" && fake->requests.back().body == plain,
|
|
"newsletter reader hashes then posts the same bytes");
|
|
// Deletes: query stripped, companion header, empty path is silent, errors name the op.
|
|
fake->dells.push_back([&]() { return m5_http(200, Bytes{}); });
|
|
WaMedia::delete_media(m5_conn("up.example"), WaMediaType::Image, "/v/x?auth=1", enc.file_enc_sha256, "eh",
|
|
"nonce");
|
|
const auto& dq = fake->requests.back();
|
|
check(dq.method == "DELETE" && dq.url.find("https://up.example/mms/image/") == 0 &&
|
|
dq.url.find("auth=") != std::string::npos && dq.url.find("d_md=") != std::string::npos &&
|
|
dq.url.find("auth=1") == std::string::npos && dq.headers.size() == 1 &&
|
|
dq.headers[0].first == "Companion_User_Secret",
|
|
"delete strips the query, signs the companion header");
|
|
size_t nreq = fake->requests.size();
|
|
WaMedia::delete_media(m5_conn("up.example"), WaMediaType::Image, "", enc.file_enc_sha256, "", "");
|
|
check(fake->requests.size() == nreq, "delete with an empty path sends nothing");
|
|
fake->dells.push_back([&]() { return m5_http(500, Bytes{}); });
|
|
bool derr = false;
|
|
try {
|
|
WaMedia::delete_media(m5_conn("up.example"), WaMediaType::Image, "/v/x", enc.file_enc_sha256, "", "");
|
|
} catch (const WaMediaError& e) {
|
|
derr = std::string(e.what()).find("media delete failed with status code 500") != std::string::npos;
|
|
}
|
|
check(derr, "delete failure names the operation");
|
|
// Sticker pack fetch.
|
|
fake->gets.push_back([&]() {
|
|
std::string j = v.get("sticker_pack_json").str();
|
|
return m5_http(200, Bytes(j.begin(), j.end()));
|
|
});
|
|
WaStickerPack pack = WaMedia::fetch_sticker_pack("pack-9");
|
|
check(pack.id == "pack-9" && pack.stickers.size() == 2 &&
|
|
fake->requests.back().url ==
|
|
"https://static.whatsapp.net/sticker?lottie=1&cat=sticker_pack_data&id=pack-9&lg=en",
|
|
"fetch_sticker_pack GETs the pack endpoint and parses it");
|
|
WaMedia::reset_test_hooks();
|
|
}
|
|
}
|
|
|
|
static void test_media_hostile() {
|
|
std::printf("[media hostile]\n");
|
|
WaMedia::reset_test_hooks();
|
|
uint64_t lcg = 0x12345;
|
|
auto rnd = [&](size_t n) {
|
|
Bytes b;
|
|
for (size_t i = 0; i < n; ++i) {
|
|
lcg = lcg * 6364136223846793005ULL + 1442695040888963407ULL;
|
|
b.push_back(static_cast<uint8_t>(lcg >> 33));
|
|
}
|
|
return b;
|
|
};
|
|
// A valid encrypted blob to mutate.
|
|
Bytes key(32, 0x0a);
|
|
Bytes plain = m5_pattern(1000);
|
|
WaMedia::Encrypted enc = WaMedia::encrypt(plain, "WhatsApp Image Keys", key);
|
|
bool ok = true;
|
|
for (int i = 0; i < 3000; ++i) {
|
|
Bytes blob = enc.data_to_upload;
|
|
Bytes ekey = key, ehash = enc.file_enc_sha256, phash = enc.file_sha256;
|
|
switch (i % 6) {
|
|
case 0: // truncate anywhere, including the empty blob
|
|
blob.resize(rnd(2)[0] % (blob.size() + 1));
|
|
break;
|
|
case 1: // flip bytes
|
|
for (int f = 0; f < 1 + (i % 5); ++f) blob[rnd(2)[0] % blob.size()] ^= static_cast<uint8_t>(1 + (i % 255));
|
|
break;
|
|
case 2: // wrong key/hash lengths
|
|
ekey.resize(rnd(1)[0] % 40);
|
|
ehash.resize(rnd(1)[0] % 40);
|
|
break;
|
|
case 3: // extend with junk
|
|
{
|
|
Bytes junk = rnd(rnd(1)[0] % 100);
|
|
blob.insert(blob.end(), junk.begin(), junk.end());
|
|
}
|
|
break;
|
|
case 4: // swap the MAC
|
|
for (size_t j = 0; j < 10; ++j) blob[blob.size() - 10 + j] = rnd(1)[0];
|
|
break;
|
|
case 5: { // split at a hostile MAC boundary, then decrypt the pieces
|
|
size_t cut = rnd(2)[0] % (blob.size() + 1);
|
|
Bytes file(blob.begin(), blob.begin() + static_cast<ptrdiff_t>(cut));
|
|
Bytes mac(blob.begin() + static_cast<ptrdiff_t>(cut), blob.end());
|
|
try {
|
|
WaMedia::decrypt_blob(file, mac, ekey, WaMediaType::Image, ehash, phash);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
try {
|
|
Bytes file, mac;
|
|
WaMedia::split_encrypted_blob(blob, ehash, file, mac);
|
|
WaMedia::decrypt_blob(file, mac, ekey, WaMediaType::Image, ehash, phash);
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
// The same blob through the file decryptor.
|
|
try {
|
|
FILE* f = m5_tmp_with(blob);
|
|
if (f != nullptr) {
|
|
try {
|
|
WaMedia::cbc_decrypt_file(f, Bytes(32, 0x11), Bytes(16, 0x22));
|
|
} catch (const std::exception&) {
|
|
}
|
|
std::fclose(f);
|
|
}
|
|
} catch (const std::length_error&) {
|
|
ok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
check(ok, "3000 mutated blobs: handled or accepted, never length_error");
|
|
// Hostile JSON, nodes, retry values and sidecar inputs.
|
|
bool jok = true;
|
|
std::string good_json = Value::loads(slurp(path("media_vectors_m5.json"))).get("sticker_pack_json").str();
|
|
for (int i = 0; i < 500; ++i) {
|
|
std::string mut = good_json;
|
|
if (!mut.empty()) {
|
|
mut[rnd(2)[0] % mut.size()] = static_cast<char>(rnd(1)[0]);
|
|
if (i % 3 == 0) mut.resize(rnd(2)[0] % (mut.size() + 1));
|
|
}
|
|
try {
|
|
WaStickerPack::parse_json(mut);
|
|
} catch (const std::length_error&) {
|
|
jok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
WaMedia::UploadResponse::parse_json(mut);
|
|
} catch (const std::length_error&) {
|
|
jok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
WaNode mc("media_conn");
|
|
mc.attrs["auth"] = std::string(rnd(1)[0] % 8, 'a');
|
|
mc.attrs["ttl"] = std::to_string(static_cast<int>(rnd(2)[0]) - 100);
|
|
mc.attrs["auth_ttl"] = i % 2 == 0 ? "x" : "";
|
|
mc.attrs["max_buckets"] = "5";
|
|
try {
|
|
WaMediaConn::parse(mc, 0);
|
|
} catch (const std::length_error&) {
|
|
jok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
Bytes rb = rnd(rnd(1)[0] % 40);
|
|
WaMedia::streaming_sidecar(rb, rnd(32), rnd(rnd(1)[0] % 200));
|
|
} catch (const std::length_error&) {
|
|
jok = false;
|
|
} catch (const std::exception&) {
|
|
}
|
|
try {
|
|
Bytes rb(rnd(1)[0] % 300, 0);
|
|
for (auto& c : rb) c = static_cast<uint8_t>(rnd(1)[0] % 128);
|
|
std::string rs(rb.begin(), rb.end());
|
|
WaMedia::parse_retry_after(rs, 5, 1700000000);
|
|
} catch (const std::exception&) {
|
|
jok = false; // the parser must never throw at all
|
|
}
|
|
}
|
|
check(jok, "500 hostile JSON/nodes/sidecars/retry-values: no length_error, retry parse never throws");
|
|
// Deterministic edges.
|
|
int edges = 0;
|
|
try {
|
|
WaMedia::download_url(std::string(9000, 'h'), "/v", Bytes(32, 0), "image");
|
|
} catch (const WaMediaError& e) {
|
|
edges += e.code() == WaMediaErrorCode::BadArgument ? 1 : 0;
|
|
}
|
|
try {
|
|
WaStickerPack::parse_json(std::string(WaConsts::kWaStickerJsonMax + 1, 'x'));
|
|
} catch (const WaMediaError&) {
|
|
++edges;
|
|
}
|
|
try {
|
|
Bytes f, m;
|
|
WaMedia::split_encrypted_blob(Bytes(100, 0), Bytes(31, 0), f, m);
|
|
} catch (const WaMediaError& e) {
|
|
edges += e.code() == WaMediaErrorCode::BadArgument ? 1 : 0;
|
|
}
|
|
// Corrupt padding with a recomputed MAC and hash: MAC passes, unpad must fail loudly.
|
|
try {
|
|
Bytes file(enc.data_to_upload.begin(), enc.data_to_upload.end() - 10);
|
|
file[file.size() - 1] ^= 0x01;
|
|
WaMedia::Keys k = WaMedia::expand(key, "WhatsApp Image Keys");
|
|
Bytes mac = WaCrypto::hmac_sha256(k.mac_key, WaCrypto::concat({k.iv, file}));
|
|
mac.resize(10);
|
|
Bytes blob = file;
|
|
blob.insert(blob.end(), mac.begin(), mac.end());
|
|
Bytes new_enc_hash = WaCrypto::sha256(blob);
|
|
WaMedia::decrypt_blob(file, mac, key, WaMediaType::Image, new_enc_hash, enc.file_sha256);
|
|
} catch (const WaMediaError& e) {
|
|
edges += std::string(e.what()).find("bad padding") != std::string::npos ? 1 : 0;
|
|
}
|
|
check(edges == 4, "overlong URL, oversized sticker body, short hash, corrupt padding all loud");
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
DIR = argc > 1 ? argv[1] : "tests/whatsapp_data";
|
|
test_tokens();
|
|
test_iq();
|
|
test_keepalive();
|
|
test_server_cert();
|
|
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_store_m2();
|
|
test_store_hostile();
|
|
test_pairing_oracle();
|
|
test_pairing_nodes();
|
|
test_qr_channel();
|
|
test_pairing_hostile();
|
|
test_media_m5();
|
|
test_media_hostile();
|
|
std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|