diff --git a/taicpp/CHANGELOG.md b/taicpp/CHANGELOG.md index b3ea5fc..3ecd9bb 100644 --- a/taicpp/CHANGELOG.md +++ b/taicpp/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - 2026-10-28 - WhatsApp transpilation harness (M0): whatsmeow `v0.0.0-20261027104441-1eec1136a50b` (commit `1eec1136a50b...0457`) pinned in [docs/whatsapp.md](docs/whatsapp.md) section 17; `tools/wa-api-inventory.sh` extracts the mechanical scope (1500 entries: every exported symbol in non-test, non-generated Go source plus the 129 used protobuf messages), `tools/wa-mapping.tsv` maps each entry to its `detz::wa` counterpart or `UNMAPPED`, `tools/wa-coverage.sh` fails below 100% (baseline 137/1500, 9.1%), `tools/wa-stub-scan.sh` fails on stub patterns and new silent early-outs. Section 17 also opens the moloapp reuse table. +- WhatsApp foundations (M1, [docs/whatsapp.md](docs/whatsapp.md) section 18): token-dict freshness oracle (236 + 4x256 strings verified verbatim), socket keepalive pings (`WaKeepAlive`, 20-30 s jitter, 10 s deadline, 3 min horizon), full iq request/response (`WaIq` + `WaIqError`/`WaDisconnectedError` taxonomy, whatsmeow-shaped request ids, 75 s default wait), Noise parity (`WaNoise::mix_shared_secret`, overflow-safe framing, `WaServerCert` chain verification that caught and fixed two proto field bugs, socket guards). Slice `tools/wa-slices/m1-foundations.txt` at 100% (91/91); full inventory 210/1522. Live-verified: handshake + cert check pass against web.whatsapp.com, pings answered over a 60 s session. ## [1.46.1] - 2026-10-27 diff --git a/taicpp/docs/whatsapp.md b/taicpp/docs/whatsapp.md index b9accee..01d8f51 100644 --- a/taicpp/docs/whatsapp.md +++ b/taicpp/docs/whatsapp.md @@ -800,3 +800,114 @@ fails review. | 2026-10-28 | WebSocket client transport | `realtime/websocket/client/websocket_client.h` (exists, but needs a caller-provided transport: the caller does TLS and the HTTP handshake itself) | keep local `WaSocket` on libcurl: live-verified against the real server (connect + frames), libcurl already linked. Revisit if libcurl ws proves insufficient (e.g. M1 keepalive pings). | | 2026-10-28 | media HTTPS upload/download | `clients/` HTTP client, pool, proxy (heavier connection-pool stack) | keep local `WaMedia` on `curl_easy` (`wa_media.cpp`): two calls (one POST, one GET), libcurl already linked | | 2026-10-28 | vault storage for device/sessions/contacts | detz-owned `store::SqlDb` (already the vault; the schema is detz's) | keep local `WaStore` on `store::SqlDb` (`wa_store.h`); no moloapp store facility needed | +| 2026-10-28 | M1 keepalive pings (revisit of the M0 transport row) | `realtime/websocket/client/websocket_client.h` `send_ping`, `realtime/websocket/server/heartbeat_manager.h`, `realtime/engineio/heartbeat_scheduler.h` | wrote local `WaKeepAlive` (`wa_keepalive.h`): whatsmeow keepalive is an application iq (`w:p` get), not a websocket ping frame, with whatsmeow-specific timing (20-30 s jitter, 10 s deadline, 3 min horizon); moloapp's facilities are ws-protocol/engine.io level and none fits. `WaSocket` stays on libcurl. | + +## 18. M1 foundations: tokens, socket, iq, Noise (2026-10-28) + +Milestone M1 of `full_transpilation_and_integration.md` (slice +`tools/wa-slices/m1-foundations.txt`, 91 entries, 100% mapped; full inventory +210/1522, 13.8%). Four pieces, each proven by a Go oracle that calls the pinned +whatsmeow itself, plus hostile-input tests and a sanitizer run. + +### 18.1 Harness repairs found by M1 + +Three harness repairs surfaced and were fixed in this milestone (all minimal, +all required by the M1 gates): + +- `tools/wa-api-inventory.sh` cut `//` comments before stripping string + literals, so the `//` inside the `"https://..."` token broke brace-depth + counting and the `var (` block in `binary/token/token.go` swallowed the + rest of the file. `token.DictVersion`, 3 lookup funcs and 18 wire consts + (22 entries) were missing from the scope. Fixed by stripping strings + first; the inventory is now 1522 entries (was 1500) with zero removals. +- `tools/wa-stub-scan.sh` died silently (exit 1, no verdict) on untracked + files without matches: `grep` exits 1, and `set -e`/`pipefail` killed the + script. Fixed with `|| true` plus a comment; M0's validation (fail on an + injected TODO / silent return) still holds. +- `tools/san-whatsapp.sh` compiled only 6 parser TUs, but the test now links + the whole module. It compiles all of `src/whatsapp/` plus the support TUs + (`json`, `seal_base64`, `sqldb`, `fuzzy`, `qrcodegen`) and links curl and + sqlite3. + +### 18.2 Token dictionary freshness + +`tests/whatsapp_data/token_oracle.go` dumps whatsmeow's own `binary/token` +tables (version, 236 + 4x256 strings, 18 consts) and whatsmeow-marshaled wire +bytes for 14 token-probe nodes (single-byte tokens, one token per double-byte +dictionary, an untokenized literal). `build/whatsapp_test` (`test_tokens`) +compares every string verbatim, every const, and byte-exact re-encodes all 14 +probes. Re-running the oracle after a pin move fails loudly on drift. The +tables were already correct; the check is the deliverable. + +Two encoding forms differ cosmetically and both are valid: whatsmeow writes a +typed server-JID `to` as JID_PAIR while detz writes the same string as its +1-byte token, and whatsmeow writes `""` as NIBBLE_8 while detz writes +BINARY_8. Both pairs decode identically (proven in `test_iq`/`test_tokens`); +the server accepts ours (proven live, section 18.5). + +### 18.3 Socket keepalive pings + +`WaKeepAlive` (`wa_keepalive.h`) is the whatsmeow `keepAliveLoop` schedule as +pure timing state over an injected millisecond clock: jittered 20-30 s +intervals, error counting, forced reconnect past the 3 min horizon. Caps in +`wa_consts.h` (`kWaKeepAliveResponseDeadline` 10 s, `kWaKeepAliveIntervalMin` +20 s, `kWaKeepAliveIntervalMax` 30 s, `kWaKeepAliveMaxFailTime` 180 s). +`WaClient::run_until` starts it and sends a `w:p` get on schedule via +`query_iq`; any reply proves liveness, exactly as `sendKeepAlive`. On a forced +reconnect the client logs and disconnects; the redial lands with M9 +auto-reconnect. The `KeepAliveTimeout`/`KeepAliveRestored` events belong to +the M9 event bus; until then failures are loud log lines. + +### 18.4 Full iq request/response + +`WaIq` (`wa_iq.h`) mirrors `request.go` for the synchronous client: `WaIqQuery` +plus `build` (id/xmlns/type, `smax_id`, `to`, `target`, one child), +`parse_error` (whatsmeow `parseIQError`, overflow-safe code parse, never +fails), `is_disconnect_node` / `is_auth_error_disconnect` (401, replaced, +device_removed), and whatsmeow-shaped request ids (`new_prefix` draws +`.-`, `generate_id` appends the counter). `WaIqError` carries +the 13 `ErrIQ*` sentinels plus `timed_out()` with `message()`/`is()`; +`WaDisconnectedError` carries `for_iq()` with `message()`/`is()`. +`WaClient::send_iq` now classifies every reply (error replies log code/text +and are returned; disconnects and timeouts log loudly), defaults to the 75 s +`kWaIqDefaultTimeout`, and `query_iq` fills ids. `ErrClientIsNil` maps to the +class itself: methods take references, a nil receiver is unrepresentable. +Retry-on-reconnect needs M9 auto-reconnect (`NoRetry` arrives with it); today +a disconnect during an iq is a loud `WaDisconnectedError`, never a silent +drop. Vectors: `iq_oracle.go` (whatsmeow-marshaled ping, count, 404, 500, +bare, result, three disconnects; code/text extracted by whatsmeow's own +getters; byte-exact encode checks where deterministic). + +### 18.5 Noise parity + +- `WaNoise::mix_shared_secret` names the X25519-into-key step + (`MixSharedSecretIntoKey`); the handshake calls it three times. Behavior is + unchanged (existing transcript test) and now differentially proven: the + oracle runs whatsmeow's own `NoiseHandshake` over the wa-go trace and our + decrypted server static + payload match byte-for-byte. +- `WaFraming::kLengthSize` names the 3-byte prefix, and `unframe` uses + overflow-safe subtraction checks (it had the forbidden `pos + 3` form). +- `WaServerCert` (`wa_cert.h`) verifies the Noise certificate chain exactly + as `verifyServerCert`: pinned-key intermediate check, issuer serials, + 64-byte signatures, key lengths, validity windows, leaf-key-vs-static + match. Any failure throws `WaCertError` with the reason, and the handshake + fails closed. This caught two real proto bugs: `parse_cert_chain` had leaf + and intermediate swapped (fields are leaf = 1, intermediate = 2), and + `parse_cert_details` read the wrong fields (serial = 1, issuer = 2, key = + 3, not_before = 4, not_after = 5). Vectors: `cert_oracle.go` (decrypted + bytes, per-field values, per-check verdicts from whatsmeow + libsignal; + the captured leaf is expired today, so the test time-travels inside its + window for the accept case and asserts expiry now, plus 6 tamper and 4 + hostile rejections). +- `WaSocket` gained the already-open and not-connected guards plus + `WaSocketStatusError` (HTTP status on connect failure); `WaClient` + refuses a second connect loudly and reports closes through the new + `on_disconnect` hook. + +Live verification (2026-10-28, scratch `TAI_HOME`, no phone): +`detz --whatsapp-link` completed the Noise handshake against +`web.whatsapp.com` with the new certificate verification passing +("handshake ok (registration)") and rendered the QR. A 60 s session fired +keepalive pings on schedule with server replies (zero unanswered/failed +lines, connection stayed open until the timeout kill). The cert accept path +and the ping shape are therefore live-proven, not just oracle-proven. diff --git a/taicpp/src/whatsapp/wa_binary.h b/taicpp/src/whatsapp/wa_binary.h index 48271f2..515204b 100644 --- a/taicpp/src/whatsapp/wa_binary.h +++ b/taicpp/src/whatsapp/wa_binary.h @@ -48,6 +48,7 @@ class WaBinary { NIBBLE_8 = 255, PACKED_MAX = 127, }; + static constexpr int SINGLE_BYTE_MAX = 256; // token.SingleByteMax: single-byte token indexes run 0..255 }; } // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_cert.cpp b/taicpp/src/whatsapp/wa_cert.cpp new file mode 100644 index 0000000..b2b7072 --- /dev/null +++ b/taicpp/src/whatsapp/wa_cert.cpp @@ -0,0 +1,81 @@ +#include "wa_cert.h" + +#include +#include +#include + +#include "wa_consts.h" +#include "wa_crypto.h" +#include "wa_proto.h" + +namespace detz::wa { + +namespace { + +std::array as32(const Bytes& b) { + std::array a{}; + std::memcpy(a.data(), b.data(), 32); + return a; +} + +std::array as64(const Bytes& b) { + std::array a{}; + std::memcpy(a.data(), b.data(), 64); + return a; +} + +// Range-checked validity (checkCertValidity): a negative now or a window edge +// outside int64 is rejected rather than converted. +void check_validity(const WaNoiseCertDetails& d, const std::string& which, int64_t now_unix) { + const bool too_early = now_unix < 0 || static_cast(now_unix) < d.not_before; + if (too_early) throw WaCertError(which + " certificate not valid yet"); + const bool too_late = d.not_after > static_cast(INT64_MAX) || + now_unix > static_cast(d.not_after); + if (too_late) throw WaCertError(which + " certificate expired"); +} + +void verify_inner(const Bytes& chain, const Bytes& server_static, int64_t now_unix) { + auto parsed = parse_cert_chain(chain); + if (!parsed) throw WaCertError("failed to unmarshal noise certificate"); + const Bytes& im_raw = parsed->intermediate_details; + const Bytes& im_sig = parsed->intermediate_signature; + const Bytes& leaf_raw = parsed->leaf_details; + const Bytes& leaf_sig = parsed->leaf_signature; + if (im_raw.empty() || im_sig.empty() || leaf_raw.empty() || leaf_sig.empty()) + throw WaCertError("missing parts of noise certificate"); + if (im_sig.size() != 64) + throw WaCertError("unexpected length of intermediate cert signature " + std::to_string(im_sig.size())); + if (leaf_sig.size() != 64) + throw WaCertError("unexpected length of leaf cert signature " + std::to_string(leaf_sig.size())); + if (!WaCrypto::xeddsa_verify(WaConsts::kWaCertPubKey, im_raw, as64(im_sig))) + throw WaCertError("failed to verify intermediate cert signature"); + auto im = parse_cert_details(im_raw); + if (!im) throw WaCertError("failed to unmarshal intermediate certificate details"); + if (im->issuer_serial != WaConsts::kWaCertIssuerSerial) + throw WaCertError("unexpected intermediate issuer serial " + std::to_string(im->issuer_serial)); + if (im->key.size() != 32) + throw WaCertError("unexpected length of intermediate cert key " + std::to_string(im->key.size())); + if (!WaCrypto::xeddsa_verify(as32(im->key), leaf_raw, as64(leaf_sig))) + throw WaCertError("failed to verify leaf cert signature"); + check_validity(*im, "intermediate", now_unix); + auto leaf = parse_cert_details(leaf_raw); + if (!leaf) throw WaCertError("failed to unmarshal leaf certificate details"); + if (leaf->issuer_serial != im->serial) + throw WaCertError("unexpected leaf issuer serial " + std::to_string(leaf->issuer_serial)); + if (!WaCrypto::const_time_eq(leaf->key, server_static)) throw WaCertError("cert key doesn't match decrypted static"); + check_validity(*leaf, "leaf", now_unix); +} + +} // namespace + +void WaServerCert::verify(const Bytes& chain, const Bytes& server_static, int64_t now_unix) { + try { + verify_inner(chain, server_static, now_unix); + } catch (const WaCertError&) { + throw; + } catch (const std::exception& e) { + throw WaCertError(std::string("unreadable certificate chain: ") + e.what()); + } +} + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_cert.h b/taicpp/src/whatsapp/wa_cert.h new file mode 100644 index 0000000..9ef3dba --- /dev/null +++ b/taicpp/src/whatsapp/wa_cert.h @@ -0,0 +1,28 @@ +#pragma once +// WaServerCert: verification of the WhatsApp server's Noise certificate chain +// against the pinned WACertPubKey, exactly as whatsmeow verifyServerCert +// (handshake.go, MPL-2.0). A failed check throws naming the reason; the +// handshake must fail closed. See docs/whatsapp.md section 18. +#include +#include +#include +#include + +#include "wa_node.h" + +namespace detz::wa { + +class WaCertError : public std::runtime_error { + public: + explicit WaCertError(const std::string& m) : std::runtime_error("wa cert: " + m) {} +}; + +class WaServerCert { + public: + // Verify the decrypted Noise certificate payload (the CertChain) against + // the decrypted server static key. now_unix defaults to the current time; + // tests inject it to travel inside a captured chain's validity window. + static void verify(const Bytes& chain, const Bytes& server_static, int64_t now_unix = std::time(nullptr)); +}; + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_client.cpp b/taicpp/src/whatsapp/wa_client.cpp index 120a334..78d443f 100644 --- a/taicpp/src/whatsapp/wa_client.cpp +++ b/taicpp/src/whatsapp/wa_client.cpp @@ -1,7 +1,5 @@ #include "wa_client.h" -#include - #include #include #include @@ -12,6 +10,7 @@ #include "seal_base64.h" #include "wa_appstate.h" #include "wa_binary.h" +#include "wa_cert.h" #include "wa_consts.h" #include "wa_media.h" #include "wa_proto.h" @@ -34,7 +33,7 @@ constexpr std::array kAdvHostedAccountPrefix = {6, 5}; constexpr int kAdvEncryptionHosted = 1; // ADVEncryptionType_HOSTED } // namespace -WaClient::WaClient(WaStorePort& store) : store_(store) { +WaClient::WaClient(WaStorePort& store) : store_(store), iq_prefix_(WaIq::new_prefix()) { if (auto d = store_.load_device()) device_ = *d; else { device_ = WaDevice::generate(); @@ -48,9 +47,13 @@ void WaClient::log(const std::string& s) { if (on_log) on_log(s); } -std::string WaClient::next_iq_id() { return std::to_string(++iq_counter_) + "." + std::to_string(::getpid() & 0xffff); } +std::string WaClient::next_iq_id() { return WaIq::generate_id(iq_prefix_, ++iq_counter_); } bool WaClient::connect() { + if (sock_ && sock_->connected()) { + log("connect refused: already connected"); + return false; + } sock_ = std::make_unique(); try { sock_->connect(WaConsts::kWaConnectTimeout); @@ -82,6 +85,12 @@ bool WaClient::do_handshake() { log("noise handshake failed"); return false; } + try { + WaServerCert::verify(res->server_payload, res->server_static); + } catch (const std::exception& e) { + log(std::string("server certificate rejected, failing closed: ") + e.what()); + return false; + } try { sock_->send_frame(WaFraming::frame(res->client_finish, false)); } catch (const std::exception& e) { @@ -314,11 +323,27 @@ void WaClient::handle_pair_success(const WaNode& iq) { } void WaClient::run_until(const std::function& stop, int timeout_seconds) { + auto clock_ms = []() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + }; + keepalive_.start(clock_ms()); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds); while (std::chrono::steady_clock::now() < deadline) { if (stop && stop()) return; // a closed stream returns from recv at once; without this check the loop spun at 100% CPU until the deadline if (!socket_open()) return; + if (keepalive_.due(clock_ms())) { + if (send_keepalive_ping()) { + keepalive_.record_pong(clock_ms()); + } else if (keepalive_.record_timeout(clock_ms())) { + log("keepalive failed for " + std::to_string(WaConsts::kWaKeepAliveMaxFailTime) + + "s, disconnecting (whatsmeow keepAliveLoop force-reconnect; the redial lands with M9 auto-reconnect)"); + disconnect(); + return; + } + } auto node = recv_node(2); if (node) dispatch(*node); } @@ -428,15 +453,53 @@ std::optional WaClient::send_iq(const WaNode& iq, int timeout_seconds) { send_node(iq); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds); while (std::chrono::steady_clock::now() < deadline) { - if (!socket_open()) return std::nullopt; + if (!socket_open()) { + log("iq " + id + ": " + WaDisconnectedError("info query").message()); + return std::nullopt; + } auto node = recv_node(2); if (!node) continue; - if (node->tag == "iq" && node->attr("id") == id) return node; + if (WaIq::is_disconnect_node(*node)) { + std::string why = WaDisconnectedError("info query", *node).message(); + why += WaIq::is_auth_error_disconnect(*node) ? ", not retrying (auth error)" + : ", retry needs M9 auto-reconnect"; + log("iq " + id + ": " + why); + return std::nullopt; + } + if (node->tag == "iq" && node->attr("id") == id) { + if (node->attr("type") == "error") log("iq " + id + " error: " + WaIq::parse_error(*node).message()); + return node; + } dispatch(*node); // handle anything else (keepalive, notifications) } + log("iq " + id + ": " + WaIqError::timed_out().message()); return std::nullopt; } +std::optional WaClient::query_iq(WaIqQuery q) { + if (q.id.empty()) q.id = next_iq_id(); + const int wait = q.timeout_seconds != 0 ? q.timeout_seconds : WaConsts::kWaIqDefaultTimeout; + return send_iq(WaIq::build(q), wait); +} + +bool WaClient::send_keepalive_ping() { + WaIqQuery q; + q.ns = "w:p"; + q.type = "get"; + q.to = WaConsts::kServerJid; + q.timeout_seconds = WaConsts::kWaKeepAliveResponseDeadline; + try { + auto reply = query_iq(q); + // any reply proves liveness, even an error one (whatsmeow sendKeepAlive returns success on any response) + const bool answered = reply.has_value(); + if (!answered) log("keepalive ping unanswered"); + return answered; + } catch (const std::exception& e) { + log(std::string("keepalive ping failed: ") + e.what()); + return false; + } +} + std::vector WaClient::usync_devices(const std::vector& jids) { WaNode query("query"); WaNode devices("devices"); @@ -1087,10 +1150,12 @@ void WaClient::sync_app_state(int key_wait_seconds) { } void WaClient::disconnect() { + const bool was_open = socket_open(); if (sock_) sock_->close(); sock_.reset(); noise_.reset(); logged_in_ = false; + if (was_open && on_disconnect) on_disconnect(); } } // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_client.h b/taicpp/src/whatsapp/wa_client.h index e3614bc..41dc85f 100644 --- a/taicpp/src/whatsapp/wa_client.h +++ b/taicpp/src/whatsapp/wa_client.h @@ -11,7 +11,10 @@ #include #include +#include "wa_consts.h" #include "wa_device.h" +#include "wa_iq.h" +#include "wa_keepalive.h" #include "wa_node.h" #include "wa_noise.h" #include "wa_socket.h" @@ -68,6 +71,7 @@ class WaClient { std::function& qr_codes)> on_qr; // registration: QR strings to show std::function on_paired; // pairing completed, device saved std::function on_log; // human-readable progress + std::function on_disconnect; // the stream closed (socket.DisconnectHandler role) // Connect, run the Noise handshake, and send the ClientPayload (registration // when the device is unpaired, login when paired). Returns false on failure. @@ -148,6 +152,8 @@ class WaClient { bool restart_seen_{false}; // stream:error 515 received int failure_code_{0}; // reason from the last node uint64_t iq_counter_{0}; + std::string iq_prefix_; // ".-" request-id prefix, drawn once per client + WaKeepAlive keepalive_; // ping schedule for run_until (whatsmeow keepAliveLoop role) Bytes last_plaintext_; // the most recent decrypted frame, for capture std::string capture_path_; // where to record a frame that broke a parser @@ -184,8 +190,14 @@ class WaClient { std::string next_iq_id(); // --- send path helpers (live, unverified in-tree) --- - // one synchronous iq request/response, matching by id - std::optional send_iq(const WaNode& iq, int timeout_seconds); + // one synchronous iq request/response, matching by id; error replies are + // logged with their code/text and returned for the caller, disconnects and + // timeouts log loudly and yield nothing + std::optional send_iq(const WaNode& iq, int timeout_seconds = WaConsts::kWaIqDefaultTimeout); + // the same through a WaIqQuery: fills the id when empty, builds the stanza + std::optional query_iq(WaIqQuery q); + // one keepalive ping (iq get xmlns w:p); any reply proves liveness + bool send_keepalive_ping(); // device JIDs for a set of user JIDs via usync std::vector usync_devices(const std::vector& jids); // ensure a Signal session exists for each device jid (fetch prekeys if not) diff --git a/taicpp/src/whatsapp/wa_consts.h b/taicpp/src/whatsapp/wa_consts.h index d7c97fd..371987a 100644 --- a/taicpp/src/whatsapp/wa_consts.h +++ b/taicpp/src/whatsapp/wa_consts.h @@ -48,6 +48,20 @@ struct WaConsts { static constexpr const char* kWaReceiptRetry = "retry"; // vCard contact import fallback (--whatsapp-import) static constexpr size_t kWaVcfFileMax = 8u * 1024 * 1024; // refuse a bigger .vcf, loud + // Noise server certificate (whatsmeow handshake.go WACertPubKey/WACertIssuerSerial): the pinned key the + // server's intermediate cert must be signed with, and the intermediate's required issuer serial. + static constexpr std::array kWaCertPubKey = {0x14, 0x23, 0x75, 0x57, 0x4d, 0x0a, 0x58, 0x71, + 0x66, 0xaa, 0xe7, 0x1e, 0xbe, 0x51, 0x64, 0x37, + 0xc4, 0xa2, 0x8b, 0x73, 0xe3, 0x69, 0x5c, 0x6c, + 0xe1, 0xf7, 0xf9, 0x54, 0x5d, 0xa8, 0xee, 0x6b}; + static constexpr uint32_t kWaCertIssuerSerial = 0; + // iq request/response (whatsmeow request.go defaultRequestTimeout): every synchronous iq wait ends here. + static constexpr int kWaIqDefaultTimeout = 75; // seconds + // socket keepalive (whatsmeow keepalive.go): jittered iq ping, reply deadline, force-reconnect horizon. + static constexpr int kWaKeepAliveResponseDeadline = 10; // seconds to wait for one ping reply + static constexpr int kWaKeepAliveIntervalMin = 20; // seconds, jitter floor between pings + static constexpr int kWaKeepAliveIntervalMax = 30; // seconds, jitter ceiling between pings + static constexpr int kWaKeepAliveMaxFailTime = 180; // seconds of failed pings before a forced reconnect }; } // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_iq.cpp b/taicpp/src/whatsapp/wa_iq.cpp new file mode 100644 index 0000000..61d88e2 --- /dev/null +++ b/taicpp/src/whatsapp/wa_iq.cpp @@ -0,0 +1,91 @@ +#include "wa_iq.h" + +#include +#include +#include + +#include "wa_crypto.h" + +namespace detz::wa { + +WaIqError::WaIqError(int code, std::string text) : code_(code), text_(std::move(text)) {} + +WaIqError::WaIqError(int code, std::string text, std::optional error_node, std::optional raw) + : code_(code), text_(std::move(text)), error_node_(std::move(error_node)), raw_(std::move(raw)) {} + +WaIqError WaIqError::timed_out() { return WaIqError(-1, "timed out"); } + +std::string WaIqError::message() const { + if (code_ < 0) return "info query " + text_; + if (code_ == 0) { + if (error_node_.has_value()) return "info query returned unknown error: " + error_node_->debug(); + if (raw_.has_value()) return "info query returned unexpected response: " + raw_->debug(); + return "unknown info query error"; + } + return "info query returned status " + std::to_string(code_) + ": " + text_; +} + +bool WaIqError::is(const WaIqError& other) const { + if (code_ != 0 && other.code_ != 0) return code_ == other.code_ && text_ == other.text_; + const bool same_node = error_node_.has_value() && other.error_node_.has_value() && + error_node_->debug() == other.error_node_->debug(); + return same_node; +} + +WaDisconnectedError::WaDisconnectedError(std::string action, std::optional node) + : action_(std::move(action)), node_(std::move(node)) {} + +std::string WaDisconnectedError::message() const { + return "websocket disconnected before " + action_ + " returned response"; +} + +WaNode WaIq::build(const WaIqQuery& q) { + WaNode iq("iq"); + iq.attrs["id"] = q.id; + iq.attrs["xmlns"] = q.ns; + iq.attrs["type"] = q.type; + if (!q.smax_id.empty()) iq.attrs["smax_id"] = q.smax_id; + if (!q.to.empty()) iq.attrs["to"] = q.to; + if (!q.target.empty()) iq.attrs["target"] = q.target; + if (q.content.has_value()) iq.content = WaNode::Children{*q.content}; + return iq; +} + +WaIqError WaIq::parse_error(const WaNode& node) { + WaIqError none(0, "", std::nullopt, node); + const WaNode* e = node.child("error"); + if (e == nullptr) return none; + // whatsmeow OptionalInt: the attribute is a string on the wire; absent or + // unparseable reads as 0. from_chars never throws and cannot overflow. + int code = 0; + const std::string raw = e->attr("code"); + if (!raw.empty()) { + int parsed = 0; + const std::string_view view(raw); + auto res = std::from_chars(view.data(), view.data() + view.size(), parsed); + if (res.ec == std::errc() && res.ptr == view.data() + view.size()) code = parsed; + } + return WaIqError(code, e->attr("text"), *e, node); +} + +bool WaIq::is_disconnect_node(const WaNode& node) { + return node.tag == "xmlstreamend" || node.tag == "stream:error"; +} + +bool WaIq::is_auth_error_disconnect(const WaNode& node) { + const WaNode* conflict = node.tag == "stream:error" ? node.child("conflict") : nullptr; + const std::string ctype = conflict != nullptr ? conflict->attr("type") : std::string(); + return node.tag == "stream:error" && + (node.attr("code") == "401" || ctype == "replaced" || ctype == "device_removed"); +} + +std::string WaIq::generate_id(const std::string& prefix, uint64_t counter) { + return prefix + std::to_string(counter); +} + +std::string WaIq::new_prefix() { + Bytes r = WaCrypto::random_bytes(2); + return std::to_string(r[0]) + "." + std::to_string(r[1]) + "-"; +} + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_iq.h b/taicpp/src/whatsapp/wa_iq.h new file mode 100644 index 0000000..0fe6be8 --- /dev/null +++ b/taicpp/src/whatsapp/wa_iq.h @@ -0,0 +1,105 @@ +#pragma once +// WaIq: info-query (iq) request/response infrastructure. Builds iq stanzas, +// parses iq error replies into the typed whatsmeow taxonomy, and recognizes +// stream disconnects, following whatsmeow request.go and errors.go (MPL-2.0). +// Synchronous single-flight use: the caller sends the built node and matches +// the reply by id (see WaClient::send_iq). See docs/whatsapp.md section 18. +#include +#include +#include + +#include "wa_node.h" + +namespace detz::wa { + +// One info query (whatsmeow infoQuery): namespace, get/set, addressing, the +// reply-matching id, and the wait deadline. infoQuery.NoRetry has no member +// here: reconnect-triggered retries arrive with M9 auto-reconnect, and there +// is nothing to retry against today. +struct WaIqQuery { + std::string ns; // xmlns + std::string type; // "get" or "set" + std::string to; // recipient JID, empty when the server is implied + std::string target; // target JID for user-scoped queries, usually empty + std::string id; // reply-matching id; the send path fills it when empty + std::string smax_id; // smax_id attribute, empty when unused + std::optional content; // single child node, if the query carries one + int timeout_seconds{0}; // wait deadline, 0 selects kWaIqDefaultTimeout +}; + +// Typed iq failure (whatsmeow IQError in errors.go): the numeric code and text +// from the child, plus the nodes for debugging. A value, not an +// exception: the send path logs it and returns the reply for the caller. +class WaIqError { + public: + WaIqError(int code, std::string text); + WaIqError(int code, std::string text, std::optional error_node, std::optional raw); + + int code() const { return code_; } + const std::string& text() const { return text_; } + bool has_error_node() const { return error_node_.has_value(); } + + // Human rendering (whatsmeow IQError.Error). + std::string message() const; + // Equivalence (whatsmeow IQError.Is): same code+text, else identical nodes. + bool is(const WaIqError& other) const; + + // Local send failures with no server reply. + static WaIqError timed_out(); // whatsmeow ErrIQTimedOut + // Common server replies (whatsmeow ErrIQ* in errors.go), for errors.Is-style use. + static WaIqError bad_request() { return WaIqError(400, "bad-request"); } + static WaIqError not_authorized() { return WaIqError(401, "not-authorized"); } + static WaIqError forbidden() { return WaIqError(403, "forbidden"); } + static WaIqError not_found() { return WaIqError(404, "item-not-found"); } + static WaIqError not_allowed() { return WaIqError(405, "not-allowed"); } + static WaIqError not_acceptable() { return WaIqError(406, "not-acceptable"); } + static WaIqError gone() { return WaIqError(410, "gone"); } + static WaIqError resource_limit() { return WaIqError(419, "resource-limit"); } + static WaIqError locked() { return WaIqError(423, "locked"); } + static WaIqError rate_over_limit() { return WaIqError(429, "rate-overlimit"); } + static WaIqError internal_server_error() { return WaIqError(500, "internal-server-error"); } + static WaIqError service_unavailable() { return WaIqError(503, "service-unavailable"); } + static WaIqError partial_server_error() { return WaIqError(530, "partial-server-error"); } + + private: + int code_{0}; + std::string text_; + std::optional error_node_; + std::optional raw_; +}; + +// The stream died before a reply arrived (whatsmeow DisconnectedError). +class WaDisconnectedError { + public: + explicit WaDisconnectedError(std::string action, std::optional node = std::nullopt); + static WaDisconnectedError for_iq() { return WaDisconnectedError("info query"); } // ErrIQDisconnected + const std::string& action() const { return action_; } + // Human rendering (whatsmeow DisconnectedError.Error). + std::string message() const; + // Equivalence (whatsmeow DisconnectedError.Is): same action. + bool is(const WaDisconnectedError& other) const { return action_ == other.action_; } + + private: + std::string action_; + std::optional node_; +}; + +class WaIq { + public: + // Assemble the iq stanza (whatsmeow sendIQAsyncAndGetData attributes). + static WaNode build(const WaIqQuery& q); + // Classify a reply (whatsmeow parseIQError): reads the child when + // present, else yields code 0 ("unexpected response"). Never fails. + static WaIqError parse_error(const WaNode& node); + // A node that ends the stream (whatsmeow isDisconnectNode). + static bool is_disconnect_node(const WaNode& node); + // A disconnect no reconnect would survive: 401, replaced, device_removed + // (whatsmeow isAuthErrorDisconnect). + static bool is_auth_error_disconnect(const WaNode& node); + // Request ids (whatsmeow generateRequestID/NewClient uniqueID): a per-client + // ".-" prefix plus a counter. + static std::string generate_id(const std::string& prefix, uint64_t counter); + static std::string new_prefix(); +}; + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_keepalive.cpp b/taicpp/src/whatsapp/wa_keepalive.cpp new file mode 100644 index 0000000..1a8baba --- /dev/null +++ b/taicpp/src/whatsapp/wa_keepalive.cpp @@ -0,0 +1,34 @@ +#include "wa_keepalive.h" + +#include + +namespace detz::wa { + +WaKeepAlive::WaKeepAlive(IntervalFn next) : next_(std::move(next)) {} + +WaKeepAlive::Ms WaKeepAlive::default_interval() { + static thread_local std::mt19937 rng(std::random_device{}()); + std::uniform_int_distribution pick(WaConsts::kWaKeepAliveIntervalMin * 1000, + WaConsts::kWaKeepAliveIntervalMax * 1000); + return pick(rng); +} + +void WaKeepAlive::start(Ms now_ms) { + started_ = true; + last_success_ms_ = now_ms; + next_due_ms_ = now_ms + next_(); +} + +void WaKeepAlive::record_pong(Ms now_ms) { + errors_ = 0; + last_success_ms_ = now_ms; + next_due_ms_ = now_ms + next_(); +} + +bool WaKeepAlive::record_timeout(Ms now_ms) { + errors_ += 1; + next_due_ms_ = now_ms + next_(); + return now_ms - last_success_ms_ > WaConsts::kWaKeepAliveMaxFailTime * 1000; +} + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_keepalive.h b/taicpp/src/whatsapp/wa_keepalive.h new file mode 100644 index 0000000..d083f30 --- /dev/null +++ b/taicpp/src/whatsapp/wa_keepalive.h @@ -0,0 +1,43 @@ +#pragma once +// WaKeepAlive: the websocket keepalive schedule (whatsmeow keepalive.go). A +// ping is due every 20-30 s (jittered); each unanswered ping counts an error, +// and failures spanning kWaKeepAliveMaxFailTime force a reconnect. Pure +// timing state over an injected millisecond clock, so tests drive it +// deterministically; WaClient sends the actual w:p iq. See docs/whatsapp.md +// section 18. +#include +#include + +#include "wa_consts.h" + +namespace detz::wa { + +class WaKeepAlive { + public: + using Ms = int64_t; + using IntervalFn = std::function; // wait until the next ping + + explicit WaKeepAlive(IntervalFn next = default_interval); + + void start(Ms now_ms); + bool due(Ms now_ms) const { return started_ && now_ms >= next_due_ms_; } + Ms next_due_ms() const { return next_due_ms_; } + // A ping reply arrived: errors clear, the schedule advances from now. + void record_pong(Ms now_ms); + // A ping went unanswered. True when the client must reconnect: failures + // spanning more than kWaKeepAliveMaxFailTime (whatsmeow KeepAliveMaxFailTime). + bool record_timeout(Ms now_ms); + int error_count() const { return errors_; } + Ms last_success_ms() const { return last_success_ms_; } + + private: + static Ms default_interval(); // uniform 20-30 s, like whatsmeow keepAliveLoop + + IntervalFn next_; + Ms next_due_ms_{0}; + Ms last_success_ms_{0}; + int errors_{0}; + bool started_{false}; +}; + +} // namespace detz::wa diff --git a/taicpp/src/whatsapp/wa_noise.cpp b/taicpp/src/whatsapp/wa_noise.cpp index b6ac353..d4935ca 100644 --- a/taicpp/src/whatsapp/wa_noise.cpp +++ b/taicpp/src/whatsapp/wa_noise.cpp @@ -17,11 +17,17 @@ Bytes WaFraming::frame(const Bytes& payload, bool with_header) { } std::optional WaFraming::unframe(const Bytes& buf, size_t& pos) { - if (pos + 3 > buf.size()) return std::nullopt; + // Routine negative, deliberately silent: "no complete frame yet" fires on + // every partial read and the caller keeps pumping; it is not a failure. + const std::optional kIncomplete; + // Overflow-safe subtraction checks (docs/cpp-safety.md Layer 1): pos is + // caller-owned, so establish pos <= size first, then compare lengths. + if (pos > buf.size()) return kIncomplete; + if (kLengthSize > buf.size() - pos) return kIncomplete; uint32_t n = (static_cast(buf[pos]) << 16) | (static_cast(buf[pos + 1]) << 8) | buf[pos + 2]; - if (pos + 3 + n > buf.size()) return std::nullopt; - Bytes out(buf.begin() + pos + 3, buf.begin() + pos + 3 + n); - pos += 3 + n; + if (n > buf.size() - pos - kLengthSize) return kIncomplete; + Bytes out(buf.begin() + pos + kLengthSize, buf.begin() + pos + kLengthSize + n); + pos += kLengthSize + n; return out; } @@ -51,6 +57,11 @@ void WaNoise::mix_key(const Bytes& data) { counter_ = 0; } +void WaNoise::mix_shared_secret(const std::array& priv, const std::array& pub) { + auto secret = WaCrypto::x25519(priv, pub); + mix_key(Bytes(secret.begin(), secret.end())); +} + Bytes WaNoise::iv(uint32_t counter) { Bytes v(12, 0); v[8] = (counter >> 24) & 0xff; @@ -100,24 +111,21 @@ std::optional WaNoise::client_handshake(const std::arr mix_hash(sh->ephemeral); std::array se_arr{}; std::copy(sh->ephemeral.begin(), sh->ephemeral.end(), se_arr.begin()); - auto es = WaCrypto::x25519(eph_priv, se_arr); - mix_key(Bytes(es.begin(), es.end())); + mix_shared_secret(eph_priv, se_arr); auto server_static = decrypt(sh->static_enc); if (!server_static || server_static->size() != 32) return std::nullopt; res.server_static = *server_static; std::array ss_pub{}; std::copy(server_static->begin(), server_static->end(), ss_pub.begin()); - auto ss = WaCrypto::x25519(eph_priv, ss_pub); - mix_key(Bytes(ss.begin(), ss.end())); + mix_shared_secret(eph_priv, ss_pub); auto server_payload = decrypt(sh->payload); if (!server_payload) return std::nullopt; res.server_payload = *server_payload; Bytes static_enc = encrypt(Bytes(static_pub.begin(), static_pub.end())); - auto se2 = WaCrypto::x25519(static_priv, se_arr); - mix_key(Bytes(se2.begin(), se2.end())); + mix_shared_secret(static_priv, se_arr); Bytes payload_enc = encrypt(client_payload); res.client_finish = encode_client_finish(static_enc, payload_enc); diff --git a/taicpp/src/whatsapp/wa_noise.h b/taicpp/src/whatsapp/wa_noise.h index 9ca56c1..0fc4f55 100644 --- a/taicpp/src/whatsapp/wa_noise.h +++ b/taicpp/src/whatsapp/wa_noise.h @@ -19,6 +19,7 @@ class WaFraming { public: static constexpr std::array kHeader = {0x57, 0x41, 0x06, 0x03}; static constexpr size_t kMaxFrame = 1u << 24; + static constexpr size_t kLengthSize = 3; // socket.FrameLengthSize: the big-endian length prefix // Wrap a payload in a length-prefixed frame; with_header prepends the WA magic. static Bytes frame(const Bytes& payload, bool with_header); // Read one length-prefixed frame from buf starting at pos; advances pos. @@ -47,6 +48,9 @@ class WaNoise { const std::array& static_pub, const Bytes& server_hello_msg, const Bytes& client_payload); + // Mix an X25519 shared secret into the handshake key (socket.NoiseHandshake.MixSharedSecretIntoKey). + void mix_shared_secret(const std::array& priv, const std::array& pub); + // transport mode (after handshake): encrypt/decrypt a frame payload. Bytes transport_encrypt(const Bytes& plaintext); std::optional transport_decrypt(const Bytes& ciphertext); diff --git a/taicpp/src/whatsapp/wa_proto.cpp b/taicpp/src/whatsapp/wa_proto.cpp index 8f63efa..c94040e 100644 --- a/taicpp/src/whatsapp/wa_proto.cpp +++ b/taicpp/src/whatsapp/wa_proto.cpp @@ -307,17 +307,17 @@ std::optional parse_cert_chain(const Bytes& b) { ProtoReader::Field f; while (r.next(f)) { if (f.wire != 2) continue; - // CertChain: intermediate = 1, leaf = 2 (each a NoiseCertificate) + // CertChain (WACert.pb.go): leaf = 1, intermediate = 2 (each a NoiseCertificate). if (f.field == 1) { - if (auto nc = parse_noise_certificate(f.bytes)) { - o.intermediate_details = nc->details; - o.intermediate_signature = nc->signature; - } - } else if (f.field == 2) { if (auto nc = parse_noise_certificate(f.bytes)) { o.leaf_details = nc->details; o.leaf_signature = nc->signature; } + } else if (f.field == 2) { + if (auto nc = parse_noise_certificate(f.bytes)) { + o.intermediate_details = nc->details; + o.intermediate_signature = nc->signature; + } } } return o; @@ -339,9 +339,16 @@ std::optional parse_cert_details(const Bytes& b) { WaNoiseCertDetails o; ProtoReader r(b); ProtoReader::Field f; + // Details (WACert.pb.go): serial = 1, issuer_serial = 2, key = 3, not_before = 4, not_after = 5. while (r.next(f)) { - if (f.field == 2 && f.wire == 0) o.serial = static_cast(f.varint); - else if (f.field == 3 && f.wire == 2) o.key = f.bytes; // key field in NoiseCertificate.Details + if (f.wire == 0) { + if (f.field == 1) o.serial = static_cast(f.varint); + else if (f.field == 2) o.issuer_serial = static_cast(f.varint); + else if (f.field == 4) o.not_before = f.varint; + else if (f.field == 5) o.not_after = f.varint; + } else if (f.field == 3 && f.wire == 2) { + o.key = f.bytes; + } } return o; } diff --git a/taicpp/src/whatsapp/wa_proto.h b/taicpp/src/whatsapp/wa_proto.h index a217ea2..0410b85 100644 --- a/taicpp/src/whatsapp/wa_proto.h +++ b/taicpp/src/whatsapp/wa_proto.h @@ -138,9 +138,14 @@ struct WaADVDeviceIdentity { std::optional parse_adv_device_identity(const Bytes& b); // ---- cert chain (noise handshake) ---- +// waCert.CertChain_NoiseCertificate.Details (fields 1..5): serial, issuer +// serial, subject key, validity window. Verified by WaServerCert. struct WaNoiseCertDetails { uint32_t serial{0}; - Bytes key; // the issuer/subject key (32 bytes) + uint32_t issuer_serial{0}; + Bytes key; // the subject key (32 bytes) + uint64_t not_before{0}; + uint64_t not_after{0}; }; struct WaCertChainParsed { Bytes intermediate_details, intermediate_signature; diff --git a/taicpp/src/whatsapp/wa_socket.cpp b/taicpp/src/whatsapp/wa_socket.cpp index 74299e9..0b47164 100644 --- a/taicpp/src/whatsapp/wa_socket.cpp +++ b/taicpp/src/whatsapp/wa_socket.cpp @@ -24,6 +24,7 @@ void WaSocket::close() { } void WaSocket::connect(int timeout_seconds) { + if (connected_) throw WaSocketError("connect on an already open socket"); CURL* c = static_cast(handle_); curl_easy_setopt(c, CURLOPT_URL, kUrl); curl_easy_setopt(c, CURLOPT_CONNECT_ONLY, 2L); // 2L = WebSocket connect-only mode @@ -33,12 +34,19 @@ void WaSocket::connect(int timeout_seconds) { curl_easy_setopt(c, CURLOPT_TIMEOUT, static_cast(timeout_seconds)); curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, static_cast(timeout_seconds)); CURLcode rc = curl_easy_perform(c); + long http_status = 0; + if (rc != CURLE_OK) curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &http_status); curl_slist_free_all(hdrs); - if (rc != CURLE_OK) throw WaSocketError(std::string("connect: ") + curl_easy_strerror(rc)); + if (rc != CURLE_OK) { + if (http_status != 0) + throw WaSocketStatusError(std::string("connect: ") + curl_easy_strerror(rc), http_status); + throw WaSocketError(std::string("connect: ") + curl_easy_strerror(rc)); + } connected_ = true; } void WaSocket::send_frame(const Bytes& framed) { + if (!connected_ || handle_ == nullptr) throw WaSocketError("send on a socket that is not connected"); CURL* c = static_cast(handle_); size_t off = 0; while (off < framed.size()) { diff --git a/taicpp/src/whatsapp/wa_socket.h b/taicpp/src/whatsapp/wa_socket.h index 2902550..2f5b400 100644 --- a/taicpp/src/whatsapp/wa_socket.h +++ b/taicpp/src/whatsapp/wa_socket.h @@ -18,6 +18,18 @@ class WaSocketError : public std::runtime_error { explicit WaSocketError(const std::string& m) : std::runtime_error("wa socket: " + m) {} }; +// A connect failure where the server answered HTTP (socket.ErrWithStatusCode): +// the status code travels with the error instead of hiding in the text. +class WaSocketStatusError : public WaSocketError { + public: + WaSocketStatusError(const std::string& m, long status) + : WaSocketError(m + " (http " + std::to_string(status) + ")"), status_(status) {} + long status() const { return status_; } + + private: + long status_{0}; +}; + class WaSocket { public: static constexpr const char* kUrl = "wss://web.whatsapp.com/ws/chat"; diff --git a/taicpp/tests/whatsapp_data/cert_oracle.go b/taicpp/tests/whatsapp_data/cert_oracle.go new file mode 100644 index 0000000..08fcf28 --- /dev/null +++ b/taicpp/tests/whatsapp_data/cert_oracle.go @@ -0,0 +1,160 @@ +// Oracle generator for detz::wa Noise handshake + server-cert vectors (M1). +// +// Runs the Noise_XX handshake over the wa-go noise.json trace with whatsmeow's +// OWN socket.NoiseHandshake (pinned version) and checks the decrypted server +// certificate with whatsmeow's waCert protobufs plus libsignal ed25519, step +// by step as the unexported verifyServerCert does. The C++ WaNoise handshake +// bytes, WaNoiseCertDetails field numbers and WaServerCert verdicts are +// checked against this output, not against themselves. +// +// Usage: go run cert_oracle.go noise.json > cert_vectors.json +// (inside a module that requires go.mau.fi/whatsmeow) + +//go:build ignore + +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "time" + + "google.golang.org/protobuf/proto" + + "go.mau.fi/libsignal/ecc" + whatsmeow "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waCert" + "go.mau.fi/whatsmeow/proto/waWa6" + "go.mau.fi/whatsmeow/socket" +) + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + fmt.Fprintf(os.Stderr, "hex: %v\n", err) + os.Exit(1) + } + return b +} + +func b32(b []byte) [32]byte { + var a [32]byte + copy(a[:], b) + return a +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: cert_oracle.go noise.json") + os.Exit(1) + } + raw, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + var vec map[string]string + if err := json.Unmarshal(raw, &vec); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ephPriv, ephPub := mustHex(vec["ephemeralKeyPriv"]), mustHex(vec["ephemeralKeyPub"]) + header := mustHex(vec["noiseHeader"]) + frame := mustHex(vec["serverHelloFrameHex"]) + // Server frames carry a 3-byte big-endian length prefix, no WA magic. + if len(frame) < 3 { + fmt.Fprintln(os.Stderr, "server hello frame too short") + os.Exit(1) + } + helloBytes := frame[3:] + + // --- handshake through the certificate, exactly as doHandshake --- + nh := socket.NewNoiseHandshake() + nh.Start(socket.NoiseStartPattern, header) + nh.Authenticate(ephPub) + var hello waWa6.HandshakeMessage + if err := proto.Unmarshal(helloBytes, &hello); err != nil { + fmt.Fprintf(os.Stderr, "unmarshal hello: %v\n", err) + os.Exit(1) + } + serverEph := hello.GetServerHello().GetEphemeral() + serverStaticCT := hello.GetServerHello().GetStatic() + certCT := hello.GetServerHello().GetPayload() + nh.Authenticate(serverEph) + if err := nh.MixSharedSecretIntoKey(b32(ephPriv), b32(serverEph)); err != nil { + fmt.Fprintf(os.Stderr, "mix server ephemeral: %v\n", err) + os.Exit(1) + } + staticDec, err := nh.Decrypt(serverStaticCT) + if err != nil { + fmt.Fprintf(os.Stderr, "decrypt server static: %v\n", err) + os.Exit(1) + } + if err := nh.MixSharedSecretIntoKey(b32(ephPriv), b32(staticDec)); err != nil { + fmt.Fprintf(os.Stderr, "mix server static: %v\n", err) + os.Exit(1) + } + certDec, err := nh.Decrypt(certCT) + if err != nil { + fmt.Fprintf(os.Stderr, "decrypt cert: %v\n", err) + os.Exit(1) + } + + // --- verifyServerCert, check by check (handshake.go) --- + checks := map[string]bool{} + var chain waCert.CertChain + checks["chain_unmarshal"] = proto.Unmarshal(certDec, &chain) == nil + imDetailsRaw := chain.GetIntermediate().GetDetails() + imSig := chain.GetIntermediate().GetSignature() + leafDetailsRaw := chain.GetLeaf().GetDetails() + leafSig := chain.GetLeaf().GetSignature() + checks["parts_present"] = imDetailsRaw != nil && imSig != nil && leafDetailsRaw != nil && leafSig != nil + checks["intermediate_sig_len_64"] = len(imSig) == 64 + checks["leaf_sig_len_64"] = len(leafSig) == 64 + checks["intermediate_sig_valid"] = checks["intermediate_sig_len_64"] && + ecc.VerifySignature(ecc.NewDjbECPublicKey(whatsmeow.WACertPubKey), imDetailsRaw, [64]byte(imSig)) + var imDetails waCert.CertChain_NoiseCertificate_Details + checks["intermediate_details_unmarshal"] = proto.Unmarshal(imDetailsRaw, &imDetails) == nil + checks["intermediate_issuer_is_zero"] = imDetails.GetIssuerSerial() == whatsmeow.WACertIssuerSerial + checks["intermediate_key_len_32"] = len(imDetails.GetKey()) == 32 + checks["leaf_sig_valid"] = checks["leaf_sig_len_64"] && checks["intermediate_key_len_32"] && + ecc.VerifySignature(ecc.NewDjbECPublicKey([32]byte(imDetails.GetKey())), leafDetailsRaw, [64]byte(leafSig)) + now := time.Now().Unix() + checks["intermediate_valid_now"] = now >= int64(imDetails.GetNotBefore()) && now <= int64(imDetails.GetNotAfter()) + var leafDetails waCert.CertChain_NoiseCertificate_Details + checks["leaf_details_unmarshal"] = proto.Unmarshal(leafDetailsRaw, &leafDetails) == nil + checks["leaf_issuer_matches"] = leafDetails.GetIssuerSerial() == imDetails.GetSerial() + checks["leaf_key_matches_static"] = bytes.Equal(leafDetails.GetKey(), staticDec) + checks["leaf_valid_now"] = now >= int64(leafDetails.GetNotBefore()) && now <= int64(leafDetails.GetNotAfter()) + overall := true + for _, v := range checks { + overall = overall && v + } + out := map[string]any{ + "server_static_hex": hex.EncodeToString(staticDec), + "server_payload_hex": hex.EncodeToString(certDec), + "now_unix": now, + "checks": checks, + "overall_now": overall, + "wacert_pubkey_hex": hex.EncodeToString(whatsmeow.WACertPubKey[:]), + "intermediate": map[string]any{ + "serial": imDetails.GetSerial(), "issuer_serial": imDetails.GetIssuerSerial(), + "key_hex": hex.EncodeToString(imDetails.GetKey()), + "not_before": imDetails.GetNotBefore(), "not_after": imDetails.GetNotAfter(), + }, + "leaf": map[string]any{ + "serial": leafDetails.GetSerial(), "issuer_serial": leafDetails.GetIssuerSerial(), + "key_hex": hex.EncodeToString(leafDetails.GetKey()), + "not_before": leafDetails.GetNotBefore(), "not_after": leafDetails.GetNotAfter(), + }, + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/taicpp/tests/whatsapp_data/cert_vectors.json b/taicpp/tests/whatsapp_data/cert_vectors.json new file mode 100644 index 0000000..2a02e7e --- /dev/null +++ b/taicpp/tests/whatsapp_data/cert_vectors.json @@ -0,0 +1 @@ +{"checks":{"chain_unmarshal":true,"intermediate_details_unmarshal":true,"intermediate_issuer_is_zero":true,"intermediate_key_len_32":true,"intermediate_sig_len_64":true,"intermediate_sig_valid":true,"intermediate_valid_now":true,"leaf_details_unmarshal":true,"leaf_issuer_matches":true,"leaf_key_matches_static":true,"leaf_sig_len_64":true,"leaf_sig_valid":true,"leaf_valid_now":false,"parts_present":true},"intermediate":{"issuer_serial":0,"key_hex":"1c51a9ac303994c6c8d0b92ea1878a533476599cc599fbea35997d9aa90cce62","not_after":1804463999,"not_before":1741392000,"serial":3},"leaf":{"issuer_serial":3,"key_hex":"91e0d75970c27845af977f8bcebcd453e2b94877ac3af0ecc59bf0de0da77f70","not_after":1783976400,"not_before":1773090000,"serial":354},"now_unix":1793153919,"overall_now":false,"server_payload_hex":"0a770a3308e20210031a2091e0d75970c27845af977f8bcebcd453e2b94877ac3af0ecc59bf0de0da77f7020d0e9bccd0628d0a3d5d20612404a2294f6192dff80193518756feb5a4688dd4d43404b7955579e15170f646963343e35f7ba1ea5a0697ce86f175d0575c2809edc31854f3fc5af4eeee124a80a12760a32080310001a201c51a9ac303994c6c8d0b92ea1878a533476599cc599fbea35997d9aa90cce62208091aebe0628ffdeb7dc061240270f294648539fed4870e25054dd4e95983aba29189c2ba6c8eeda7055555f753740f5ec192ab64c26c26d6ade6d20b9f774aee37120a6b20395f53c66058507","server_static_hex":"91e0d75970c27845af977f8bcebcd453e2b94877ac3af0ecc59bf0de0da77f70","wacert_pubkey_hex":"142375574d0a587166aae71ebe516437c4a28b73e3695c6ce1f7f9545da8ee6b"} diff --git a/taicpp/tests/whatsapp_data/iq_oracle.go b/taicpp/tests/whatsapp_data/iq_oracle.go new file mode 100644 index 0000000..301f435 --- /dev/null +++ b/taicpp/tests/whatsapp_data/iq_oracle.go @@ -0,0 +1,203 @@ +// Oracle generator for detz::wa iq request/response vectors (M1). +// +// whatsmeow's own binary.Marshal produces the wire bytes and whatsmeow's own +// AttrGetter extracts error code/text exactly as the unexported parseIQError +// does, so the C++ WaIq decoder/classifier is checked against the reference. +// whatsmeow writes attributes in Go map order (random), so multi-attribute +// nodes are decode-direction only; nodes with <= 2 attributes also serve the +// encode direction (single wire_hex when deterministic, wire_set otherwise). +// +// Run from a module that requires go.mau.fi/whatsmeow: +// go run iq_oracle.go > iq_vectors.json (inside a module that requires go.mau.fi/whatsmeow) + +//go:build ignore + +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sort" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func mustMarshal(n waBinary.Node) string { + raw, err := waBinary.Marshal(n) + if err != nil { + fmt.Fprintf(os.Stderr, "marshal: %v\n", err) + os.Exit(1) + } + return hex.EncodeToString(raw) +} + +// marshalSet remarshals until the order distribution stabilizes (or 200 +// tries): whatsmeow's attribute order is random, so the C++ encoder's fixed +// order must be a member of the observed set. +func marshalSet(n waBinary.Node) []string { + seen := map[string]bool{} + var out []string + for i := 0; i < 200; i++ { + h := mustMarshal(n) + if !seen[h] { + seen[h] = true + out = append(out, h) + } + } + sort.Strings(out) + return out +} + +// errorOf mirrors whatsmeow parseIQError (errors.go) using only exported calls. +func errorOf(n waBinary.Node) (int, string) { + val, ok := n.GetOptionalChildByTag("error") + if !ok { + return 0, "" + } + ag := val.AttrGetter() + return ag.OptionalInt("code"), ag.OptionalString("text") +} + +type decodeVec struct { + Name string `json:"name"` + WireHex string `json:"wire_hex"` + Tag string `json:"tag"` + Attrs map[string]any `json:"attrs"` + Children []string `json:"children"` + Code int `json:"code"` + Text string `json:"text"` + HasError bool `json:"has_error"` +} + +type encodeVec struct { + Name string `json:"name"` + Tag string `json:"tag"` + Attrs map[string]any `json:"attrs"` + WireHex string `json:"wire_hex,omitempty"` + WireSet []string `json:"wire_set,omitempty"` +} + +func strmap(a waBinary.Attrs) map[string]any { + m := map[string]any{} + for k, v := range a { + m[k] = fmt.Sprint(v) + } + return m +} + +func main() { + id := "42.7-9" + var dec []decodeVec + add := func(name string, n waBinary.Node, children []string) { + wire := mustMarshal(n) + // Extract from the unmarshaled node, as the real receive path does: + // on the wire every attribute is a string. + raw, err := hex.DecodeString(wire) + if err != nil { + fmt.Fprintf(os.Stderr, "hex: %v\n", err) + os.Exit(1) + } + unpacked, err := waBinary.Unpack(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "unpack %s: %v\n", name, err) + os.Exit(1) + } + back, err := waBinary.Unmarshal(unpacked) + if err != nil { + fmt.Fprintf(os.Stderr, "unmarshal %s: %v\n", name, err) + os.Exit(1) + } + code, text := errorOf(*back) + _, hasErr := back.GetOptionalChildByTag("error") + dec = append(dec, decodeVec{ + Name: name, WireHex: wire, Tag: back.Tag, + Attrs: strmap(back.Attrs), Children: children, + Code: code, Text: text, HasError: hasErr, + }) + } + // 1. keepalive ping (whatsmeow keepalive.go sendKeepAlive shape). + add("keepalive_ping", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "xmlns": "w:p", "type": "get", "to": types.ServerJID}, + }, nil) + // 2. prekey count query (whatsmeow prekeys.go shape). + add("encrypt_count", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "xmlns": "encrypt", "type": "get", "to": types.ServerJID}, + Content: []waBinary.Node{{Tag: "count"}}, + }, []string{"count"}) + // 3. error results. + add("iq_error_404", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "type": "error", "from": types.ServerJID}, + Content: []waBinary.Node{{Tag: "error", + Attrs: waBinary.Attrs{"code": 404, "text": "item-not-found"}}}, + }, []string{"error"}) + add("iq_error_500", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "type": "error", "from": types.ServerJID}, + Content: []waBinary.Node{{Tag: "error", + Attrs: waBinary.Attrs{"code": 500, "text": "internal-server-error"}}}, + }, []string{"error"}) + // 4. bare error result (no child: code 0 path). + add("iq_error_bare", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "type": "error", "from": types.ServerJID}, + }, nil) + // 5. pong result. + add("iq_result_ping", waBinary.Node{ + Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "type": "result", "from": types.ServerJID}, + Content: []waBinary.Node{{Tag: "ping"}}, + }, []string{"ping"}) + // 6. disconnect shapes (whatsmeow request.go isDisconnectNode inputs). + add("stream_error_401", waBinary.Node{ + Tag: "stream:error", + Attrs: waBinary.Attrs{"code": "401"}, + }, nil) + add("stream_error_conflict", waBinary.Node{ + Tag: "stream:error", + Content: []waBinary.Node{{Tag: "conflict", + Attrs: waBinary.Attrs{"type": "replaced"}}}, + }, []string{"conflict"}) + add("stream_error_515", waBinary.Node{ + Tag: "stream:error", + Attrs: waBinary.Attrs{"code": "515"}, + }, nil) + + var enc []encodeVec + enc = append(enc, encodeVec{Name: "ping_child", Tag: "ping", + WireHex: mustMarshal(waBinary.Node{Tag: "ping"})}) + enc = append(enc, encodeVec{Name: "iq_id_only", Tag: "iq", + Attrs: map[string]any{"id": id}, + WireHex: mustMarshal(waBinary.Node{Tag: "iq", Attrs: waBinary.Attrs{"id": id}})}) + enc = append(enc, encodeVec{Name: "error_code_text", Tag: "error", + Attrs: map[string]any{"code": "404", "text": "item-not-found"}, + WireSet: marshalSet(waBinary.Node{Tag: "error", + Attrs: waBinary.Attrs{"code": 404, "text": "item-not-found"}})}) + enc = append(enc, encodeVec{Name: "iq_id_type", Tag: "iq", + Attrs: map[string]any{"id": id, "type": "get"}, + WireSet: marshalSet(waBinary.Node{Tag: "iq", + Attrs: waBinary.Attrs{"id": id, "type": "get"}})}) + // NOTE: no "to": ServerJID encode vector. whatsmeow passes `to` as a typed + // JID and writes the JID_PAIR form (fa 00 03); detz passes the same value + // as a string and writes its single-byte token (03). Both are valid and + // decode identically (see the keepalive_ping decode vector); the server + // accepts ours (proven live). Byte-parity here would mean knowing which + // strings are JIDs, which the string-attr model cannot express. + // NOTE: no 4-attribute encode vector: Go map iteration covers only a few + // of the 24 orders, so set membership would assert a non-requirement. + // Multi-attribute nodes are decode-direction only (above); the full ping + // shape is additionally covered by live server interop. + + vec := map[string]any{"decode": dec, "encode": enc} + out := json.NewEncoder(os.Stdout) + out.SetEscapeHTML(false) + if err := out.Encode(vec); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/taicpp/tests/whatsapp_data/iq_vectors.json b/taicpp/tests/whatsapp_data/iq_vectors.json new file mode 100644 index 0000000..5358ee2 --- /dev/null +++ b/taicpp/tests/whatsapp_data/iq_vectors.json @@ -0,0 +1 @@ +{"decode":[{"name":"keepalive_ping","wire_hex":"00f8091908ff0342b7a91657042911fa0003","tag":"iq","attrs":{"id":"42.7-9","to":"s.whatsapp.net","type":"get","xmlns":"w:p"},"children":null,"code":0,"text":"","has_error":false},{"name":"encrypt_count","wire_hex":"00f80a1908ff0342b7a916cb042911fa0003f801f80141","tag":"iq","attrs":{"id":"42.7-9","to":"s.whatsapp.net","type":"get","xmlns":"encrypt"},"children":["count"],"code":0,"text":"","has_error":false},{"name":"iq_error_404","wire_hex":"00f8081908ff0342b7a904a806fa0003f801f805a870ec3538b6","tag":"iq","attrs":{"from":"s.whatsapp.net","id":"42.7-9","type":"error"},"children":["error"],"code":404,"text":"item-not-found","has_error":true},{"name":"iq_error_500","wire_hex":"00f8081908ff0342b7a904a806fa0003f801f805a870eca438fc15696e7465726e616c2d7365727665722d6572726f72","tag":"iq","attrs":{"from":"s.whatsapp.net","id":"42.7-9","type":"error"},"children":["error"],"code":500,"text":"internal-server-error","has_error":true},{"name":"iq_error_bare","wire_hex":"00f8071906fa000308ff0342b7a904a8","tag":"iq","attrs":{"from":"s.whatsapp.net","id":"42.7-9","type":"error"},"children":null,"code":0,"text":"","has_error":false},{"name":"iq_result_ping","wire_hex":"00f8081908ff0342b7a9041406fa0003f801f80156","tag":"iq","attrs":{"from":"s.whatsapp.net","id":"42.7-9","type":"result"},"children":["ping"],"code":0,"text":"","has_error":false},{"name":"stream_error_401","wire_hex":"00f8039d708d","tag":"stream:error","attrs":{"code":"401"},"children":null,"code":0,"text":"","has_error":false},{"name":"stream_error_conflict","wire_hex":"00f8029df801f803ec0e04ec11","tag":"stream:error","attrs":{},"children":["conflict"],"code":0,"text":"","has_error":false},{"name":"stream_error_515","wire_hex":"00f8039d70ff82515f","tag":"stream:error","attrs":{"code":"515"},"children":null,"code":0,"text":"","has_error":false}],"encode":[{"name":"ping_child","tag":"ping","attrs":null,"wire_hex":"00f80156"},{"name":"iq_id_only","tag":"iq","attrs":{"id":"42.7-9"},"wire_hex":"00f8031908ff0342b7a9"},{"name":"error_code_text","tag":"error","attrs":{"code":"404","text":"item-not-found"},"wire_set":["00f805a838b670ec35","00f805a870ec3538b6"]},{"name":"iq_id_type","tag":"iq","attrs":{"id":"42.7-9","type":"get"},"wire_set":["00f80519042908ff0342b7a9","00f8051908ff0342b7a90429"]}]} diff --git a/taicpp/tests/whatsapp_data/token_oracle.go b/taicpp/tests/whatsapp_data/token_oracle.go new file mode 100644 index 0000000..f1d91d7 --- /dev/null +++ b/taicpp/tests/whatsapp_data/token_oracle.go @@ -0,0 +1,100 @@ +// Oracle generator for detz::wa token-dictionary freshness vectors (M1). +// +// Dumps whatsmeow's own binary/token tables (pinned version, see +// docs/whatsapp.md section 17) plus whatsmeow binary.Marshal wire bytes for +// token-probe nodes, so the C++ tables in src/whatsapp/wa_tokens_data.h and +// the codec's token selection are checked against the reference, not against +// themselves. Re-running this after a pin move fails loudly on drift. +// +// Run from a module that requires go.mau.fi/whatsmeow: +// go run token_oracle.go > token_vectors.json (inside a module that requires go.mau.fi/whatsmeow) + +//go:build ignore + +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/binary/token" +) + +type probe struct { + String string `json:"string"` + WireHex string `json:"wire_hex"` + // Index expectation from whatsmeow itself (single >= 0, else dict/index). + Single int `json:"single"` + Dict int `json:"dict"` + Index int `json:"index"` + Found bool `json:"found"` +} + +func main() { + // Full tables, verbatim. + single := make([]string, len(token.SingleByteTokens)) + copy(single, token.SingleByteTokens[:]) + double := make([][]string, len(token.DoubleByteTokens)) + for d, dict := range token.DoubleByteTokens { + double[d] = make([]string, len(dict)) + copy(double[d], dict[:]) + } + // Probe strings: single-byte tokens, one token from each double-byte + // dictionary, and strings with no token (literal encoding). + probes := []string{ + "iq", "s.whatsapp.net", "w:p", "message", "lid", + "patch", "mute_v2", + "pair-device", "phash", + "mediaretry", "enc_p", + "create", "spam_request", + "no-such-token-in-any-dict-zzz", + } + var out []probe + for _, s := range probes { + p := probe{String: s, Single: -1, Dict: -1, Index: -1} + if v, ok := token.IndexOfSingleToken(s); ok { + p.Single, p.Found = int(v), true + } else if d, i, ok := token.IndexOfDoubleByteToken(s); ok { + p.Dict, p.Index, p.Found = int(d), int(i), true + } + // A bare tag node has no attributes, so whatsmeow's marshal is + // deterministic here and the C++ encoder must match it byte-for-byte. + raw, err := waBinary.Marshal(waBinary.Node{Tag: s}) + if err != nil { + fmt.Fprintf(os.Stderr, "marshal %q: %v\n", s, err) + os.Exit(1) + } + p.WireHex = hex.EncodeToString(raw) + out = append(out, p) + } + // A GetDoubleToken miss must be an error, never a panic. + if _, err := token.GetDoubleToken(4, 0); err == nil { + fmt.Fprintln(os.Stderr, "GetDoubleToken(4,0) unexpectedly succeeded") + os.Exit(1) + } + vec := map[string]any{ + "dict_version": token.DictVersion, + "single": single, + "double": double, + "consts": map[string]int{ + "ListEmpty": token.ListEmpty, "Dictionary0": token.Dictionary0, + "Dictionary1": token.Dictionary1, "Dictionary2": token.Dictionary2, + "Dictionary3": token.Dictionary3, "InteropJID": token.InteropJID, + "FBJID": token.FBJID, "ADJID": token.ADJID, "List8": token.List8, + "List16": token.List16, "JIDPair": token.JIDPair, "Hex8": token.Hex8, + "Binary8": token.Binary8, "Binary20": token.Binary20, + "Binary32": token.Binary32, "Nibble8": token.Nibble8, + "PackedMax": token.PackedMax, "SingleByteMax": token.SingleByteMax, + }, + "probes": out, + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(vec); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/taicpp/tests/whatsapp_data/token_vectors.json b/taicpp/tests/whatsapp_data/token_vectors.json new file mode 100644 index 0000000..9eba60d --- /dev/null +++ b/taicpp/tests/whatsapp_data/token_vectors.json @@ -0,0 +1 @@ +{"consts":{"ADJID":247,"Binary20":253,"Binary32":254,"Binary8":252,"Dictionary0":236,"Dictionary1":237,"Dictionary2":238,"Dictionary3":239,"FBJID":246,"Hex8":251,"InteropJID":245,"JIDPair":250,"List16":249,"List8":248,"ListEmpty":0,"Nibble8":255,"PackedMax":127,"SingleByteMax":256},"dict_version":3,"double":[["read-self","active","fbns","protocol","reaction","screen_width","heartbeat","deviceid","2:47DEQpj8","uploadfieldstat","voip_settings","retry","priority","longitude","conflict","false","ig_professional","replaced","preaccept","cover_photo","uncompressed","encopt","ppic","04","passive","status-revoke-drop","keygen","540","offer","rate","opus","latitude","w:gp2","ver","4","business_profile","medium","sender","prev_v_id","email","website","invited","sign_credential","05","transport","skey","reason","peer_abtest_bucket","America/Sao_Paulo","appid","refresh","100","06","404","101","104","107","102","109","103","member_add_mode","105","transaction-id","110","106","outgoing","108","111","tokens","followers","ig_handle","self_pid","tue","dec","thu","joinable","peer_pid","mon","features","wed","peer_device_presence","pn","delete","07","fri","audio_duration","admin","connected","delta","rcat","disable","collection","08","480","sat","phash","all","invite","accept","critical_unblock_low","group_update","signed_credential","blinded_credential","eph_setting","net","09","background_location","refresh_id","Asia/Kolkata","privacy_mode_ts","account_sync","voip_payload_type","service_areas","acs_public_key","v_id","0a","fallback_class","relay","actual_actors","metadata","w:biz","5","connected-limit","notice","0b","host_storage","fb_page","subject","privatestats","invis","groupadd","010","note.m4r","uuid","0c","8000","sun","372","1020","stage","1200","720","canonical","fb","011","video_duration","0d","1140","superadmin","012","Opening.m4r","keystore_attestation","dleq_proof","013","timestamp","ab_key","w:sync:app:state","0e","vertical","600","p_v_id","6","likes","014","500","1260","creator","0f","rte","destination","group","group_info","syncd_anti_tampering_fatal_exception_enabled","015","dl_bw","Asia/Jakarta","vp8/h.264","online","1320","fb:multiway","10","timeout","016","nse_retry","urn:xmpp:whatsapp:dirty","017","a_v_id","web_shops_chat_header_button_enabled","nse_call","inactive-upgrade","none","web","groups","2250","mms_hot_content_timespan_in_seconds","contact_blacklist","nse_read","suspended_group_deletion_notification","binary_version","018","https://www.whatsapp.com/otp/copy/","reg_push","shops_hide_catalog_attachment_entrypoint","server_sync",".","ephemeral_messages_allowed_values","019","mms_vcache_aggregation_enabled","iphone","America/Argentina/Buenos_Aires","01a","mms_vcard_autodownload_size_kb","nse_ver","shops_header_dropdown_menu_item","dhash","catalog_status","communities_mvp_new_iqs_serverprop","blocklist","default","11","ephemeral_messages_enabled","01b","original_dimensions","8","mms4_media_retry_notification_encryption_enabled","mms4_server_error_receipt_encryption_enabled","original_image_url","sync","multiway","420","companion_enc_static","shops_profile_drawer_entrypoint","01c","vcard_as_document_size_kb","status_video_max_duration","request_image_url","01d","regular_high","s_t","abt","share_ext_min_preliminary_image_quality","01e","32","syncd_key_rotation_enabled","data_namespace","md_downgrade_read_receipts2","patch","polltype","ephemeral_messages_setting","userrate","15","partial_pjpeg_bw_threshold","played-self","catalog_exists","01f","mute_v2"],["reject","dirty","announcement","020","13","9","status_video_max_bitrate","fb:thrift_iq","offline_batch","022","full","ctwa_first_business_reply_logging","h.264","smax_id","group_description_length","https://www.whatsapp.com/otp/code","status_image_max_edge","smb_upsell_business_profile_enabled","021","web_upgrade_to_md_modal","14","023","s_o","smaller_video_thumbs_status_enabled","media_max_autodownload","960","blocking_status","peer_msg","joinable_group_call_client_version","group_call_video_maximization_enabled","return_snapshot","high","America/Mexico_City","entry_point_block_logging_enabled","pop","024","1050","16","1380","one_tap_calling_in_group_chat_size","regular_low","inline_joinable_education_enabled","hq_image_max_edge","locked","America/Bogota","smb_biztools_deeplink_enabled","status_image_quality","1088","025","payments_upi_intent_transaction_limit","voip","w:g2","027","md_pin_chat_enabled","026","multi_scan_pjpeg_download_enabled","shops_product_grid","transaction_id","ctwa_context_enabled","20","fna","hq_image_quality","alt_jpeg_doc_detection_quality","group_call_max_participants","pkey","America/Belem","image_max_kbytes","web_cart_v1_1_order_message_changes_enabled","ctwa_context_enterprise_enabled","urn:xmpp:whatsapp:account","840","Asia/Kuala_Lumpur","max_participants","video_remux_after_repair_enabled","stella_addressbook_restriction_type","660","900","780","context_menu_ios13_enabled","mute-state","ref","payments_request_messages","029","frskmsg","vcard_max_size_kb","sample_buffer_gif_player_enabled","match_last_seen","510","4983","video_max_bitrate","028","w:comms:chat","17","frequently_forwarded_max","groups_privacy_blacklist","Asia/Karachi","02a","web_download_document_thumb_mms_enabled","02b","hist_sync","biz_block_reasons_version","1024","18","web_is_direct_connection_for_plm_transparent","view_once_write","file_max_size","paid_convo_id","online_privacy_setting","video_max_edge","view_once_read","enhanced_storage_management","multi_scan_pjpeg_encoding_enabled","ctwa_context_forward_enabled","video_transcode_downgrade_enable","template_doc_mime_types","hq_image_bw_threshold","30","body","u_aud_limit_sil_restarts_ctrl","other","participating","w:biz:directory","1110","vp8","4018","meta","doc_detection_image_max_edge","image_quality","1170","02c","smb_upsell_chat_banner_enabled","key_expiry_time_second","pid","stella_interop_enabled","19","linked_device_max_count","md_device_sync_enabled","02d","02e","360","enhanced_block_enabled","ephemeral_icon_in_forwarding","paid_convo_status","gif_provider","project_name","server-error","canonical_url_validation_enabled","wallpapers_v2","syncd_clear_chat_delete_chat_enabled","medianotify","02f","shops_required_tos_version","vote","reset_skey_on_id_change","030","image_max_edge","multicast_limit_global","ul_bw","21","25","5000","poll","570","22","031","1280","WhatsApp","032","bloks_shops_enabled","50","upload_host_switching_enabled","web_ctwa_context_compose_enabled","ptt_forwarded_features_enabled","unblocked","partial_pjpeg_enabled","fbid:devices","height","ephemeral_group_query_ts","group_join_permissions","order","033","alt_jpeg_status_quality","migrate","popular-bank","win_uwp_deprecation_killswitch_enabled","web_download_status_thumb_mms_enabled","blocking","url_text","035","web_forwarding_limit_to_groups","1600","val","1000","syncd_msg_date_enabled","bank-ref-id","max_subject","payments_web_enabled","web_upload_document_thumb_mms_enabled","size","request","ephemeral","24","receipt_agg","ptt_remember_play_position","sampling_weight","enc_rekey","mute_always","037","034","23","036","action","click_to_chat_qr_enabled","width","disabled","038","md_blocklist_v2","played_self_enabled","web_buttons_message_enabled","flow_id","clear","450","fbid:thread","bloks_session_state","America/Lima","attachment_picker_refresh","download_host_switching_enabled","1792","u_aud_limit_sil_restarts_test2","custom_urls","device_fanout","optimistic_upload","2000","key_cipher_suite","web_smb_upsell_in_biz_profile_enabled","e","039","siri_post_status_shortcut","pair-device","lg","lc","stream_attribution_url","model","mspjpeg_phash_gen","catalog_send_all","new_multi_vcards_ui","share_biz_vcard_enabled","-","clean","200","md_blocklist_v2_server","03b","03a","web_md_migration_experience","ptt_conversation_waveform","u_aud_limit_sil_restarts_test1"],["64","ptt_playback_speed_enabled","web_product_list_message_enabled","paid_convo_ts","27","manufacturer","psp-routing","grp_uii_cleanup","ptt_draft_enabled","03c","business_initiated","web_catalog_products_onoff","web_upload_link_thumb_mms_enabled","03e","mediaretry","35","hfm_string_changes","28","America/Fortaleza","max_keys","md_mhfs_days","streaming_upload_chunk_size","5541","040","03d","2675","03f","...","512","mute","48","041","alt_jpeg_quality","60","042","md_smb_quick_reply","5183","c","1343","40","1230","043","044","mms_cat_v1_forward_hot_override_enabled","user_notice","ptt_waveform_send","047","Asia/Calcutta","250","md_privacy_v2","31","29","128","md_messaging_enabled","046","crypto","690","045","enc_iv","75","failure","ptt_oot_playback","AIzaSyDR5yfaG7OG8sMTUj8kfQEb8T9pN8BM6Lk","w","048","2201","web_large_files_ui","Asia/Makassar","812","status_collapse_muted","1334","257","2HP4dm","049","patches","1290","43cY6T","America/Caracas","web_sticker_maker","campaign","ptt_pausable_enabled","33","42","attestation","biz","04b","query_linked","s","125","04a","810","availability","1411","responsiveness_v2_m1","catalog_not_created","34","America/Santiago","1465","enc_p","04d","status_info","04f","key_version","..","04c","04e","md_group_notification","1598","1215","web_cart_enabled","37","630","1920","2394","-1","vcard","38","elapsed","36","828","peer","pricing_category","1245","invalid","stella_ios_enabled","2687","45","1528","39","u_is_redial_audio_1104_ctrl","1025","1455","58","2524","2603","054","bsp_system_message_enabled","web_pip_redesign","051","verify_apps","1974","1272","1322","1755","052","70","050","1063","1135","1361","80","1096","1828","1851","1251","1921","key_config_id","1254","1566","1252","2525","critical_block","1669","max_available","w:auth:backup:token","product","2530","870","1022","participant_uuid","web_cart_on_off","1255","1432","1867","41","1415","1440","240","1204","1608","1690","1846","1483","1687","1749","69","url_number","053","1325","1040","365","59","Asia/Riyadh","1177","test_recommended","057","1612","43","1061","1518","1635","055","1034","1375","750","1430","event_code","1682","503","55","865","78","1309","1365","44","America/Guayaquil","535","LIMITED","1377","1613","1420","1599","1822","05a","1681","password","1111","1214","1376","1478","47","1082","4282","Europe/Istanbul","1307","46","058","1124","256","rate-overlimit","retail","u_a_socket_err_fix_succ_test","1292","1370","1388","520","861","psa","regular","1181","1766","05b","1183","1213","1304","1537"],["1724","profile_picture","1071","1314","1605","407","990","1710","746","pricing_model","056","059","061","1119","6027","65","877","1607","05d","917","seen","1516","49","470","973","1037","1350","1394","1480","1796","keys","794","1536","1594","2378","1333","1524","1825","116","309","52","808","827","909","495","1660","361","957","google","1357","1565","1967","996","1775","586","736","1052","1670","bank","177","1416","2194","2222","1454","1839","1275","53","997","1629","6028","smba","1378","1410","05c","1849","727","create","1559","536","1106","1310","1944","670","1297","1316","1762","en","1148","1295","1551","1853","1890","1208","1784","7200","05f","178","1283","1332","381","643","1056","1238","2024","2387","179","981","1547","1705","05e","290","903","1069","1285","2436","062","251","560","582","719","56","1700","2321","325","448","613","777","791","51","488","902","Asia/Almaty","is_hidden","1398","1527","1893","1999","2367","2642","237","busy","065","067","233","590","993","1511","54","723","860","363","487","522","605","995","1321","1691","1865","2447","2462","NON_TRANSACTIONAL","433","871","432","1004","1207","2032","2050","2379","2446","279","636","703","904","248","370","691","700","1068","1655","2334","060","063","364","533","534","567","1191","1210","1473","1827","069","701","2531","514","prev_dhash","064","496","790","1046","1139","1505","1521","1108","207","544","637","final","1173","1293","1694","1939","1951","1993","2353","2515","504","601","857","modify","spam_request","p_121_aa_1101_test4","866","1427","1502","1638","1744","2153","068","382","725","1704","1864","1990","2003","Asia/Dubai","508","531","1387","1474","1632","2307","2386","819","2014","066","387","1468","1706","2186","2261","471","728","1147","1372","1961"]],"probes":[{"string":"iq","wire_hex":"00f80119","single":25,"dict":-1,"index":-1,"found":true},{"string":"s.whatsapp.net","wire_hex":"00f80103","single":3,"dict":-1,"index":-1,"found":true},{"string":"w:p","wire_hex":"00f80157","single":87,"dict":-1,"index":-1,"found":true},{"string":"message","wire_hex":"00f80113","single":19,"dict":-1,"index":-1,"found":true},{"string":"lid","wire_hex":"00f80176","single":118,"dict":-1,"index":-1,"found":true},{"string":"patch","wire_hex":"00f801ecf6","single":-1,"dict":0,"index":246,"found":true},{"string":"mute_v2","wire_hex":"00f801ecff","single":-1,"dict":0,"index":255,"found":true},{"string":"pair-device","wire_hex":"00f801edee","single":-1,"dict":1,"index":238,"found":true},{"string":"phash","wire_hex":"00f801ec5f","single":-1,"dict":0,"index":95,"found":true},{"string":"mediaretry","wire_hex":"00f801ee0e","single":-1,"dict":2,"index":14,"found":true},{"string":"enc_p","wire_hex":"00f801ee62","single":-1,"dict":2,"index":98,"found":true},{"string":"create","wire_hex":"00f801ef4c","single":-1,"dict":3,"index":76,"found":true},{"string":"spam_request","wire_hex":"00f801efdc","single":-1,"dict":3,"index":220,"found":true},{"string":"no-such-token-in-any-dict-zzz","wire_hex":"00f801fc1d6e6f2d737563682d746f6b656e2d696e2d616e792d646963742d7a7a7a","single":-1,"dict":-1,"index":-1,"found":false}],"single":["","xmlstreamstart","xmlstreamend","s.whatsapp.net","type","participant","from","receipt","id","notification","disappearing_mode","status","jid","broadcast","user","devices","device_hash","to","offline","message","result","class","xmlns","duration","notify","iq","t","ack","g.us","enc","urn:xmpp:whatsapp:push","presence","config_value","picture","verified_name","config_code","key-index-list","contact","mediatype","routing_info","edge_routing","get","read","urn:xmpp:ping","fallback_hostname","0","chatstate","business_hours_config","unavailable","download_buckets","skmsg","verified_level","composing","handshake","device-list","media","text","fallback_ip4","media_conn","device","creation","location","config","item","fallback_ip6","count","w:profile:picture","image","business","2","hostname","call-creator","display_name","relaylatency","platform","abprops","success","msg","offline_preview","prop","key-index","v","day_of_week","pkmsg","version","1","ping","w:p","download","video","set","specific_hours","props","primary","unknown","hash","commerce_experience","last","subscribe","max_buckets","call","profile","member_since_text","close_time","call-id","sticker","mode","participants","value","query","profile_options","open_time","code","list","host","ts","contacts","upload","lid","preview","update","usync","w:stats","delivery","auth_ttl","context","fail","cart_enabled","appdata","category","atn","direct_connection","decrypt-fail","relay_id","mmg-fallback.whatsapp.net","target","available","name","last_id","mmg.whatsapp.net","categories","401","is_new","index","tctoken","ip4","token_id","latency","recipient","edit","ip6","add","thumbnail-document","26","paused","true","identity","stream:error","key","sidelist","background","audio","3","thumbnail-image","biz-cover-photo","cat","gcm","thumbnail-video","error","auth","deny","serial","in","registration","thumbnail-link","remove","00","gif","thumbnail-gif","tag","capability","multicast","item-not-found","description","business_hours","config_expo_key","md-app-state","expiration","fallback","ttl","300","md-msg-hist","device_orientation","out","w:m","open_24h","side_list","token","inactive","01","document","te2","played","encrypt","msgr","hide","direct_path","12","state","not-authorized","url","terminate","signature","status-revoke-delay","02","te","linked_accounts","trusted_contact","timezone","ptt","kyc-id","privacy_token","readreceipts","appointment_only","address","expected_ts","privacy","7","android","interactive","device-identity","enabled","attribute_padding","1080","03","screen_height"]} diff --git a/taicpp/tests/whatsapp_test.cpp b/taicpp/tests/whatsapp_test.cpp index 4348316..e5b2b6f 100644 --- a/taicpp/tests/whatsapp_test.cpp +++ b/taicpp/tests/whatsapp_test.cpp @@ -15,9 +15,13 @@ #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" @@ -25,6 +29,7 @@ #include "wa_media.h" #include "wa_receiver.h" #include "wa_store.h" +#include "wa_tokens_data.h" #include "wa_vcf.h" using namespace detz::wa; @@ -347,6 +352,29 @@ static void test_hostile() { } } 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) ---- @@ -862,8 +890,357 @@ static void test_vcf_import() { 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 ".-". + 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 .- 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 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(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"); +} + 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(); diff --git a/taicpp/tools/san-whatsapp.sh b/taicpp/tools/san-whatsapp.sh index abb111d..b9c24ad 100755 --- a/taicpp/tools/san-whatsapp.sh +++ b/taicpp/tools/san-whatsapp.sh @@ -4,8 +4,8 @@ # for detz::wa's parsing layer: a malformed frame that overflows a length check, # over-reads a buffer, or over-allocates a vector aborts here at the exact line, # instead of slipping past a bounds check in a release build. Run it in CI and -# before any change to a parser (WaBinary, WaProto, WaNoise, WaSignal). See -# docs/cpp-safety.md. +# before any change to a parser (WaBinary, WaProto, WaNoise, WaSignal, WaIq, +# WaServerCert). See docs/cpp-safety.md. # # The vendored ed25519 ref10 (third_party/ed25519) is built WITHOUT UBSan on # purpose: it uses intentional, portable signed-shift idioms that UBSan flags @@ -15,17 +15,20 @@ cd "$(dirname "$0")/.." OUT=$(mktemp -d) trap 'rm -rf "$OUT"' EXIT SAN="-fsanitize=address,undefined -fno-sanitize-recover=all -D_GLIBCXX_ASSERTIONS -g -O1 -std=c++23 -Wall -Wextra" -INC="-Isrc/whatsapp -Isrc/llm -Ithird_party/ed25519" -# only the parser TUs the hostile/golden test exercises -for f in wa_crypto wa_binary wa_proto wa_noise wa_signal wa_device; do - g++ $SAN $INC -c "src/whatsapp/$f.cpp" -o "$OUT/$f.o" +INC="-Isrc/whatsapp -Isrc/llm -Isrc/seal -Isrc/store -Isrc/util -Ithird_party/ed25519 -Ithird_party/qrcodegen" +# the whole detz::wa module plus the support TUs the test links (json, vault, +# base64, fuzzy): the hostile/golden test exercises the parsers through them. +n=0 +for f in src/whatsapp/*.cpp src/llm/json.cpp src/seal/seal_base64.cpp src/store/sqldb.cpp src/util/fuzzy.cpp \ + third_party/qrcodegen/*.cpp; do + n=$((n + 1)) + g++ $SAN $INC -c "$f" -o "$OUT/tu$n.o" done -g++ $SAN $INC -c src/llm/json.cpp -o "$OUT/json.o" # ref10 C without UBSan (intentional portable shift idioms), still ASan for memory for f in third_party/ed25519/*.c; do cc -fsanitize=address -D_GLIBCXX_ASSERTIONS -g -O2 -Ithird_party/ed25519 -c "$f" -o "$OUT/$(basename "$f" .c).o" done -g++ $SAN $INC tests/whatsapp_test.cpp "$OUT"/*.o -lssl -lcrypto -lz -o "$OUT/whatsapp_test_san" +g++ $SAN $INC tests/whatsapp_test.cpp "$OUT"/*.o -lssl -lcrypto -lz -lcurl -lsqlite3 -o "$OUT/whatsapp_test_san" echo "running whatsapp_test under ASan+UBSan+_GLIBCXX_ASSERTIONS ..." "$OUT/whatsapp_test_san" tests/whatsapp_data echo "san-whatsapp: OK" diff --git a/taicpp/tools/wa-api-inventory.sh b/taicpp/tools/wa-api-inventory.sh index 8866666..e777a12 100755 --- a/taicpp/tools/wa-api-inventory.sh +++ b/taicpp/tools/wa-api-inventory.sh @@ -65,9 +65,12 @@ def pkg_of(text): return m.group(1) if m else "?" def strip_line(ln): - ln = ln.split("//", 1)[0] + # Strings first, then the comment: a "//" inside a literal (e.g. the + # "https://..." token in binary/token/token.go) must not truncate the + # line, or brace-depth counting breaks and the block swallows the file. ln = re.sub(r"`[^`]*`", "", ln) ln = re.sub(r'"(?:[^"\\]|\\.)*"', "", ln) + ln = ln.split("//", 1)[0] return ln def exported(name): diff --git a/taicpp/tools/wa-mapping.tsv b/taicpp/tools/wa-mapping.tsv index 2ce879a..c22da6d 100644 --- a/taicpp/tools/wa-mapping.tsv +++ b/taicpp/tools/wa-mapping.tsv @@ -5,6 +5,7 @@ # Mapping rule: a named C++ entity (function, method, type, const, field) # performs the counterpart role; inline fragments with no named entity stay # UNMAPPED for a later milestone to name. Subset notes name the gap (M1-M10). + appstate.AllPatchNames UNMAPPED appstate.BuildArchive UNMAPPED appstate.BuildDeleteChat UNMAPPED @@ -450,22 +451,22 @@ richresponse.UnknownViewModel.String UNMAPPED richresponse.ViewModel UNMAPPED richresponse.ViewModelContainer UNMAPPED richresponse.ViewModelContainer.UnmarshalJSON UNMAPPED -socket.DisconnectHandler UNMAPPED +socket.DisconnectHandler detz::wa::WaClient::on_disconnect (src/whatsapp/wa_client.h; invoked when the stream closes) socket.ErrDialFailed detz::wa::WaSocket::connect throws WaSocketError (src/whatsapp/wa_socket.h; shared type) socket.ErrFrameTooLarge detz::wa::WaFraming::frame throws on oversize (src/whatsapp/wa_noise.cpp) -socket.ErrSocketAlreadyOpen UNMAPPED -socket.ErrSocketClosed UNMAPPED -socket.ErrWithStatusCode UNMAPPED -socket.FrameHandler UNMAPPED -socket.FrameLengthSize detz::wa::WaFraming::frame/unframe 3-byte prefix (src/whatsapp/wa_noise.h; literal, unnamed) +socket.ErrSocketAlreadyOpen detz::wa::WaSocket::connect throws WaSocketError (src/whatsapp/wa_socket.h; already-open guard) +socket.ErrSocketClosed detz::wa::WaSocket::send_frame throws WaSocketError (src/whatsapp/wa_socket.h; not-connected guard) +socket.ErrWithStatusCode detz::wa::WaSocketStatusError (src/whatsapp/wa_socket.h) +socket.FrameHandler detz::wa::WaClient::recv_node + dispatch (src/whatsapp/wa_client.h; pull model, no frame callback) +socket.FrameLengthSize detz::wa::WaFraming::kLengthSize (src/whatsapp/wa_noise.h) socket.FrameMaxSize detz::wa::WaFraming::kMaxFrame (src/whatsapp/wa_noise.h) -socket.FrameSocket UNMAPPED -socket.FrameSocket.Close UNMAPPED -socket.FrameSocket.Connect UNMAPPED -socket.FrameSocket.Context UNMAPPED -socket.FrameSocket.IsConnected UNMAPPED -socket.FrameSocket.SendFrame UNMAPPED -socket.NewFrameSocket UNMAPPED +socket.FrameSocket detz::wa::WaSocket (src/whatsapp/wa_socket.h) +socket.FrameSocket.Close detz::wa::WaSocket::close (src/whatsapp/wa_socket.h) +socket.FrameSocket.Connect detz::wa::WaSocket::connect (src/whatsapp/wa_socket.h) +socket.FrameSocket.Context detz::wa::WaSocket::connected (src/whatsapp/wa_socket.h; closure state; the sync model has no cancel contexts) +socket.FrameSocket.IsConnected detz::wa::WaSocket::connected (src/whatsapp/wa_socket.h) +socket.FrameSocket.SendFrame detz::wa::WaSocket::send_frame (src/whatsapp/wa_socket.h) +socket.NewFrameSocket detz::wa::WaSocket::WaSocket (src/whatsapp/wa_socket.h) socket.NewNoiseHandshake detz::wa::WaNoise::WaNoise (src/whatsapp/wa_noise.h) socket.NoiseHandshake detz::wa::WaNoise (src/whatsapp/wa_noise.h) socket.NoiseHandshake.Authenticate detz::wa::WaNoise::mix_hash (src/whatsapp/wa_noise.h; private mix step) @@ -473,12 +474,12 @@ socket.NoiseHandshake.Decrypt detz::wa::WaNoise::decrypt + transport_decrypt (sr socket.NoiseHandshake.Encrypt detz::wa::WaNoise::encrypt + transport_encrypt (src/whatsapp/wa_noise.h) socket.NoiseHandshake.Finish detz::wa::WaNoise::finish (src/whatsapp/wa_noise.h; private split step) socket.NoiseHandshake.MixIntoKey detz::wa::WaNoise::mix_key (src/whatsapp/wa_noise.h; private) -socket.NoiseHandshake.MixSharedSecretIntoKey UNMAPPED +socket.NoiseHandshake.MixSharedSecretIntoKey detz::wa::WaNoise::mix_shared_secret (src/whatsapp/wa_noise.h) socket.NoiseHandshake.Start detz::wa::WaNoise::client_handshake (src/whatsapp/wa_noise.h; XX initiator run as one call) -socket.NoiseSocket UNMAPPED -socket.NoiseSocket.IsConnected UNMAPPED -socket.NoiseSocket.SendFrame UNMAPPED -socket.NoiseSocket.Stop UNMAPPED +socket.NoiseSocket detz::wa::WaClient transport pair (src/whatsapp/wa_client.h; WaNoise + WaSocket via send_node/recv_node) +socket.NoiseSocket.IsConnected detz::wa::WaClient::socket_open (src/whatsapp/wa_client.h) +socket.NoiseSocket.SendFrame detz::wa::WaClient::send_node (src/whatsapp/wa_client.h; transport-encrypt + frame send) +socket.NoiseSocket.Stop detz::wa::WaClient::disconnect (src/whatsapp/wa_client.h; close + on_disconnect, no suppress flag) socket.NoiseStartPattern detz::wa::WaNoise kMode (src/whatsapp/wa_noise.cpp; same 32-byte string) socket.Origin detz::wa::WaSocket::kOrigin (src/whatsapp/wa_socket.h) socket.URL detz::wa::WaSocket::kUrl (src/whatsapp/wa_socket.h) @@ -712,8 +713,30 @@ store.WAVersionContainer.IsZero UNMAPPED store.WAVersionContainer.LessThan UNMAPPED store.WAVersionContainer.ProtoAppVersion UNMAPPED store.WAVersionContainer.String UNMAPPED -token.DoubleByteTokens UNMAPPED -token.SingleByteTokens UNMAPPED +token.ADJID detz::wa::WaBinary::AD_JID (src/whatsapp/wa_binary.h) +token.Binary20 detz::wa::WaBinary::BINARY_20 (src/whatsapp/wa_binary.h) +token.Binary32 detz::wa::WaBinary::BINARY_32 (src/whatsapp/wa_binary.h) +token.Binary8 detz::wa::WaBinary::BINARY_8 (src/whatsapp/wa_binary.h) +token.DictVersion detz::wa::kDictVersion (src/whatsapp/wa_tokens_data.h) +token.Dictionary0 detz::wa::WaBinary::DICTIONARY_0 (src/whatsapp/wa_binary.h) +token.Dictionary1 detz::wa::WaBinary::DICTIONARY_1 (src/whatsapp/wa_binary.h) +token.Dictionary2 detz::wa::WaBinary::DICTIONARY_2 (src/whatsapp/wa_binary.h) +token.Dictionary3 detz::wa::WaBinary::DICTIONARY_3 (src/whatsapp/wa_binary.h) +token.DoubleByteTokens detz::wa::kDoubleByteTokens (src/whatsapp/wa_tokens_data.h) +token.FBJID detz::wa::WaBinary::FB_JID (src/whatsapp/wa_binary.h) +token.GetDoubleToken detz::wa::Decoder::read_string double-token branch (src/whatsapp/wa_binary.cpp; file-local decoder, throws WireError when out of range) +token.Hex8 detz::wa::WaBinary::HEX_8 (src/whatsapp/wa_binary.h) +token.IndexOfDoubleByteToken detz::wa::Encoder::double_token (src/whatsapp/wa_binary.cpp; file-local encoder lookup) +token.IndexOfSingleToken detz::wa::Encoder::single_token (src/whatsapp/wa_binary.cpp; file-local encoder lookup) +token.InteropJID detz::wa::WaBinary::INTEROP_JID (src/whatsapp/wa_binary.h) +token.JIDPair detz::wa::WaBinary::JID_PAIR (src/whatsapp/wa_binary.h) +token.List16 detz::wa::WaBinary::LIST_16 (src/whatsapp/wa_binary.h) +token.List8 detz::wa::WaBinary::LIST_8 (src/whatsapp/wa_binary.h) +token.ListEmpty detz::wa::WaBinary::LIST_EMPTY (src/whatsapp/wa_binary.h) +token.Nibble8 detz::wa::WaBinary::NIBBLE_8 (src/whatsapp/wa_binary.h) +token.PackedMax detz::wa::WaBinary::PACKED_MAX (src/whatsapp/wa_binary.h) +token.SingleByteMax detz::wa::WaBinary::SINGLE_BYTE_MAX (src/whatsapp/wa_binary.h) +token.SingleByteTokens detz::wa::kSingleByteTokens (src/whatsapp/wa_tokens_data.h) types.AddressingMode UNMAPPED types.AddressingModeLID UNMAPPED types.AddressingModePN UNMAPPED @@ -962,8 +985,8 @@ waArmadilloApplication.Armadillo_Content_RavenMessage.SetImageMessage UNMAPPED waArmadilloApplication.Armadillo_Content_RavenMessage.SetVideoMessage UNMAPPED waArmadilloApplication.RavenImageTransportVersion UNMAPPED waArmadilloApplication.RavenVideoTransportVersion UNMAPPED -waCert.CertChain UNMAPPED -waCert.CertChain_NoiseCertificate_Details UNMAPPED +waCert.CertChain detz::wa::WaCertChainParsed (src/whatsapp/wa_proto.h) +waCert.CertChain_NoiseCertificate_Details detz::wa::WaNoiseCertDetails (src/whatsapp/wa_proto.h) waCommon.MessageKey UNMAPPED waCommon.SubProtocol UNMAPPED waCompanionReg.ClientPairingProps UNMAPPED @@ -1292,9 +1315,9 @@ whatsmeow.DisappearingTimer24Hours UNMAPPED whatsmeow.DisappearingTimer7Days UNMAPPED whatsmeow.DisappearingTimer90Days UNMAPPED whatsmeow.DisappearingTimerOff UNMAPPED -whatsmeow.DisconnectedError UNMAPPED -whatsmeow.DisconnectedError.Error UNMAPPED -whatsmeow.DisconnectedError.Is UNMAPPED +whatsmeow.DisconnectedError detz::wa::WaDisconnectedError (src/whatsapp/wa_iq.h) +whatsmeow.DisconnectedError.Error detz::wa::WaDisconnectedError::message (src/whatsapp/wa_iq.h) +whatsmeow.DisconnectedError.Is detz::wa::WaDisconnectedError::is (src/whatsapp/wa_iq.h) whatsmeow.DownloadHTTPError UNMAPPED whatsmeow.DownloadHTTPError.Error UNMAPPED whatsmeow.DownloadHTTPError.Is UNMAPPED @@ -1313,31 +1336,31 @@ whatsmeow.EncSecretPollEdit UNMAPPED whatsmeow.EncSecretPollVote UNMAPPED whatsmeow.EncSecretReaction UNMAPPED whatsmeow.EncSecretReportToken UNMAPPED -whatsmeow.ErrAlreadyConnected UNMAPPED +whatsmeow.ErrAlreadyConnected detz::wa::WaClient::connect refuses when open (src/whatsapp/wa_client.h; loud log + false) whatsmeow.ErrAppStateUpdate UNMAPPED whatsmeow.ErrBroadcastListUnsupported UNMAPPED whatsmeow.ErrBusinessMessageLinkNotFound UNMAPPED -whatsmeow.ErrClientIsNil UNMAPPED +whatsmeow.ErrClientIsNil detz::wa::WaClient (src/whatsapp/wa_client.h; methods take references, a nil receiver is unrepresentable) whatsmeow.ErrContactQRLinkNotFound UNMAPPED whatsmeow.ErrEventAlreadyProcessed UNMAPPED whatsmeow.ErrFileLengthMismatch UNMAPPED whatsmeow.ErrGroupInviteLinkUnauthorized UNMAPPED whatsmeow.ErrGroupNotFound UNMAPPED -whatsmeow.ErrIQBadRequest UNMAPPED -whatsmeow.ErrIQDisconnected UNMAPPED -whatsmeow.ErrIQForbidden UNMAPPED -whatsmeow.ErrIQGone UNMAPPED -whatsmeow.ErrIQInternalServerError UNMAPPED -whatsmeow.ErrIQLocked UNMAPPED -whatsmeow.ErrIQNotAcceptable UNMAPPED -whatsmeow.ErrIQNotAllowed UNMAPPED -whatsmeow.ErrIQNotAuthorized UNMAPPED -whatsmeow.ErrIQNotFound UNMAPPED -whatsmeow.ErrIQPartialServerError UNMAPPED -whatsmeow.ErrIQRateOverLimit UNMAPPED -whatsmeow.ErrIQResourceLimit UNMAPPED -whatsmeow.ErrIQServiceUnavailable UNMAPPED -whatsmeow.ErrIQTimedOut UNMAPPED +whatsmeow.ErrIQBadRequest detz::wa::WaIqError::bad_request (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQDisconnected detz::wa::WaDisconnectedError::for_iq (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQForbidden detz::wa::WaIqError::forbidden (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQGone detz::wa::WaIqError::gone (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQInternalServerError detz::wa::WaIqError::internal_server_error (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQLocked detz::wa::WaIqError::locked (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQNotAcceptable detz::wa::WaIqError::not_acceptable (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQNotAllowed detz::wa::WaIqError::not_allowed (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQNotAuthorized detz::wa::WaIqError::not_authorized (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQNotFound detz::wa::WaIqError::not_found (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQPartialServerError detz::wa::WaIqError::partial_server_error (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQRateOverLimit detz::wa::WaIqError::rate_over_limit (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQResourceLimit detz::wa::WaIqError::resource_limit (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQServiceUnavailable detz::wa::WaIqError::service_unavailable (src/whatsapp/wa_iq.h) +whatsmeow.ErrIQTimedOut detz::wa::WaIqError::timed_out (src/whatsapp/wa_iq.h) whatsmeow.ErrInvalidDisappearingTimer UNMAPPED whatsmeow.ErrInvalidImageFormat UNMAPPED whatsmeow.ErrInvalidInlineBotID UNMAPPED @@ -1357,7 +1380,7 @@ whatsmeow.ErrNoPrivacyToken UNMAPPED whatsmeow.ErrNoPushName UNMAPPED whatsmeow.ErrNoSession UNMAPPED whatsmeow.ErrNoURLPresent UNMAPPED -whatsmeow.ErrNotConnected UNMAPPED +whatsmeow.ErrNotConnected detz::wa::WaSocket::send_frame throws WaSocketError (src/whatsapp/wa_socket.h; not-connected guard) whatsmeow.ErrNotEncryptedCommentMessage UNMAPPED whatsmeow.ErrNotEncryptedReactionMessage UNMAPPED whatsmeow.ErrNotInGroup UNMAPPED @@ -1401,14 +1424,14 @@ whatsmeow.GetNewsletterUpdatesParams UNMAPPED whatsmeow.GetProfilePictureParams UNMAPPED whatsmeow.HashPollOptions UNMAPPED whatsmeow.IGMessageApplicationVersion UNMAPPED -whatsmeow.IQError UNMAPPED -whatsmeow.IQError.Error UNMAPPED -whatsmeow.IQError.Is UNMAPPED +whatsmeow.IQError detz::wa::WaIqError (src/whatsapp/wa_iq.h) +whatsmeow.IQError.Error detz::wa::WaIqError::message (src/whatsapp/wa_iq.h) +whatsmeow.IQError.Is detz::wa::WaIqError::is (src/whatsapp/wa_iq.h) whatsmeow.InviteLinkPrefix UNMAPPED -whatsmeow.KeepAliveIntervalMax UNMAPPED -whatsmeow.KeepAliveIntervalMin UNMAPPED -whatsmeow.KeepAliveMaxFailTime UNMAPPED -whatsmeow.KeepAliveResponseDeadline UNMAPPED +whatsmeow.KeepAliveIntervalMax detz::wa::WaConsts::kWaKeepAliveIntervalMax (src/whatsapp/wa_consts.h; schedule in WaKeepAlive, src/whatsapp/wa_keepalive.h) +whatsmeow.KeepAliveIntervalMin detz::wa::WaConsts::kWaKeepAliveIntervalMin (src/whatsapp/wa_consts.h; schedule in WaKeepAlive, src/whatsapp/wa_keepalive.h) +whatsmeow.KeepAliveMaxFailTime detz::wa::WaConsts::kWaKeepAliveMaxFailTime (src/whatsapp/wa_consts.h; schedule in WaKeepAlive, src/whatsapp/wa_keepalive.h) +whatsmeow.KeepAliveResponseDeadline detz::wa::WaConsts::kWaKeepAliveResponseDeadline (src/whatsapp/wa_consts.h; ping in WaClient::send_keepalive_ping) whatsmeow.MediaAppState UNMAPPED whatsmeow.MediaAudio UNMAPPED whatsmeow.MediaConn detz::wa::WaClient::MediaConn (src/whatsapp/wa_client.h; auth+host) @@ -1493,8 +1516,8 @@ whatsmeow.SendResponse UNMAPPED whatsmeow.SetProxyOptions UNMAPPED whatsmeow.UploadResponse detz::wa::WaMedia::Uploaded (src/whatsapp/wa_media.h) whatsmeow.UsyncQueryExtras UNMAPPED -whatsmeow.WACertIssuerSerial UNMAPPED -whatsmeow.WACertPubKey UNMAPPED +whatsmeow.WACertIssuerSerial detz::wa::WaConsts::kWaCertIssuerSerial (src/whatsapp/wa_consts.h) +whatsmeow.WACertPubKey detz::wa::WaConsts::kWaCertPubKey (src/whatsapp/wa_consts.h; verified by WaServerCert in src/whatsapp/wa_cert.h) whatsmeow.WantedPreKeyCount detz::wa::WaConsts::kWaPrekeyBatch (src/whatsapp/wa_consts.h) whatsmeow.WebMessageIDPrefix UNMAPPED whatsmeow.WebPushConfig UNMAPPED diff --git a/taicpp/tools/wa-slices/m1-foundations.txt b/taicpp/tools/wa-slices/m1-foundations.txt new file mode 100644 index 0000000..c940cdc --- /dev/null +++ b/taicpp/tools/wa-slices/m1-foundations.txt @@ -0,0 +1,42 @@ +# M1 foundations slice (docs/whatsapp.md section 18): the token dictionary, +# the socket/Noise transport, the handshake certificate, the keepalive +# tunables, and the iq error taxonomy. request.go, keepalive.go and +# handshake.go carry no other exported symbols; their behavior lives in +# WaIq, WaKeepAlive and WaServerCert. Deliberately outside M1: the +# KeepAliveTimeout/KeepAliveRestored events (M9 event bus), iq retry on +# reconnect (M9 auto-reconnect), and the binary AttrUtility getters (M4). +pkg:token +pkg:socket +sym:whatsmeow.NoiseHandshakeResponseTimeout +sym:whatsmeow.WACertPubKey +sym:whatsmeow.WACertIssuerSerial +sym:whatsmeow.KeepAliveIntervalMax +sym:whatsmeow.KeepAliveIntervalMin +sym:whatsmeow.KeepAliveMaxFailTime +sym:whatsmeow.KeepAliveResponseDeadline +sym:whatsmeow.IQError +sym:whatsmeow.IQError.Error +sym:whatsmeow.IQError.Is +sym:whatsmeow.DisconnectedError +sym:whatsmeow.DisconnectedError.Error +sym:whatsmeow.DisconnectedError.Is +sym:whatsmeow.ErrIQDisconnected +sym:whatsmeow.ErrIQTimedOut +sym:whatsmeow.ErrNotConnected +sym:whatsmeow.ErrAlreadyConnected +sym:whatsmeow.ErrClientIsNil +sym:whatsmeow.ErrIQBadRequest +sym:whatsmeow.ErrIQNotAuthorized +sym:whatsmeow.ErrIQForbidden +sym:whatsmeow.ErrIQNotFound +sym:whatsmeow.ErrIQNotAllowed +sym:whatsmeow.ErrIQNotAcceptable +sym:whatsmeow.ErrIQGone +sym:whatsmeow.ErrIQResourceLimit +sym:whatsmeow.ErrIQLocked +sym:whatsmeow.ErrIQRateOverLimit +sym:whatsmeow.ErrIQInternalServerError +sym:whatsmeow.ErrIQServiceUnavailable +sym:whatsmeow.ErrIQPartialServerError +sym:waCert.CertChain +sym:waCert.CertChain_NoiseCertificate_Details diff --git a/taicpp/tools/wa-stub-scan.sh b/taicpp/tools/wa-stub-scan.sh index 4b5839d..c7c64d9 100755 --- a/taicpp/tools/wa-stub-scan.sh +++ b/taicpp/tools/wa-stub-scan.sh @@ -59,7 +59,8 @@ for ln in sys.stdin: ' > "$new_returns" while IFS= read -r f; do test -n "$f" || continue - grep -nE "$RET" "$f" | sed "s|^|$f:|" | cut -d: -f1,2 >> "$new_returns" + # A file with no matches must not fail the scan (grep exits 1): it is clean. + grep -nE "$RET" "$f" | sed "s|^|$f:|" | cut -d: -f1,2 >> "$new_returns" || true done < <(git status --porcelain -- src/whatsapp/ | grep "^??" | sed -e "s|^?? ||" -e "s|^$PREFIX||" || true) else echo "wa-stub-scan: no HEAD, scanning the whole tree for silent returns" >&2