whatsapp: M1 foundations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
`<byte0>.<byte1>-`, `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "wa_cert.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "wa_consts.h"
|
||||
#include "wa_crypto.h"
|
||||
#include "wa_proto.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
namespace {
|
||||
|
||||
std::array<uint8_t, 32> as32(const Bytes& b) {
|
||||
std::array<uint8_t, 32> a{};
|
||||
std::memcpy(a.data(), b.data(), 32);
|
||||
return a;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 64> as64(const Bytes& b) {
|
||||
std::array<uint8_t, 64> 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<uint64_t>(now_unix) < d.not_before;
|
||||
if (too_early) throw WaCertError(which + " certificate not valid yet");
|
||||
const bool too_late = d.not_after > static_cast<uint64_t>(INT64_MAX) ||
|
||||
now_unix > static_cast<int64_t>(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
|
||||
@@ -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 <cstdint>
|
||||
#include <ctime>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#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
|
||||
@@ -1,7 +1,5 @@
|
||||
#include "wa_client.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
@@ -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<uint8_t, 2> 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<WaSocket>();
|
||||
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<bool()>& stop, int timeout_seconds) {
|
||||
auto clock_ms = []() {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
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<WaNode> 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<WaNode> 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<std::string> WaClient::usync_devices(const std::vector<std::string>& 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
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<void(const std::vector<std::string>& qr_codes)> on_qr; // registration: QR strings to show
|
||||
std::function<void(const std::string& jid)> on_paired; // pairing completed, device saved
|
||||
std::function<void(const std::string& text)> on_log; // human-readable progress
|
||||
std::function<void()> 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 <failure> node
|
||||
uint64_t iq_counter_{0};
|
||||
std::string iq_prefix_; // "<byte0>.<byte1>-" 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<WaNode> 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<WaNode> 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<WaNode> 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<std::string> usync_devices(const std::vector<std::string>& jids);
|
||||
// ensure a Signal session exists for each device jid (fetch prekeys if not)
|
||||
|
||||
@@ -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<uint8_t, 32> 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
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "wa_iq.h"
|
||||
|
||||
#include <charconv>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
|
||||
#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<WaNode> error_node, std::optional<WaNode> 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<WaNode> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#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<WaNode> 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 <error> 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<WaNode> error_node, std::optional<WaNode> 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<WaNode> error_node_;
|
||||
std::optional<WaNode> raw_;
|
||||
};
|
||||
|
||||
// The stream died before a reply arrived (whatsmeow DisconnectedError).
|
||||
class WaDisconnectedError {
|
||||
public:
|
||||
explicit WaDisconnectedError(std::string action, std::optional<WaNode> 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<WaNode> node_;
|
||||
};
|
||||
|
||||
class WaIq {
|
||||
public:
|
||||
// Assemble the iq stanza (whatsmeow sendIQAsyncAndGetData attributes).
|
||||
static WaNode build(const WaIqQuery& q);
|
||||
// Classify a reply (whatsmeow parseIQError): reads the <error> 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
|
||||
// "<byte0>.<byte1>-" prefix plus a counter.
|
||||
static std::string generate_id(const std::string& prefix, uint64_t counter);
|
||||
static std::string new_prefix();
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "wa_keepalive.h"
|
||||
|
||||
#include <random>
|
||||
|
||||
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<Ms> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <functional>
|
||||
|
||||
#include "wa_consts.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
class WaKeepAlive {
|
||||
public:
|
||||
using Ms = int64_t;
|
||||
using IntervalFn = std::function<Ms()>; // 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
|
||||
@@ -17,11 +17,17 @@ Bytes WaFraming::frame(const Bytes& payload, bool with_header) {
|
||||
}
|
||||
|
||||
std::optional<Bytes> 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<Bytes> 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<uint32_t>(buf[pos]) << 16) | (static_cast<uint32_t>(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<uint8_t, 32>& priv, const std::array<uint8_t, 32>& 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::HandshakeResult> WaNoise::client_handshake(const std::arr
|
||||
mix_hash(sh->ephemeral);
|
||||
std::array<uint8_t, 32> 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<uint8_t, 32> 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);
|
||||
|
||||
@@ -19,6 +19,7 @@ class WaFraming {
|
||||
public:
|
||||
static constexpr std::array<uint8_t, 4> 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<uint8_t, 32>& 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<uint8_t, 32>& priv, const std::array<uint8_t, 32>& pub);
|
||||
|
||||
// transport mode (after handshake): encrypt/decrypt a frame payload.
|
||||
Bytes transport_encrypt(const Bytes& plaintext);
|
||||
std::optional<Bytes> transport_decrypt(const Bytes& ciphertext);
|
||||
|
||||
@@ -307,17 +307,17 @@ std::optional<WaCertChainParsed> 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<WaNoiseCertDetails> 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<uint32_t>(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<uint32_t>(f.varint);
|
||||
else if (f.field == 2) o.issuer_serial = static_cast<uint32_t>(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;
|
||||
}
|
||||
|
||||
@@ -138,9 +138,14 @@ struct WaADVDeviceIdentity {
|
||||
std::optional<WaADVDeviceIdentity> 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;
|
||||
|
||||
@@ -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<CURL*>(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<long>(timeout_seconds));
|
||||
curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, static_cast<long>(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<CURL*>(handle_);
|
||||
size_t off = 0;
|
||||
while (off < framed.size()) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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 <error> 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)
|
||||
}
|
||||
}
|
||||
@@ -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"]}]}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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 "<byte0>.<byte1>-<counter>".
|
||||
check(WaIq::generate_id("42.7-", 9) == "42.7-9", "request id is prefix + counter");
|
||||
std::string pfx = WaIq::new_prefix();
|
||||
check(pfx.size() >= 5 && pfx.back() == '-' && pfx.find('.') != std::string::npos,
|
||||
"request prefix has the <n>.<m>- shape");
|
||||
check(WaIq::new_prefix().size() >= 5, "a second prefix draws cleanly");
|
||||
// Disconnect predicates on the oracle's disconnect nodes.
|
||||
const Value& dec = d.get("decode");
|
||||
auto by_name = [&](const std::string& name) {
|
||||
for (const auto& v : dec.items())
|
||||
if (v.get("name").str() == name) return WaBinary::decode(WaBinary::unpack(fromhex(v.get("wire_hex").str())));
|
||||
return WaNode("missing");
|
||||
};
|
||||
WaNode e401 = by_name("stream_error_401"), conf = by_name("stream_error_conflict"),
|
||||
e515 = by_name("stream_error_515"), pong = by_name("iq_result_ping");
|
||||
check(WaIq::is_disconnect_node(e401) && WaIq::is_disconnect_node(conf) && WaIq::is_disconnect_node(e515) &&
|
||||
!WaIq::is_disconnect_node(pong),
|
||||
"disconnect nodes recognized (stream:error yes, iq result no)");
|
||||
check(WaIq::is_auth_error_disconnect(e401) && WaIq::is_auth_error_disconnect(conf) &&
|
||||
!WaIq::is_auth_error_disconnect(e515) && !WaIq::is_auth_error_disconnect(pong),
|
||||
"auth disconnects (401, replaced) separated from the 515 restart");
|
||||
WaNode xml_end("xmlstreamend");
|
||||
check(WaIq::is_disconnect_node(xml_end) && !WaIq::is_auth_error_disconnect(xml_end),
|
||||
"xmlstreamend disconnects without being an auth error");
|
||||
// Error taxonomy mirrors whatsmeow errors.go.
|
||||
WaIqError e404 = WaIq::parse_error(by_name("iq_error_404"));
|
||||
check(e404.is(WaIqError::not_found()) && !e404.is(WaIqError::internal_server_error()),
|
||||
"parsed 404 is ErrIQNotFound, not ErrIQInternalServerError");
|
||||
check(WaIqError::timed_out().message() == "info query timed out", "timeout message matches ErrIQTimedOut");
|
||||
check(WaIqError::not_found().message() == "info query returned status 404: item-not-found",
|
||||
"status message matches IQError.Error");
|
||||
check(WaDisconnectedError::for_iq().message() == "websocket disconnected before info query returned response",
|
||||
"disconnect message matches DisconnectedError.Error");
|
||||
check(WaDisconnectedError::for_iq().is(WaDisconnectedError("info query")) &&
|
||||
!WaDisconnectedError::for_iq().is(WaDisconnectedError("message send")),
|
||||
"disconnect equivalence is by action");
|
||||
WaIqError bare = WaIq::parse_error(by_name("iq_error_bare"));
|
||||
check(bare.code() == 0 && !bare.has_error_node(), "a result without <error> parses to code 0");
|
||||
// Hostile shapes: never throw, never overflow, code reads 0.
|
||||
WaNode evil("iq");
|
||||
evil.content = WaNode::Children{WaNode("error")};
|
||||
WaNode* child = const_cast<WaNode*>(evil.child("error"));
|
||||
child->attrs["code"] = "not-a-number";
|
||||
check(WaIq::parse_error(evil).code() == 0, "a non-numeric error code reads 0");
|
||||
child->attrs["code"] = "99999999999999999999999";
|
||||
check(WaIq::parse_error(evil).code() == 0, "an overflowing error code reads 0, no UB");
|
||||
child->attrs.erase("code");
|
||||
child->attrs["text"] = std::string(70000, 'x');
|
||||
check(WaIq::parse_error(evil).code() == 0, "a missing code with a huge text reads 0");
|
||||
// Socket guards fail loudly without any network.
|
||||
bool threw = false;
|
||||
try {
|
||||
WaSocket s;
|
||||
s.send_frame(Bytes{1, 2, 3});
|
||||
} catch (const WaSocketError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "sending on an unconnected socket throws WaSocketError");
|
||||
WaSocketStatusError with_status("connect: refused", 403);
|
||||
check(with_status.status() == 403 && std::string(with_status.what()).find("403") != std::string::npos,
|
||||
"the status-carrying socket error keeps its code (ErrWithStatusCode role)");
|
||||
}
|
||||
|
||||
// ---- M1: keepalive schedule (deterministic clock) ----
|
||||
static void test_keepalive() {
|
||||
std::printf("[keepalive]\n");
|
||||
check(WaConsts::kWaKeepAliveResponseDeadline == 10 && WaConsts::kWaKeepAliveIntervalMin == 20 &&
|
||||
WaConsts::kWaKeepAliveIntervalMax == 30 && WaConsts::kWaKeepAliveMaxFailTime == 180,
|
||||
"keepalive caps equal whatsmeow (10s deadline, 20-30s jitter, 3min horizon)");
|
||||
WaKeepAlive ka([]() { return 25000; });
|
||||
ka.start(100000);
|
||||
check(!ka.due(100000) && !ka.due(124999) && ka.due(125000), "first ping due after the interval");
|
||||
check(ka.error_count() == 0, "no errors at start");
|
||||
ka.record_pong(125000);
|
||||
check(ka.error_count() == 0 && ka.last_success_ms() == 125000 && !ka.due(149999) && ka.due(150000),
|
||||
"a pong clears errors and advances the schedule");
|
||||
check(!ka.record_timeout(150000) && ka.error_count() == 1, "one unanswered ping counts, no reconnect yet");
|
||||
check(!ka.record_timeout(175000) && ka.error_count() == 2, "errors accumulate across pings");
|
||||
ka.record_pong(200000);
|
||||
check(ka.error_count() == 0 && ka.last_success_ms() == 200000, "a later pong recovers the streak");
|
||||
// Failures spanning more than 3 minutes force a reconnect (whatsmeow KeepAliveMaxFailTime).
|
||||
WaKeepAlive dying([]() { return 25000; });
|
||||
dying.start(0);
|
||||
bool forced = false;
|
||||
for (int64_t t = 25000; t <= 400000 && !forced; t += 25000) forced = dying.record_timeout(t);
|
||||
check(forced && dying.error_count() == 8, "8 unanswered pings (200s of failure) force a reconnect");
|
||||
WaKeepAlive edge([]() { return 25000; });
|
||||
edge.start(0);
|
||||
bool early = true;
|
||||
for (int i = 0; i < 7; ++i) early = early && !edge.record_timeout(25000 + i * 25000);
|
||||
check(early && edge.record_timeout(200000), "the horizon trips past 180s, not before");
|
||||
// The default jitter always stays inside 20-30s.
|
||||
bool in_range = true;
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
WaKeepAlive probe;
|
||||
probe.start(0);
|
||||
int64_t wait = probe.next_due_ms();
|
||||
in_range = in_range && wait >= 20000 && wait <= 30000;
|
||||
}
|
||||
check(in_range, "default jitter stays inside 20-30s over 200 draws");
|
||||
check(WaConsts::kWaIqDefaultTimeout == 75, "default iq wait is whatsmeow defaultRequestTimeout (75s)");
|
||||
}
|
||||
|
||||
// ---- M1: Noise parity + server certificate (whatsmeow cert_oracle.go) ----
|
||||
static void test_server_cert() {
|
||||
std::printf("[server-cert]\n");
|
||||
Value d = Value::loads(slurp(path("cert_vectors.json")));
|
||||
Value n = Value::loads(slurp(path("noise.json")));
|
||||
// Re-run the handshake over the wa-go trace: our decrypted bytes must equal
|
||||
// whatsmeow's own NoiseHandshake output from the oracle.
|
||||
auto ephPriv = a32(fromhex(n.get("ephemeralKeyPriv").str()));
|
||||
auto ephPub = a32(fromhex(n.get("ephemeralKeyPub").str()));
|
||||
auto stPriv = a32(fromhex(n.get("authStaticKeyPriv").str()));
|
||||
auto stPub = a32(fromhex(n.get("authStaticKeyPub").str()));
|
||||
Bytes shFrame = fromhex(n.get("serverHelloFrameHex").str());
|
||||
size_t pos = 0;
|
||||
auto sh = WaFraming::unframe(shFrame, pos);
|
||||
WaNoise noise(ephPub);
|
||||
auto res = noise.client_handshake(ephPriv, ephPub, stPriv, stPub, *sh, Bytes{1, 2, 3, 4});
|
||||
check(res.has_value(), "handshake completes (mix_shared_secret refactor)");
|
||||
check(res && tohex(res->server_static) == d.get("server_static_hex").str(),
|
||||
"server static equals whatsmeow NoiseHandshake output");
|
||||
check(res && tohex(res->server_payload) == d.get("server_payload_hex").str(),
|
||||
"server payload equals whatsmeow NoiseHandshake output (241 bytes)");
|
||||
// Details fields match whatsmeow's waCert parse field by field.
|
||||
auto chain = parse_cert_chain(fromhex(d.get("server_payload_hex").str()));
|
||||
auto im = parse_cert_details(chain->intermediate_details);
|
||||
auto leaf = parse_cert_details(chain->leaf_details);
|
||||
const Value& oi = d.get("intermediate");
|
||||
const Value& ol = d.get("leaf");
|
||||
check(im && im->serial == (uint32_t)oi.get("serial").as_int(0) &&
|
||||
im->issuer_serial == (uint32_t)oi.get("issuer_serial").as_int(0) &&
|
||||
tohex(im->key) == oi.get("key_hex").str() &&
|
||||
im->not_before == (uint64_t)oi.get("not_before").as_int(0) &&
|
||||
im->not_after == (uint64_t)oi.get("not_after").as_int(0),
|
||||
"intermediate details match whatsmeow (serial 3, issuer 0, key, window)");
|
||||
check(leaf && leaf->serial == (uint32_t)ol.get("serial").as_int(0) &&
|
||||
leaf->issuer_serial == (uint32_t)ol.get("issuer_serial").as_int(0) &&
|
||||
tohex(leaf->key) == ol.get("key_hex").str() &&
|
||||
leaf->not_before == (uint64_t)ol.get("not_before").as_int(0) &&
|
||||
leaf->not_after == (uint64_t)ol.get("not_after").as_int(0),
|
||||
"leaf details match whatsmeow (serial 354, issuer 3, key, window)");
|
||||
Bytes pubkey(WaConsts::kWaCertPubKey.begin(), WaConsts::kWaCertPubKey.end());
|
||||
check(tohex(pubkey) == d.get("wacert_pubkey_hex").str(), "pinned key equals whatsmeow WACertPubKey");
|
||||
check(WaConsts::kWaCertIssuerSerial == 0, "pinned issuer serial equals whatsmeow WACertIssuerSerial");
|
||||
// The captured leaf is expired today, so travel inside its window: every
|
||||
// check must pass there (the oracle confirms the signatures are genuine).
|
||||
Bytes payload = fromhex(d.get("server_payload_hex").str());
|
||||
Bytes server_static = fromhex(d.get("server_static_hex").str());
|
||||
int64_t mid = (int64_t)(((uint64_t)ol.get("not_before").as_int(0) + (uint64_t)ol.get("not_after").as_int(0)) / 2);
|
||||
bool accepted = false;
|
||||
try {
|
||||
WaServerCert::verify(payload, server_static, mid);
|
||||
accepted = true;
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
check(accepted, "a genuine chain verifies inside its validity window");
|
||||
bool expired = false;
|
||||
try {
|
||||
WaServerCert::verify(payload, server_static);
|
||||
} catch (const WaCertError& e) {
|
||||
expired = std::string(e.what()).find("expired") != std::string::npos;
|
||||
}
|
||||
check(expired, "the same chain is refused as expired today");
|
||||
// Tamper with every verified property: each must fail closed with a reason.
|
||||
auto tampers = [&](const std::string& name, const Bytes& bad_payload, const Bytes& bad_static) {
|
||||
bool threw = false;
|
||||
try {
|
||||
WaServerCert::verify(bad_payload, bad_static, mid);
|
||||
} catch (const WaCertError&) {
|
||||
threw = true;
|
||||
} catch (const std::length_error&) {
|
||||
threw = false;
|
||||
}
|
||||
check(threw, "tampered chain refused: " + name);
|
||||
};
|
||||
Bytes bad_sig = payload;
|
||||
bad_sig[bad_sig.size() - 1] ^= 0x01;
|
||||
tampers("flipped signature byte", bad_sig, server_static);
|
||||
Bytes bad_static = server_static;
|
||||
bad_static[0] ^= 0x01;
|
||||
tampers("wrong server static", payload, bad_static);
|
||||
tampers("truncated chain", Bytes(payload.begin(), payload.begin() + payload.size() / 2), server_static);
|
||||
tampers("empty chain", Bytes{}, server_static);
|
||||
tampers("empty static", payload, Bytes{});
|
||||
tampers("garbage chain", Bytes(241, 0xab), server_static);
|
||||
// Hostile protobuf inside the chain: oversized lengths and overlong varints
|
||||
// must throw a handled error, never length_error, never UB.
|
||||
bool hostile_ok = true;
|
||||
for (const Bytes& evil : {Bytes{0x0a, 0xff, 0xff, 0xff, 0xff, 0x0f}, Bytes{0x12, 0x80, 0x80, 0x80, 0x80,
|
||||
0x80, 0x80, 0x80, 0x80, 0x80, 0x7f},
|
||||
Bytes{0x0a}, Bytes{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f}}) {
|
||||
try {
|
||||
WaServerCert::verify(evil, server_static, mid);
|
||||
} catch (const std::length_error&) {
|
||||
hostile_ok = false;
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
}
|
||||
check(hostile_ok, "hostile chain bytes rejected without length_error");
|
||||
// A swapped leaf/intermediate must not verify (regression: the fields were swapped).
|
||||
WaCertChainParsed swapped;
|
||||
swapped.leaf_details = chain->intermediate_details;
|
||||
swapped.leaf_signature = chain->intermediate_signature;
|
||||
swapped.intermediate_details = chain->leaf_details;
|
||||
swapped.intermediate_signature = chain->leaf_signature;
|
||||
// Re-encode the swap through the writer to feed verify() bytes.
|
||||
ProtoWriter wl, wi, wc;
|
||||
wl.message(1, swapped.leaf_details);
|
||||
wl.bytes(2, swapped.leaf_signature);
|
||||
wi.message(1, swapped.intermediate_details);
|
||||
wi.bytes(2, swapped.intermediate_signature);
|
||||
wc.message(1, wl.take());
|
||||
wc.message(2, wi.take());
|
||||
bool swap_threw = false;
|
||||
try {
|
||||
WaServerCert::verify(wc.take(), server_static, mid);
|
||||
} catch (const WaCertError&) {
|
||||
swap_threw = true;
|
||||
}
|
||||
check(swap_threw, "a leaf/intermediate swap is refused");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
+75
-52
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user