whatsapp: fix link/login 401, add image send, contact sync and search; harden failover
Root causes, each reproduced against the live server or endpoint: - Server-only JIDs decoded as "@s.whatsapp.net" and pairing replies copied them into `to`, so the device was never registered and every login returned 401. Decoder/encoder fixed, replies use s.whatsapp.net, ADV hosted prefixes follow whatsmeow. The link wizard now waits for the restart, logs in, uploads prekeys and retries once with a fresh device on 401/403. - Closed sockets made every receive loop spin until its deadline (the 7 minute CPU burn). Loops stop on a closed stream. - whatsapp_send had no image argument and send_image was a stub. Added WaMedia (byte-exact against 8 wa-go golden vectors), media_conn upload, image send and the DeviceSentMessage copy for the user's other devices. - Phone contacts never reached the client: added app-state sync (WaAppState, WaReceiver, LTHash and MAC checks verified against a whatsmeow-built oracle) and the historySyncConfig declaration at registration. - Contact lookup: FTS5 trigram plus fuzzy ranking, ambiguity is reported and never guessed, global search lists contacts. Send caps (10/min, 200/day) are enforced and survive unlink. Failover and agent: - Every failed backend candidate is printed the moment it fails. - muse runs with low reasoning effort, an empty preset and one stable workspace (36.5s down to 2.8s on the real prompt); Ctrl-C no longer counts as a backend failure; CLI backends use the circuit breaker; opencode runs are capped at 30s. - Orphan tool results are dropped before sending (devplace HTTP 400, reproduced); compaction no longer starts its window on a tool result. - Terminal art pasted as a prompt is refused before any model call. Tests: whatsapp_test, muse_flags_test, llm_history_test; full suite 40 steps (agent.e2e reads live tmux panes and is flaky, green on re-run). Research archived under research/whatsapp-automation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+5
-3
@@ -7,8 +7,8 @@
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Subjects | 18 |
|
||||
| Total source documents | 1181 |
|
||||
| — Tier 1 (Source Dossier, independently analyzed) | 309 |
|
||||
| Total source documents | 1183 |
|
||||
| — Tier 1 (Source Dossier, independently analyzed) | 311 |
|
||||
| — Tier 2 (Source Citation Card, listed only) | 872 |
|
||||
| Total conclusion documents | 45 |
|
||||
| Archive opened | 2026-10-09 |
|
||||
@@ -33,7 +33,7 @@
|
||||
| [`cap-protocol`](cap-protocol/) | 83 (34 / 49) | 4 | 2026-10-18 | 2026-10-25 | Supporting CAP (CLI Agent Protocol) as agent and orchestrator: full read of core + coding profile, quickstart, STATUS, all 5 manifests; selective A2A/ACP wire reads; decided phased rollout without gRPC plus dual A2A spelling; extended 2026-10-25 with model handling, model-as-tool and skill-management research (ACP config options, MCP sampling, agents-as-tools, advisor tools, subagent and skill docs, RouteLLM), 2 recommendation-only conclusions, nothing built |
|
||||
| [`mcp-client-support`](mcp-client-support/) | 106 (38 / 68) | 3 | 2026-10-18 | 2026-10-18 | Designing tai's MCP client: wire spec (stdio + Streamable HTTP on the 2025-11-25 baseline), pasted-config/GitHub-URL resolver, and the trust/approval model; decided minimal stdlib client, normalizer plus resolver agent, untrusted-until-consent with description pinning, no OAuth in v1, nothing built yet |
|
||||
| [`fuzzy-matching-selectors`](fuzzy-matching-selectors/) | 65 (12 / 53) | 2 | 2026-10-25 | 2026-10-25 | Making `@agent` / `@model` mentions pick the most related candidate: fzf/fzy/Sublime/clangd subsequence scoring, Damerau-Levenshtein, pg_trgm, RapidFuzz WRatio, difflib, CPython suggestions surveyed; decided one generic `FuzzyMatcher` (four signals, deterministic ties, floor 0.3 across agents and models) and implemented it the same day |
|
||||
| [`whatsapp-automation`](whatsapp-automation/) | 70 (9 / 61) | 3 | 2026-10-27 | 2026-10-27 | Making detz send WhatsApp messages from C++ on a personal number: no native Python library exists (neonize is whatsmeow over ctypes, yowsup is dead), no browser needed, decided a native C++ transpile from wa-go (MIT, golden vectors) with whatsmeow as referee, GPL libsignal avoided, plus a whatsapp-mcp shortcut; specified in taicpp/docs/whatsapp.md, nothing built |
|
||||
| [`whatsapp-automation`](whatsapp-automation/) | 75 (14 / 61) | 7 | 2026-10-27 | 2026-10-28 | Making detz send WhatsApp messages from C++ on a personal number: no native Python library exists (neonize is whatsmeow over ctypes, yowsup is dead), no browser needed, decided a native C++ transpile from wa-go (MIT, golden vectors) with whatsmeow as referee, GPL libsignal avoided, plus a whatsapp-mcp shortcut; specified in taicpp/docs/whatsapp.md, nothing built |
|
||||
| [`safe-cpp-practices`](safe-cpp-practices/) | 45 (8 / 37) | 2 | 2026-10-27 | 2026-10-27 | How to write safe, fast, OO production C++ while keeping dev speed, triggered by a `std::vector larger than max_size()` crash in the WhatsApp wire parser; decided a four-layer playbook (overflow-safe checked-reader parsing, cheap always-on release hardening, an ASan+UBSan+_GLIBCXX_ASSERTIONS test net, and frame capture), no new dependency; applied to detz::wa and documented in docs/cpp-safety.md. The sanitizer net prescribed by the research then caught a second UB (a varint shift >= 64 bits) that manual review had missed twice |
|
||||
| [`terminal-screenshot-rendering`](terminal-screenshot-rendering/) | 99 (10 / 89) | 2 | 2026-10-27 | 2026-10-27 | Making detz take a PNG of the current terminal on a headless server for WhatsApp: surveyed freeze, termshot, textimg, libansilove, agg, aha, silicon, tmux capture-pane -e, bitmap fonts (Spleen, Terminus, Unifont, X11 fixed) and how Baileys, whatsmeow and whatsapp-rust set image mimetypes; decided an in-process renderer (tmux escapes, TuiVtScreen, embedded Spleen 8x16, zlib PNG) and image/png without a JPEG encoder; implemented the same session |
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
| 2026-10-25 | `fuzzy-matching-selectors` | New subject: 65 sources (12 Tier 1 / 53 Tier 2) + 2 conclusions, from 10 searches and 14 fetches on fuzzy string matching (fzf algo.go and man page, fzy ALGORITHM.md and match.c, Sublime fuzzy match via an AsciiDoc copy because the original blog returned 403, clangd FuzzyMatch, pg_trgm docs, RapidFuzz WRatio source, Jaro-Winkler and Damerau-Levenshtein articles, difflib docs, CPython traceback suggestions). Decisions: one generic `detz::FuzzyMatcher` (subsequence DP with fzf constants + restricted Damerau-Levenshtein + per-token typo + trigram Jaccard, deterministic shorter-then-earlier ties) and a fuzzy catalog resolution order with floor 0.3. Implemented the same session in taicpp (src/util/fuzzy.*, src/cap/cap_model_catalog.cpp, tests/fuzzy_test.cpp). Carried gaps: VS Code fuzzyScorer.ts never retrieved; fzy constants not retrieved (fzf's used); no measurement on a real model-id corpus; the Jaro-Winkler prefix-inflation claim rests on a search listing only. |
|
||||
| 2026-10-25 | `cap-protocol` | Extended with 59 sources (11 Tier 1 / 48 Tier 2) + 2 conclusions (`cap-model-handling-practice`, `agent-delegation-tool-plus-skill`), from 10 searches and 15 fetches on how CAP and neighboring protocols treat models and cost, and whether delegation should be a tool plus a skill. Both conclusions are recommendations pending retoor, not decisions (their Decided by field says so). Two existing dossiers (`cap-core-v1`, `cap-profile-coding-v1`) were forward-corrected with model-handling findings. Carried gaps: Claude Code sub-agent and skills pages read only to character 100000; advisor benefit rests on one unverified transcript; the A2A searches found no model metadata field; CAP STATUS and the OpenAI handoffs page were not re-read. Nothing built. |
|
||||
| 2026-10-25 | `background-work-limits` | Extended with 46 sources (5 Tier 1 / 41 Tier 2) + 2 conclusions (`sub-agent-panels-on-the-multiplexer`, `sub-agent-supervision-and-collection`), from 8 searches and 6 fetches on how parallel agents are shown, scheduled, timed out, monitored and how their results are collected (Claude Code agent teams, Temporal activity timeouts, the claim-check pattern, Celery worker limits, tmux layouts, Kubernetes termination, terminal streaming, parallel-agent dashboards). Implemented the same session in taicpp (feed panes, `src/orch/`, supervision, CAP collection) and verified live. Carried gaps: the Kubernetes pod lifecycle page was opened but its termination and probe sections were not returned, so it is a Tier 2 card and its numbers are unverified; the tmux manual was read to character 100000 of 278260 (options unread); the 60 s and 300 s idle limits are static judgments, not measurements. |
|
||||
| 2026-10-28 | `whatsapp-automation` | Added the link/image/contacts incident: 3 Tier 1 dossiers (whatsmeow pair.go with the oxidezap #1439/#1440 prefix audit; media upload with the wa-go golden vectors; DeviceProps historySyncConfig) + 2 conclusions (pairing replies go to the server JID and server-only JIDs decode bare; registration declares historySyncConfig). Implemented in taicpp src/whatsapp; image crypto verified byte-exact offline, send acknowledged by the live server, address-book sync still waiting on a re-link made with this build. |
|
||||
| 2026-10-27 | `whatsapp-automation` | Added app-state sync (contact list): 2 Tier 1 dossiers (whatsmeow appstate package read line by line; key share and peer message protos and send path) + 2 conclusions (LTHash is HKDF-SHA256 as in whatsmeow, not the BLAKE2b the brief suggested; verification against a Go oracle built from whatsmeow's own packages). Implemented in taicpp src/whatsapp (wa_appstate, wa_receiver), not yet verified against a live phone. |
|
||||
| 2026-10-27 | `whatsapp-automation` | New subject: 70 sources (9 Tier 1 / 61 Tier 2) + 3 conclusions, from 12 searches, 9 repositories shallow-cloned and read (whatsmeow, libsignal-protocol-go, wa-go, Baileys, neonize, whatsapp-rust, whatsapp-mcp, yowsup, libsignal-protocol-c), line counts and GitHub API activity measured, and one live check (libcurl got 101 from wss://web.whatsapp.com/ws/chat). Mirror and language copies of one page are logged as one card each, with every URL named in the card. Decisions: no native Python library exists and no browser is needed; native C++ `detz::wa` transpiled from wa-go (MIT) with whatsmeow (MPL-2.0) as referee and GPL-3.0 libsignal avoided; whatsapp-mcp through detz's MCP client as the two-day shortcut. Specified in taicpp/docs/whatsapp.md, nothing built. Carried gaps: an unpaired whatsmeow QR probe was refused by the session permission classifier, so the full handshake is unverified here; XEdDSA implementation, LID addressing, client-version policy and ban behaviour are open deep-research items. |
|
||||
| 2026-10-27 | `terminal-screenshot-rendering` | New subject: 99 sources (10 Tier 1 / 89 Tier 2) + 2 conclusions, from 12 searches, 5 page fetches, the Spleen BDF and LICENSE download, 3 source files read in the cloned Baileys, whatsmeow and whatsapp-rust repositories, the local console-setup font copyright file, and live tmux 3.6 captures. Mirror and version copies of one page are one card each, with the URL count in the card title. Decisions: render in-process (capture-pane -e -p -N, TuiVtScreen, embedded Spleen 8x16 BSD-2, zlib PNG); send as image/png, no JPEG encoder. Implemented in taicpp (src/termshot/, terminal_screenshot tool, tests/termshot_test.cpp, docs/termshot.md) and verified with a real model calling the tool. Carried gaps: glyphs outside Spleen render as '?', wide characters take one column, the native plexer path and a live WhatsApp send of image/png are unverified. |
|
||||
| 2026-10-27 | `safe-cpp-practices` | New subject: 45 sources (8 Tier 1 / 37 Tier 2) + 2 conclusions, from 6 extended web searches on safe production C++ (OpenSSF compiler hardening, C++ Core Guidelines bounds/GSL/narrow_cast, std::span no-default-bounds-check, integer-overflow size checks, libFuzzer+ASan/UBSan CI, sanitizer overhead ladder, clang-tidy ratchet, GCC -fhardened). Triggered by incident WA-2026-10-27, the `std::vector larger than max_size()` crash in the WhatsApp wire parser. Decisions: a four-layer playbook (overflow-safe checked-reader parsing with `len <= size - pos`; cheap always-on release hardening; an ASan+UBSan+_GLIBCXX_ASSERTIONS test net via tools/san-whatsapp.sh; frame capture as a regression corpus), no new runtime dependency. Applying the research's own prescription (the sanitizer net) immediately caught a second, independent UB (a varint shift of up to 70 bits) that two manual reviews had missed; fixed and guarded. Documented in docs/cpp-safety.md, docs/whatsapp.md section 13, AGENTS.md. Carried gaps: the exact live frame from the user is still unreproduced (capture hook added); clang-tidy narrowing-conversion ratchet not yet wired; ed25519 ref10 excluded from UBSan for intentional shift idioms. |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Conclusion: app-state LTHash uses HKDF-SHA256, not BLAKE2b
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Conclusion |
|
||||
| Date | 2026-10-27 |
|
||||
| Researched by | tai (agent), model: Claude Haiku 5.5, on behalf of retoor |
|
||||
| Based on | whatsmeow-appstate-sync.md (Tier 1) |
|
||||
| Status | Active |
|
||||
|
||||
## Decision
|
||||
|
||||
The LTHash of the critical_unblock_low collection is HKDF-SHA256 with info `WhatsApp Patch Integrity` and 128 bytes of output, as whatsmeow implements it. BLAKE2b is not used.
|
||||
|
||||
## Why
|
||||
|
||||
The task brief suggested BLAKE2b (EVP_blake2b512). whatsmeow, the reference the project follows, does not use BLAKE2b for this hash; its `appstate/lthash` package calls `hkdfutil.SHA256`. A BLAKE2b LTHash would produce different state, every MAC check would fail against the real phone, and the sync would be silently useless. The reference wins where it disagrees with a brief.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `WaLtHash` in src/whatsapp/wa_appstate.cpp uses `WaCrypto::hkdf_sha256`; no BLAKE2b is needed.
|
||||
- The golden vector (tests/whatsapp_data/appstate_vectors.json, produced by whatsmeow's own lthash package) pins the choice.
|
||||
- If whatsmeow ever changes this function, the vector test fails and the decision must be revisited with a new dossier.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-27 | Conclusion recorded. |
|
||||
@@ -0,0 +1,31 @@
|
||||
# Conclusion: verify app-state sync against whatsmeow's own code, offline
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Conclusion |
|
||||
| Date | 2026-10-27 |
|
||||
| Researched by | tai (agent), model: Claude Haiku 5.5, on behalf of retoor |
|
||||
| Based on | whatsmeow-appstate-sync.md, whatsmeow-key-share-and-peer-messages.md (both Tier 1) |
|
||||
| Status | Active |
|
||||
|
||||
## Decision
|
||||
|
||||
Golden vectors for the app-state sync are generated by a small Go program (tests/whatsapp_data/appstate_oracle.go) that imports whatsmeow's `appstate/lthash`, `util/hkdfutil` and protobuf packages and copies the MAC and key formulas verbatim from the appstate package. The C++ tests check the decoder against those bytes. No hand-written expectation is used for MACs or hashes.
|
||||
|
||||
## Why
|
||||
|
||||
The package has no test vectors, so a self-consistency test (add then remove) cannot catch a wrong formula. Running the reference code is the only oracle available offline. It does not prove the server accepts our patches; that needs a re-linked phone.
|
||||
|
||||
## Limits
|
||||
|
||||
The oracle covers the decode and hash math and the protobuf shapes. It does not cover the live server, the external blob download, or the key share timing.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-27 | Conclusion recorded. |
|
||||
@@ -0,0 +1,31 @@
|
||||
# Conclusion: registration declares historySyncConfig so the phone sends history and app-state keys
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Conclusion |
|
||||
| Date | 2026-10-28 |
|
||||
| Based on | whatsmeow-device-props-history-sync.md (Tier 1), whatsmeow-key-share-and-peer-messages.md (Tier 1) |
|
||||
| Date researched | 2026-10-28 |
|
||||
| Researched by | tai (agent), model: Claude Sonnet 5.5, on behalf of retoor |
|
||||
| Status | Active |
|
||||
|
||||
## Decision
|
||||
|
||||
WaDeviceProps::encode always includes historySyncConfig with the values of the reference client, and the peer key request is encrypted to the phone LID identity.
|
||||
|
||||
## Why
|
||||
|
||||
After a correct link, a key request got a server acknowledgement but no answer and no key share in 80 s; the registration carried no history declaration, which the reference client always sends. This is the most likely cause and the only difference found; it is verified offline (DeviceProps bytes) and needs a live link made with this build to confirm.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing links must be redone (--whatsapp-unlink, --whatsapp-link).
|
||||
- If the phone still sends nothing, the next step is a packet-level comparison with a whatsmeow session.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-28: created.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Conclusion: pairing replies are addressed to the server JID, and server-only JIDs decode as the bare server
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Conclusion |
|
||||
| Date | 2026-10-28 |
|
||||
| Based on | whatsmeow-pair-success.md (Tier 1) |
|
||||
| Date researched | 2026-10-28 |
|
||||
| Researched by | tai (agent), model: Claude Sonnet 5.5, on behalf of retoor |
|
||||
| Status | Active |
|
||||
|
||||
## Decision
|
||||
|
||||
The pair-device ack and the pair-device-sign reply use to="s.whatsapp.net" (WaConsts::kServerJid). The binary decoder returns a JID pair with an empty user as the bare server name, and the encoder writes bare server names back as JID pairs.
|
||||
|
||||
## Why
|
||||
|
||||
The decoder printed such JIDs as "@s.whatsapp.net"; copying the incoming from into to produced a reply the server refused, the device was never registered, and login returned failure 401. Reproduced: a captured pair-success decoded with from=@s.whatsapp.net; live login 401 before the change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Regression test "server-only JID round-trips as s.whatsapp.net" in tests/whatsapp_test.cpp.
|
||||
- The ADV prefix rules (hosted HMAC and account prefix, device prefix always [6,1]) follow the reference and the Rust audit.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-10-28: created.
|
||||
@@ -0,0 +1,58 @@
|
||||
# whatsmeow appstate package (app-state sync and LTHash)
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Source Dossier |
|
||||
| Tier | 1 |
|
||||
| Source title | whatsmeow, package appstate (appstate.go, appstate/decode.go, appstate/keys.go, appstate/hash.go, appstate/lthash/lthash.go, appstate/errors.go, appstate/recovery.go) and download.go |
|
||||
| Source URL | https://github.com/tulir/whatsmeow (paths under main: appstate.go, appstate/decode.go, appstate/keys.go, appstate/hash.go, appstate/lthash/lthash.go, download.go) |
|
||||
| Source publisher / author | Tulir Asokan and contributors |
|
||||
| Source type | repository |
|
||||
| Source date (published) | Unknown — not stated (snapshot of main fetched 2026-10-27, Go module version v0.0.0-20261027104441-1eec1136a50b) |
|
||||
| Date researched | 2026-10-27 |
|
||||
| Researched by | tai (agent), model: Claude Haiku 5.5, on behalf of retoor |
|
||||
| Research method | direct page fetch (raw.githubusercontent.com) and go module download for an executable oracle |
|
||||
| Confidence | High for the formulas (read line by line and reproduced byte-exact by an oracle built from the same package); Medium for behaviour the server exercises that we cannot see from source |
|
||||
| Status | Active |
|
||||
|
||||
## Summary
|
||||
|
||||
whatsmeow implements WhatsApp's app-state sync as a chain of patches over a collection (here `critical_unblock_low`, whose `contact` index carries the phone's address book). Each collection state is a 128-byte LTHash plus a version. Records are AES-256-CBC encrypted with keys derived by HKDF-SHA256 from 32-byte sync keys, and every record and patch is authenticated with HMAC (SHA-512 truncated for value MACs, SHA-256 for index, snapshot and patch MACs). The source is explicit about every formula, so the whole path can be ported without guessing.
|
||||
|
||||
## Key findings
|
||||
|
||||
- Key expansion: HKDF-SHA256, empty salt, info `WhatsApp Mutation Keys`, 160 bytes, split into index, value encryption, value MAC, snapshot MAC and patch MAC (32 bytes each), in that order (appstate/keys.go `expandAppStateKeys`).
|
||||
- LTHash is HKDF-SHA256 (not BLAKE2b): `lthash.WAPatchIntegrity` is `LTHash{HKDFInfo: "WhatsApp Patch Integrity", HKDFSize: 128}`, each 32-byte item is expanded with `hkdfutil.SHA256(item, nil, info, 128)` and added or subtracted per little-endian uint16 lane (appstate/lthash/lthash.go).
|
||||
- Value blob: iv (16 bytes) || AES-256-CBC ciphertext || value MAC (last 32 bytes). Value MAC = HMAC-SHA512 keyed by the value MAC key over `[op+1] || keyID || iv||ciphertext || BE64(len(keyID)+1)`, truncated to 32 bytes; op is 0 for SET, 1 for REMOVE (decode.go `generateContentMAC`).
|
||||
- Index MAC = HMAC-SHA256(index key, index JSON bytes). The index JSON is a string array such as `["contact","31611111111@s.whatsapp.net"]`.
|
||||
- Snapshot MAC = HMAC-SHA256(snapshot MAC key, hash || BE64(version) || collection name). Patch MAC = HMAC-SHA256(patch MAC key, snapshot MAC || value MAC of every mutation in order || BE64(version) || name) (hash.go `generateSnapshotMAC`, `generatePatchMAC`).
|
||||
- Hash update per patch: for every mutation, subtract the previous SET value MAC of the same index (earlier in the same patch first, then the stored MAC table); add the value MAC of each SET. A REMOVE finds its previous value the same way and adds nothing (hash.go `updateHash`, decode.go `validatePatch`).
|
||||
- Version 0 means full sync: the request carries `return_snapshot="true"` and no version. Otherwise it carries `version=N` (appstate.go `fetchAppStatePatches`, `fetchAppState`).
|
||||
- Large snapshots and patches arrive as external blobs (ExternalBlobReference): the blob is fetched from the media host with the `md-app-state` mms type and decrypted with HKDF info `WhatsApp App State Keys` (download.go `DownloadMediaWithPath`, `downloadAndDecrypt`).
|
||||
- Missing keys: `GetMissingKeyIDs` lists them; `requestMissingAppStateKeys` skips a key id requested within 24 h (appstate.go, the map `appStateKeyRequests`).
|
||||
- Contacts: `filterContacts` and `dispatchAppState` read `index[0] == "contact"`, JID from `index[1]`, name from `contactAction.firstName` and `fullName`. whatsmeow stores both names; we store one (fullName, else firstName) as the task requires.
|
||||
|
||||
## Pros / strengths
|
||||
|
||||
- Complete and explicit: every constant, split offset and MAC input is in the source, so an independent port can be checked.
|
||||
- The LTHash and key expansion are small enough that an oracle built from whatsmeow's own lthash package reproduces them exactly.
|
||||
|
||||
## Cons / limitations / caveats
|
||||
|
||||
- The package has no published test vectors; the only oracle is the code itself (we generate ours from it).
|
||||
- The source does not show the server's behaviour for REMOVE of an unknown index (whatsmeow only warns), nor the rate at which the server sends key shares.
|
||||
- The download path needs a live media connection; this dossier does not verify host selection beyond the first `media_conn` host.
|
||||
|
||||
## Relevance to tai
|
||||
|
||||
This is the protocol the contact sync in `src/whatsapp/wa_appstate.cpp` follows. The formulas above were ported as written and checked against golden vectors generated by whatsmeow's lthash package and protobuf types (tests/whatsapp_data/appstate_vectors.json, `build/whatsapp_test` section `appstate`). The decision to follow HKDF rather than BLAKE2b is recorded in conclusions/appstate-lthash-hkdf-not-blake2b.md.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-27 | Initial dossier created. |
|
||||
@@ -0,0 +1,34 @@
|
||||
# whatsmeow store/clientpayload.go DeviceProps and proto/waCompanionReg DeviceProps.HistorySyncConfig
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Source Dossier |
|
||||
| Tier | 1 |
|
||||
| Source title | whatsmeow store/clientpayload.go DeviceProps and proto/waCompanionReg DeviceProps.HistorySyncConfig |
|
||||
| Source URL | https://github.com/tulir/whatsmeow/blob/main/store/clientpayload.go ; https://github.com/tulir/whatsmeow/blob/main/proto/waCompanionReg/WACompanionReg.proto |
|
||||
| Source publisher / author | Tulir Asokan and contributors |
|
||||
| Source type | repository source and protobuf definition |
|
||||
| Source date (published) | Unknown - not stated (main branch fetched 2026-10-28) |
|
||||
| Research method | direct fetch (raw.githubusercontent.com) |
|
||||
| Confidence | Medium: the field values are exact; that the phone withholds history and app-state keys without them is an inference from the symptom (no key share arrived in 80 s after a request) plus the reference client always declaring it |
|
||||
| Date researched | 2026-10-28 |
|
||||
| Researched by | tai (agent), model: Claude Sonnet 5.5, on behalf of retoor |
|
||||
| Status | Active |
|
||||
|
||||
## Summary
|
||||
|
||||
The registration payload a companion sends declares what history it supports. whatsmeow declares historySyncConfig (storage quota, inline initial payload, call log, groups, ...). Ours declared none.
|
||||
|
||||
## Key findings
|
||||
|
||||
- DeviceProps fields: os 1, version 2, platformType 3, requireFullSync 4, historySyncConfig 5.
|
||||
- HistorySyncConfig values whatsmeow sends: storageQuotaMb 10240 (3), inlineInitialPayloadInE2EeMsg true (4), supportCallLogHistory (6), supportBotUserAgentChatHistory (7), supportCagReactionsAndPolls (8), supportBizHostedMsg (9), supportRecentSyncChunkMessageCountTuning (10), supportHostedGroupMsg (11), supportFbidBotChatHistory (12), supportMessageAssociation (14), supportGroupHistory (15) all true; thumbnailSyncDaysLimit 60 (19); supportManusHistory (21) and supportHatchHistory (22) true.
|
||||
- The peer app-state key request is encrypted to the phone LID identity (preparePeerMessageNode: GetLIDForPN), with category="peer" and push_priority="high" for APP_STATE_SYNC_KEY_REQUEST.
|
||||
|
||||
## Limits
|
||||
|
||||
- DeviceProps is only read at registration, so an already linked device must be re-linked for the change to matter.
|
||||
@@ -0,0 +1,51 @@
|
||||
# whatsmeow key share receipt and peer messages (message.go, send.go, E2E and sync protos)
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Source Dossier |
|
||||
| Tier | 1 |
|
||||
| Source title | whatsmeow message.go (handleProtocolMessage, handleAppStateSyncKeyShare), send.go (SendPeerMessage, preparePeerMessageNode), appstate.go (requestAppStateKeys), and proto/waE2E/WAWebProtobufsE2E.proto, proto/waServerSync/WAWebProtobufsServerSync.proto, proto/waSyncAction/WAWebProtobufSyncAction.proto |
|
||||
| Source URL | https://github.com/tulir/whatsmeow (main: message.go, send.go, appstate.go, proto/) |
|
||||
| Source publisher / author | Tulir Asokan and contributors |
|
||||
| Source type | repository |
|
||||
| Source date (published) | Unknown — not stated (snapshot fetched 2026-10-27) |
|
||||
| Date researched | 2026-10-27 |
|
||||
| Researched by | tai (agent), model: Claude Haiku 5.5, on behalf of retoor |
|
||||
| Research method | direct page fetch (raw.githubusercontent.com, GitHub contents API) |
|
||||
| Confidence | High for field numbers and node shapes (read from the .proto and Go source); Medium for live server behaviour |
|
||||
| Status | Active |
|
||||
|
||||
## Summary
|
||||
|
||||
The phone delivers app-state sync keys to a linked device as an ordinary encrypted 1:1 message from its own account, with `protocolMessage.type = APP_STATE_SYNC_KEY_SHARE`. A device that lacks a key sends `APP_STATE_SYNC_KEY_REQUEST` as a peer message (category `peer`, encrypted to the primary phone only). The protobuf field numbers needed for both are fixed in the .proto files.
|
||||
|
||||
## Key findings
|
||||
|
||||
- Only messages whose sender is our own account are processed for protocol messages (message.go `handleProtocolMessage` returns early when `!info.IsFromMe`).
|
||||
- Key share: `Message.protocolMessage` (field 12) with `type` (field 2) = 6 and `appStateSyncKeyShare` (field 7) whose `keys` (field 1, repeated) hold `keyID.keyID` (1) and `keyData` with `keyData` (1), `fingerprint` (2) and `timestamp` (3).
|
||||
- Key request: `protocolMessage.type` = 7 and `appStateSyncKeyRequest` (field 8) with `keyIDs` (field 1, repeated, each `keyID` = field 1).
|
||||
- Peer send: `<message id type="text" category="peer" to="<own user>">` with `<meta appdata="default"/>` and one `<enc>` for the primary device (device 0); no participants list (send.go `preparePeerMessageNode`). The key request sets `push_priority="high"`.
|
||||
- Sync protos: SyncdPatch (version 1, mutations 2, externalMutations 3, snapshotMAC 4, patchMAC 5, keyID 6), SyncdSnapshot (version 1, records 2, mac 3, keyID 4), SyncdRecord (index 1, value 2, keyID 3), SyncdMutation (operation 1, record 2, SET = 0, REMOVE = 1), SyncActionData (index 1, value 2, version 4), SyncActionValue.contactAction (3), ContactAction (fullName 1, firstName 2).
|
||||
|
||||
## Pros / strengths
|
||||
|
||||
- Field numbers are read from the generated-source proto, not from memory, and the key share and request bytes were reproduced byte-exact by an oracle from whatsmeow's own protobuf package.
|
||||
|
||||
## Cons / limitations / caveats
|
||||
|
||||
- The send path for peer messages uses a session with the phone's device 0; no source here shows what the server does if that session is missing (whatsmeow fetches prekeys, we do the same).
|
||||
- The protos listed are the snapshot revision on 2026-10-27; field numbers may change.
|
||||
|
||||
## Relevance to tai
|
||||
|
||||
Defines the wire shape of `src/whatsapp/wa_proto.cpp` (`parse_protocol_message`, `encode_app_state_key_request`, the sync parsers) and of the receive path in `src/whatsapp/wa_receiver.cpp` and `WaClient::handle_message`.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-27 | Initial dossier created. |
|
||||
@@ -0,0 +1,36 @@
|
||||
# whatsmeow upload.go, mediaconn.go, download.go (getMediaKeys) and wa-go media_vectors.json
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Source Dossier |
|
||||
| Tier | 1 |
|
||||
| Source title | whatsmeow upload.go, mediaconn.go, download.go (getMediaKeys) and wa-go media_vectors.json |
|
||||
| Source URL | https://github.com/tulir/whatsmeow ; https://github.com/jfelipesjc/wa-go/blob/master/testdata/media/media_vectors.json |
|
||||
| Source publisher / author | Tulir Asokan and contributors; jfelipesjc (wa-go, MIT) |
|
||||
| Source type | repository source and golden test vectors |
|
||||
| Source date (published) | Unknown - not stated (main branch fetched 2026-10-28) |
|
||||
| Research method | direct fetch (raw.githubusercontent.com) |
|
||||
| Confidence | High: our implementation reproduces all 8 Baileys-generated vectors byte for byte |
|
||||
| Date researched | 2026-10-28 |
|
||||
| Researched by | tai (agent), model: Claude Sonnet 5.5, on behalf of retoor |
|
||||
| Status | Active |
|
||||
|
||||
## Summary
|
||||
|
||||
The complete image-send recipe for a companion: media key, key expansion, encryption, MAC, media_conn query, HTTPS upload, and the response fields that go into ImageMessage.
|
||||
|
||||
## Key findings
|
||||
|
||||
- Media key: 32 random bytes. HKDF-SHA256(mediaKey, salt nil, info "WhatsApp Image Keys", 112) gives iv[0:16], cipherKey[16:48], macKey[48:80], refKey[80:112].
|
||||
- Upload blob = AES-256-CBC(cipherKey, iv, PKCS7(plain)) || HMAC-SHA256(macKey, iv||ciphertext)[:10]; fileSha256 = SHA-256(plain); fileEncSha256 = SHA-256(blob).
|
||||
- media_conn: <iq xmlns="w:m" type="set" to="s.whatsapp.net"><media_conn/></iq> returns auth, ttl, and <host hostname=...> children.
|
||||
- Upload: POST https://HOST/mms/image/BASE64URL(fileEncSha256)?auth=AUTH&token=TOKEN with Origin and Referer https://web.whatsapp.com; the JSON reply carries url and direct_path.
|
||||
- The message node for media has type="media"; enc nodes carry mediatype="image" when a DeviceSentMessage copy exists; own other devices receive DeviceSentMessage{destinationJid, message} (Message field 31) encrypted separately (send.go marshalMessage, encryptMessageForDevices).
|
||||
|
||||
## Limits
|
||||
|
||||
- Delivery to the phone was confirmed only by the server acknowledging the send; the user checks the picture on the phone.
|
||||
@@ -0,0 +1,35 @@
|
||||
# whatsmeow pair.go (handlePairSuccess, verifyAccountSignature, generateDeviceSignature) with oxidezap/whatsapp-rust issue 1439 and PR 1440
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Institute | Molodetz Institute — Research & Retrieval Division |
|
||||
| Subject | whatsapp-automation |
|
||||
| Document type | Source Dossier |
|
||||
| Tier | 1 |
|
||||
| Source title | whatsmeow pair.go (handlePairSuccess, verifyAccountSignature, generateDeviceSignature) with oxidezap/whatsapp-rust issue 1439 and PR 1440 |
|
||||
| Source URL | https://github.com/tulir/whatsmeow/blob/main/pair.go ; https://github.com/oxidezap/whatsapp-rust/issues/1439 ; https://github.com/oxidezap/whatsapp-rust/pull/1440 |
|
||||
| Source publisher / author | Tulir Asokan and contributors; oxidezap contributors |
|
||||
| Source type | repository source and issue/PR text |
|
||||
| Source date (published) | Unknown - not stated (main branch fetched 2026-10-28) |
|
||||
| Research method | direct fetch (raw.githubusercontent.com, GitHub REST API) |
|
||||
| Confidence | High for the reply shape and the prefix rules (the Rust PR cites an audit of the official WhatsApp Web bundle); Medium for server behaviour not visible from source |
|
||||
| Date researched | 2026-10-28 |
|
||||
| Researched by | tai (agent), model: Claude Sonnet 5.5, on behalf of retoor |
|
||||
| Status | Active |
|
||||
|
||||
## Summary
|
||||
|
||||
How a companion must answer pair-success: verify the ADV container, sign the device identity, and reply with pair-device-sign addressed to the server JID. Explains why our reply (addressed to "@s.whatsapp.net") left the device unregistered and every later login 401.
|
||||
|
||||
## Key findings
|
||||
|
||||
- The reply is <iq to="s.whatsapp.net" type="result" id=reqID><pair-device-sign><device-identity key-index=N>selfSignedIdentity</device-identity></pair-device-sign></iq>; to is the constant types.ServerJID, never copied from the incoming iq.
|
||||
- HMAC of the container: prefix [6,5] is written first only when the OUTER account_type is HOSTED. Account signature prefix: [6,5] when the INNER ADVDeviceIdentity.device_type is HOSTED, else [6,0]. Device signature prefix is always [6,1] for a companion (oxidezap PR 1440, citing the WhatsApp Web bundle).
|
||||
- The self-signed identity sent back drops accountSignatureKey but keeps the account and device signatures.
|
||||
- After the reply the server closes the stream with stream:error 515; the client reconnects and logs in.
|
||||
|
||||
## Limits
|
||||
|
||||
- Nothing here explains the encryption-metadata and jurisdiction children seen in a live pair-success for a Dutch number; whatsmeow main ignores them and pairing succeeds without handling them.
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
|
||||
## [Unreleased] - 2026-10-25
|
||||
- Verified the static build still works (`make static` exit 0, 29 MB `build/detz-static`, `--version` prints `tai 1.44.3`); stamped in [docs/embed.md](docs/embed.md).
|
||||
- Docs: [docs/whatsapp.md](docs/whatsapp.md) now states up front what `whatsmeow` is (the upstream Go reference library, MPL-2.0) and how this tree uses it (behavior referee for the native C++ port, not a linked dependency).
|
||||
- WhatsApp link, image and contacts ([docs/whatsapp.md](docs/whatsapp.md) section 15): pairing replies now address `s.whatsapp.net` (the decoder printed server-only JIDs as `@s.whatsapp.net`, the root cause of every post-link 401); the link wizard waits for the restart, logs in, uploads prekeys and retries once with a fresh device on 401/403; closed sockets no longer spin the CPU; `whatsapp_send` takes `image` (PNG/JPEG up to 16 MB, `WaMedia`, own devices get the DeviceSentMessage copy) and enforces 10/minute and 200/day; contacts sync from the phone (app-state, `historySyncConfig` now declared at registration) and are searched with FTS5 trigram plus fuzzy ranking, also from the global `search` tool; ambiguous names are reported instead of guessed.
|
||||
- `clean_history` drops orphan tool results (and logs it), and compaction no longer starts its kept window on a tool result: reproduced the devplace `HTTP 400 No function call found for function call output` against the live endpoint. Test: `build/llm_history_test`.
|
||||
- Terminal art (QR codes, boxed screens) pasted as a prompt is refused before any model call; a failed `Ctrl-C` no longer counts against backend health; muse runs in one stable workspace per home; the provider probe accepts bare JSON lists (pollinations) and prints the start of a bad body.
|
||||
- WhatsApp native protocol core ([docs/whatsapp.md](docs/whatsapp.md), `src/whatsapp/`, namespace `detz::wa`): a from-scratch C++ implementation of WhatsApp Web's multi-device wire protocol, built on OpenSSL plus a vendored public-domain ed25519 ref10 (`third_party/ed25519`, zlib) for XEdDSA, with no new runtime dependency. `WaCrypto` (X25519, AES-GCM/CTR/CBC, HKDF, PBKDF2, SHA, XEdDSA), `WaBinary` (binary-XML codec + token dict v3), `WaProto` (hand-written protobuf + the messages the 1:1 text/image client needs), `WaNoise` (Noise XX handshake + transport), `WaSignal` (X3DH + Double Ratchet, AES-CBC+HMAC). Verified byte-for-byte against golden vectors captured from the real Baileys client (`tests/whatsapp_data/`, from jfelipesjc/wa-go, MIT): HKDF/PBKDF2 RFC vectors, XEdDSA roundtrips, codec battery 19/19, ClientPayload byte-exact, the full Noise handshake, and a 4-exchange Signal session byte-exact. Test: `build/whatsapp_test` (16/16), step `whatsapp.unit` in run_all. The live socket/pairing/send/receive layer and the `--whatsapp-link` wizard and agent tools are not built yet; the live path is unverified (needs a linked phone). Research: research/whatsapp-automation.
|
||||
- Terminal screenshots ([docs/termshot.md](docs/termshot.md)): new tool `terminal_screenshot` writes a PNG of the current tmux or native multiplexer pane on a headless server (capture-pane -e, TuiVtScreen replay, embedded Spleen 8x16 font, zlib PNG; no new dependency). detz-only tools now live in `src/agent/detz_tools.*`; agent.diff and agent.e2e turn them off to keep comparing with tai.py. Test: `build/termshot_test`. Research: research/terminal-screenshot-rendering (99 sources, 2 conclusions).
|
||||
- WhatsApp research and build spec: [docs/whatsapp.md](docs/whatsapp.md) (no native Python library exists, no browser needed, native C++ `detz::wa` transpiled from wa-go with whatsmeow as referee, no new dependencies, build vs deep-research table, caps, tests, phases, a whatsapp-mcp shortcut). Research: research/whatsapp-automation (70 sources, 3 conclusions). Nothing built.
|
||||
|
||||
@@ -104,6 +104,7 @@ $(eval $(call TESTBIN,$(BUILD)/fuzzy_test,tests/fuzzy_test.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/whatsapp_test,tests/whatsapp_test.cpp,-Isrc/whatsapp -Isrc/llm -Ithird_party/ed25519))
|
||||
$(eval $(call TESTBIN,$(BUILD)/termshot_test,tests/termshot_test.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/muse_flags_test,tests/muse_flags_test.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/llm_history_test,tests/llm_history_test.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/orch_test,tests/orch_test.cpp))
|
||||
$(eval $(call TESTBIN,$(BUILD)/orch_live,tests/orch_live.cpp))
|
||||
# misc (skills, sched, telegram, rag, install)
|
||||
|
||||
+90
-1
@@ -1,5 +1,17 @@
|
||||
# whatsapp (taicpp): research result and build specification
|
||||
|
||||
`whatsmeow` is an open source Go library for the WhatsApp Web multidevice API
|
||||
(author Tulir Asokan, module `go.mau.fi/whatsmeow`, license MPL-2.0). It speaks
|
||||
the WhatsApp protocol directly over one WebSocket, with no browser involved:
|
||||
QR and pair-code linking, Noise handshake, Signal encryption, send and receive,
|
||||
media upload, and app-state (contact) sync. detz does not link or ship it: the
|
||||
native C++ module `detz::wa` is transpiled from `wa-go` (MIT) with `whatsmeow`
|
||||
as the behavior reference (section 3), and the test oracle
|
||||
`tests/whatsapp_data/appstate_oracle.go` imports its Go packages to generate
|
||||
byte-exact vectors. Any comment saying "ported from whatsmeow" means the logic
|
||||
or byte layout was copied from upstream and must stay compatible with it. Full
|
||||
comparison: section 2.1.
|
||||
|
||||
Status: **built and integrated; the offline core is verified, the live link
|
||||
is verified through QR display, message send is built but unverified** (it
|
||||
needs a phone-linked account, which development does not have). What is in the
|
||||
@@ -169,7 +181,7 @@ pair, as the rest of taicpp.
|
||||
| `WaTools` | agent tools `whatsapp_send`, `whatsapp_contacts`, `whatsapp_alias` | none, detz design |
|
||||
|
||||
Out of scope for v1, listed so nobody assumes otherwise: groups (sender keys),
|
||||
app-state sync (LTHash contact list), history sync, calls, status, newsletters,
|
||||
history sync, calls, status, newsletters,
|
||||
receiving media, editing, reactions.
|
||||
|
||||
## 5. Protocol walkthrough and crypto mapping
|
||||
@@ -557,3 +569,80 @@ parser-focused tests and the first sanitizer runs because those never exercised
|
||||
the capture hook (which turned an unreproducible failure into a deterministic
|
||||
one), and the rule that a round-trip test must cover the populated case, not
|
||||
just the empty one. The honest open item from 13.6 is now closed.
|
||||
|
||||
## 14. App-state sync: the contact list (`WaAppState`, `WaReceiver`)
|
||||
|
||||
Status: implemented and verified offline. The live path needs a re-linked
|
||||
phone: nothing here has been run against a phone that holds contacts.
|
||||
|
||||
Why: the phone's address book reaches a linked companion only through app-state
|
||||
sync (collection `critical_unblock_low`, index `["contact", "<jid>"]`). Without
|
||||
it `wa_contact` stays empty and names cannot be resolved.
|
||||
|
||||
| Piece | Files | What it does |
|
||||
|---|---|---|
|
||||
| Key receipt | `wa_receiver.*`, `wa_proto.*` (`parse_protocol_message`), `WaClient::handle_message` | Decrypts `<enc>` from our own account (pkmsg via X3DH responder, msg via session), unpads, stores each APP_STATE_SYNC_KEY_SHARE key in `wa_appstate_key`. Only the phone's own messages are decrypted; other senders are acked and ignored as before. |
|
||||
| Key request | `WaClient::request_app_state_keys`, `send_peer_message`, `encode_app_state_key_request` | Peer message to the primary phone (category `peer`), at most `kWaAppStateKeyIdsMax` ids, one request per key id per `kWaAppStateKeyRequestInterval` (24 h), stored in `wa_appstate_keyreq`. |
|
||||
| Key expansion, MACs, values | `wa_appstate.*` (`WaAppStateKeys`, `WaAppStateCrypto`) | HKDF-SHA256 `WhatsApp Mutation Keys` to 160 bytes; value MAC HMAC-SHA512 truncated to 32; index, snapshot and patch MACs HMAC-SHA256; AES-256-CBC values. `WaCrypto::hmac_sha512` added. |
|
||||
| LTHash | `wa_appstate.*` (`WaLtHash`) | 128-byte state, HKDF-SHA256 info `WhatsApp Patch Integrity` (whatsmeow, not BLAKE2b; see research conclusion `appstate-lthash-hkdf-not-blake2b`). |
|
||||
| Patch fetch and apply | `WaAppStateSync::sync` | iq `w:sync:app:state`, `return_snapshot` at version 0, `version=N` otherwise; external blobs via `WaMedia::download` (`md-app-state`); snapshot MAC, patch MAC, index and value MACs checked before anything is stored. |
|
||||
| Contacts | `WaAppStateSync::apply_contact` | SET stores jid = index[1], name = fullName else firstName; empty name is counted and skipped; REMOVE deletes the row. |
|
||||
| Login hook | `WaClient::login` calls `sync_app_state` after prekeys | Never fails the login (errors are logged). If a key is missing, requests it, waits up to `kWaAppStateKeyWait` s for a key share, then syncs once more. |
|
||||
|
||||
Caps (`wa_consts.h`): `kWaAppStatePatchMax` 50 patches per sync,
|
||||
`kWaAppStateMutationMax` 10000 mutations, `kWaAppStateTimeout` 30 s per iq,
|
||||
`kWaAppStateBlobMax` 16 MiB per external blob, `kWaAppStateKeyIdsMax` 16 key
|
||||
ids per request, `kWaAppStateKeyWait` 20 s. A cap stops the sync loudly
|
||||
(`capped` in the report, a log line) and the rest waits for the next login.
|
||||
|
||||
Failure rule: a patch or snapshot that fails a MAC or hash check is not stored,
|
||||
the sync stops, and the collection version stays at the last verified state.
|
||||
This skips the bad patch and everything after it, because each later patch
|
||||
depends on the hash chain.
|
||||
|
||||
Tables: `wa_appstate_key`, `wa_appstate_version`, `wa_appstate_mac` (value MAC
|
||||
per index MAC, needed to undo a SET or REMOVE), `wa_appstate_keyreq`. `clear_device`
|
||||
empties them, so a new link syncs from version 0.
|
||||
|
||||
Verification (offline, `build/whatsapp_test`, section `appstate`):
|
||||
|
||||
- expanded keys equal whatsmeow `expandAppStateKeys` bytes;
|
||||
- LTHash add/remove cancel and are order-free;
|
||||
- a snapshot plus one patch (SET, REMOVE, in-patch replacement, unnamed SET, a
|
||||
non-contact SET) decodes to the oracle's final hash and version, and the
|
||||
contact table matches the oracle's expected names;
|
||||
- a re-sync at version 2 changes nothing;
|
||||
- a tampered patch is rejected and not stored;
|
||||
- a missing key stores nothing and reports the key id;
|
||||
- the key share and key request bytes match whatsmeow's Message encoding;
|
||||
- a pkmsg built with `WaSession` decrypts through `WaReceiver` to the key share
|
||||
Message, and a corrupted pkmsg is refused.
|
||||
|
||||
The oracle is `tests/whatsapp_data/appstate_vectors.json`, generated by
|
||||
`tests/whatsapp_data/appstate_oracle.go` (a Go program using whatsmeow's own
|
||||
`lthash`, `hkdfutil` and protobuf packages). It is not run by `make test`.
|
||||
|
||||
Not verified: the live server's answers to the iq, key share timing, the media
|
||||
host for external blobs, and whether a REMOVE of an unknown index (whatsmeow
|
||||
only warns) causes trouble. Known gap: a SET with an empty name keeps any older
|
||||
name the row had; the spec says skip, not delete.
|
||||
|
||||
|
||||
|
||||
## 15. Incident WA-2026-10-27b: link ended in 401, no picture, no contacts
|
||||
|
||||
Everything here was measured on 2026-10-27/28 against the live server and the user's phone.
|
||||
|
||||
| Symptom | Root cause | Fix | Evidence |
|
||||
|---|---|---|---|
|
||||
| `Linked, but the login confirmation did not complete`; later login `failure 401` | The decoder printed a server-only JID (empty user) as `@s.whatsapp.net`; the pairing replies copied that into `to`, so the server refused `pair-device-sign` and never registered the device | decoder returns the bare server name; encoder writes bare server names as JID pairs; replies use `WaConsts::kServerJid` as whatsmeow does | captured `pair-success` decoded (`from=@s.whatsapp.net`), live login probe 401 before, `logged in` after re-link; test `server-only JID round-trips` |
|
||||
| wizard never reported the real login result | `run_until` returned at once because `paired_event_` stayed set | `run_until` is stateless; `WaClient::await_pair_restart` + `WaClient::login`; one fresh pairing after a 401/403 (`kWaLinkRetries`) | wizard source |
|
||||
| `user 7m` CPU while linking | a closed socket made every receive return instantly and the loops spun until their deadline | `WaSocket` clears `connected_` on close, every loop checks `WaClient::socket_open()` | probe after the 401: 1.7 s, 0.01 s CPU (was 20 s of spinning) |
|
||||
| PNG could not be sent | the `whatsapp_send` tool had no image argument and `send_image` was a stub | `WaMedia` (HKDF `WhatsApp Image Keys`, AES-CBC, 10-byte HMAC, `media_conn`, HTTPS upload), `send_image`, DeviceSentMessage copy to our other devices, tool argument `image` | 8/8 wa-go golden vectors byte-exact; live: image to own number acknowledged by the server |
|
||||
| Nigel not found | the phone address book reaches a companion only through app-state sync, which did not exist; and the phone only pushes history and keys to a companion that declares `DeviceProps.historySyncConfig`, which registration omitted | section 14 (`WaAppState`, `WaReceiver`); `WaDeviceProps` now sends `historySyncConfig` (field 5, whatsmeow values); peer key request encrypted to the phone's LID identity | unit tests; the live address book needs a link made with this build |
|
||||
| name lookups were exact or one fuzzy guess | contact table only, one `FuzzyMatcher::Best` | `WaStore::search_contacts`: FTS5 trigram candidates + fuzzy rank; ambiguity (two scores within 0.08) is reported, never guessed; the global `search` tool lists matching contacts | tests `contact search: ...` |
|
||||
| no send limits | caps were specified, not enforced | `wa_send_log`, `kWaSendPerMinute` 10 and `kWaSendPerDay` 200 enforced in `whatsapp_send` | test `send log counts sends in the window` |
|
||||
|
||||
Gaps: the contact sync and the address-book key share are not verified against a phone yet; a newly linked device
|
||||
must be created with this build for the phone to send history. Set `DETZ_WA_TRACE=1` to log every node's tag and
|
||||
attributes when debugging a protocol exchange.
|
||||
|
||||
@@ -152,6 +152,15 @@ std::pair<json::Value, bool> compact_messages(const json::Value& messages, llm::
|
||||
size_t rstart = total - static_cast<size_t>(keep);
|
||||
while (rstart < total && !(messages.at(rstart).get("role").is_string() && messages.at(rstart).get("role").str() == "user")) { ++rstart; }
|
||||
if (rstart >= total) { rstart = total - static_cast<size_t>(keep); }
|
||||
// The window must not start on a tool result: its assistant tool_calls message would fall into the summarised
|
||||
// middle and the provider rejects the orphan ("No function call found for function call output with call_id").
|
||||
// Skip the orphans forward; if nothing is left, begin at the assistant message that issued the trailing results.
|
||||
auto is_tool = [&](size_t i) { return messages.at(i).get("role").is_string() && messages.at(i).get("role").str() == "tool"; };
|
||||
while (rstart < total && is_tool(rstart)) { ++rstart; }
|
||||
if (rstart >= total) {
|
||||
rstart = total - 1;
|
||||
while (rstart > 1 && is_tool(rstart)) { --rstart; }
|
||||
}
|
||||
// middle = messages[1:len(messages) - len(recent)]
|
||||
size_t recent_len = total - rstart;
|
||||
size_t mid_end = total - recent_len;
|
||||
@@ -784,6 +793,27 @@ std::pair<std::optional<std::string>, std::optional<std::string>> Agent::apply_m
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the loop
|
||||
// True when most visible characters are box-drawing (U+2500..U+257F) or block elements (U+2580..U+259F),
|
||||
// the shape of a terminal QR code or a boxed screen pasted as text. Such input has no request in it; a
|
||||
// model that gets it spends a full turn guessing (see everythingwrong.md, four rounds on a pasted QR).
|
||||
static bool is_terminal_art(const std::string& text) {
|
||||
size_t visible = 0, art = 0;
|
||||
for (size_t i = 0; i < text.size();) {
|
||||
unsigned char c = static_cast<unsigned char>(text[i]);
|
||||
size_t len = c < 0x80 ? 1 : (c >> 5) == 0x6 ? 2 : (c >> 4) == 0xE ? 3 : 4;
|
||||
if (c == ' ' || c == '\n' || c == '\t' || c == '\r') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
bool block = len == 3 && i + 2 < text.size() && static_cast<unsigned char>(text[i]) == 0xE2 &&
|
||||
static_cast<unsigned char>(text[i + 1]) >= 0x94 && static_cast<unsigned char>(text[i + 1]) <= 0x96;
|
||||
++visible;
|
||||
if (block) ++art;
|
||||
i += len;
|
||||
}
|
||||
return visible >= 40 && art * 2 > visible;
|
||||
}
|
||||
|
||||
std::string Agent::run_turn(const std::string& text, bool capture, int max_steps, bool routed) {
|
||||
json::Value meta = json::Value::object();
|
||||
meta.set("goal", py::head(text, 500));
|
||||
@@ -816,8 +846,13 @@ std::string Agent::run_turn(const std::string& text, bool capture, int max_steps
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
std::string Agent::run_turn_inner(const std::string& in_text, bool capture, int max_steps, bool routed) {
|
||||
std::string text = in_text;
|
||||
if (is_terminal_art(text)) {
|
||||
return "not sent to the model: the input is terminal art (a QR code or a boxed screen), not a request. "
|
||||
"To link WhatsApp run `detz --whatsapp-link` in a terminal and scan the code it draws; to share a screen, use terminal_screenshot.";
|
||||
}
|
||||
apply_system();
|
||||
if (!routed) {
|
||||
auto [prompt, result] = apply_mentions(text, capture, max_steps);
|
||||
@@ -1048,6 +1083,8 @@ std::string Agent::run_turn_inner(const std::string& in_text, bool capture, int
|
||||
|
||||
std::pair<std::string, bool> Agent::run_verified(const std::string& prompt) {
|
||||
if (depth != 0) { return {run_turn(prompt), true}; }
|
||||
// terminal art is refused before the model; there is no goal to verify, so no verifier call either
|
||||
if (is_terminal_art(prompt)) { return {run_turn(prompt), true}; }
|
||||
return verify_loop(prompt, run_turn(prompt));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ bool& enabled_flag() {
|
||||
const std::vector<std::string>& DetzTools::schema_json() {
|
||||
static const std::vector<std::string> v = {
|
||||
R"DETZ({"type": "function", "function": {"name": "terminal_screenshot", "description": "Make a PNG picture of the user's current terminal (the tmux pane, or the tai multiplexer pane), with colors, on a headless server too: no desktop needed. Returns the file path, size and grid; send or describe that file next (for example as a WhatsApp image). Pass target (a tmux pane id like %3 or session:window.pane) for another pane and path to choose the file, default under the detz home in shots/.", "parameters": {"type": "object", "properties": {"target": {"type": "string"}, "path": {"type": "string"}}, "required": []}}})DETZ",
|
||||
R"DETZ({"type": "function", "function": {"name": "whatsapp_send", "description": "Send a WhatsApp text message to a contact. 'to' is a name or alias you stored, or a phone number in international format (digits only, with country code, no +). Requires that WhatsApp was linked first with `detz --whatsapp-link` (scan the QR on your phone). Returns the message id, or an error explaining what to do (for example, link first).", "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}}})DETZ",
|
||||
R"DETZ({"type": "function", "function": {"name": "whatsapp_contacts", "description": "List or add WhatsApp contacts (name to number aliases). With no arguments, lists known contacts. Pass name and number to store an alias you can then use with whatsapp_send. Number is international format, digits only, with country code, no +.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "number": {"type": "string"}}, "required": []}}})DETZ",
|
||||
R"DETZ({"type": "function", "function": {"name": "whatsapp_send", "description": "Send a WhatsApp message to a contact: text, or a picture (image = path to a .png, .jpg or .jpeg file, up to 16 MB) with text as its caption, or both. 'to' is a name or alias you stored, or a phone number in international format (digits only, with country code, no +). Requires that WhatsApp was linked first with `detz --whatsapp-link` (scan the QR on your phone). Returns the message id, or an error explaining what to do (for example, link first).", "parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}, "image": {"type": "string"}}, "required": ["to"]}}})DETZ",
|
||||
R"DETZ({"type": "function", "function": {"name": "whatsapp_contacts", "description": "Search, list or add WhatsApp contacts. The contacts come from the user's phone address book (synced after `detz --whatsapp-link`). Pass query to search by name or number with typo-tolerant ranking (finds \"Nigel\" for \"Niggel\"); no arguments lists everything known. Pass name and number to store an alias you can then use with whatsapp_send. Number is international format, digits only, with country code, no +.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "name": {"type": "string"}, "number": {"type": "string"}}, "required": []}}})DETZ",
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ class Tools {
|
||||
std::string run_terminal_screenshot(const json::Value& args); // detz-only (DetzTools)
|
||||
std::string run_whatsapp_send(const json::Value& args); // detz-only (DetzTools)
|
||||
std::string run_whatsapp_contacts(const json::Value& args); // detz-only (DetzTools)
|
||||
// WhatsApp contacts matching a query, as `search` result lines (empty when WhatsApp was never linked)
|
||||
std::vector<std::string> whatsapp_contact_lines(const std::string& query, size_t limit);
|
||||
std::string run_plexer_send(const json::Value& args);
|
||||
std::string run_plexer_read(const json::Value& args);
|
||||
std::string run_plexer_type(const json::Value& args);
|
||||
|
||||
@@ -391,14 +391,15 @@ std::string Tools::run_search(const json::Value& args) {
|
||||
std::string query = py::strip(py::str_or_empty(args.get("query")));
|
||||
if (query.empty()) { return "error: empty query"; }
|
||||
std::vector<std::string> kinds;
|
||||
bool want_contacts = !args.get("kinds").truthy(); // WhatsApp contacts ride along unless kinds excludes them
|
||||
if (args.get("kinds").truthy()) {
|
||||
const json::Value& k = args.get("kinds");
|
||||
if (!k.is_array() || k.size() == 0) { return "error: kinds must be a list of record, event, audit, chunk"; }
|
||||
for (const auto& item : k.items()) {
|
||||
if (!item.is_string() || !(item.str() == "record" || item.str() == "event" || item.str() == "audit" || item.str() == "chunk")) {
|
||||
if (!item.is_string() || !(item.str() == "record" || item.str() == "event" || item.str() == "audit" || item.str() == "chunk" || item.str() == "contact")) {
|
||||
return "error: kinds must be a list of record, event, audit, chunk";
|
||||
}
|
||||
kinds.push_back(item.str());
|
||||
if (item.str() == "contact") { want_contacts = true; } else { kinds.push_back(item.str()); }
|
||||
}
|
||||
} else {
|
||||
kinds = {"record", "event", "audit", "chunk"};
|
||||
@@ -413,7 +414,7 @@ std::string Tools::run_search(const json::Value& args) {
|
||||
bool expand = args.get("expand").truthy();
|
||||
std::string profile = active_profile();
|
||||
StorePort& st = S("fts_search");
|
||||
json::Value hits_v = st.fts_search(query, kinds, profile, limit);
|
||||
json::Value hits_v = kinds.empty() ? json::Value::array() : st.fts_search(query, kinds, profile, limit);
|
||||
std::vector<json::Value> hits(hits_v.items().begin(), hits_v.items().end());
|
||||
if (st.seal_enabled() && std::find(kinds.begin(), kinds.end(), "event") != kinds.end()) {
|
||||
bool any_event = false;
|
||||
@@ -449,7 +450,9 @@ std::string Tools::run_search(const json::Value& args) {
|
||||
(void)n;
|
||||
}
|
||||
st.log_event(profile, "tool", "search-query", "q=" + py::head(query, 200) + "\tlimit=" + std::to_string(limit) + "\thits=" + py::head(py::join(hit_parts, ","), 1500));
|
||||
if (hits.empty()) { return "no matches for '" + py::head(query, 80) + "'"; }
|
||||
std::vector<std::string> contact_lines;
|
||||
if (want_contacts) { contact_lines = whatsapp_contact_lines(query, 5); }
|
||||
if (hits.empty() && contact_lines.empty()) { return "no matches for '" + py::head(query, 80) + "'"; }
|
||||
std::vector<int64_t> chunk_ids;
|
||||
for (const auto& h : hits) {
|
||||
if (h.get("kind").is_string() && h.get("kind").str() == "chunk") {
|
||||
@@ -505,6 +508,7 @@ std::string Tools::run_search(const json::Value& args) {
|
||||
lines.push_back("[audit/" + sv(h.get("sub")) + "] #" + rest + " " + py::head(sv(h.get("title")), 80) + " -- " + sv(h.get("snippet")));
|
||||
}
|
||||
}
|
||||
lines.insert(lines.end(), contact_lines.begin(), contact_lines.end());
|
||||
return py::join(lines, "\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
#include "../termshot/termshot_shooter.h"
|
||||
#include "../util/fuzzy.h"
|
||||
#include "../whatsapp/wa_client.h"
|
||||
#include "../whatsapp/wa_consts.h"
|
||||
#include "../whatsapp/wa_store.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <iostream>
|
||||
#include <regex>
|
||||
#include <unistd.h>
|
||||
@@ -703,6 +706,18 @@ std::string Tools::run_install_skill(const json::Value& args) {
|
||||
}
|
||||
|
||||
// --- WhatsApp tools (detz-only) ---
|
||||
std::vector<std::string> Tools::whatsapp_contact_lines(const std::string& query, size_t limit) {
|
||||
std::vector<std::string> lines;
|
||||
// never create the WhatsApp database just because someone searched
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(wa::WaStore::db_path(app.config.home, app.config.profile), ec)) return lines;
|
||||
wa::WaStore store(app.config.home, app.config.profile);
|
||||
for (const auto& h : store.search_contacts(query, limit, 0.4)) {
|
||||
lines.push_back("[contact/whatsapp] " + h.name + " -- " + h.jid.substr(0, h.jid.find('@')));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::string Tools::run_whatsapp_contacts(const json::Value& args) {
|
||||
wa::WaStore store(app.config.home, app.config.profile);
|
||||
std::string name = py::strip(py::str_or_empty(args.get("name")));
|
||||
@@ -715,8 +730,22 @@ std::string Tools::run_whatsapp_contacts(const json::Value& args) {
|
||||
store.put_contact(digits + "@s.whatsapp.net", name);
|
||||
return "saved WhatsApp contact: " + name + " -> " + digits;
|
||||
}
|
||||
std::string query = py::strip(py::str_or_empty(args.get("query")));
|
||||
if (!query.empty()) {
|
||||
auto hits = store.search_contacts(query, 10, 0.3);
|
||||
if (hits.empty()) return "no WhatsApp contact matches \"" + query + "\" (" + std::to_string(store.contacts().size()) + " contacts known)";
|
||||
std::string out = std::to_string(hits.size()) + " WhatsApp contact(s) matching \"" + query + "\":\n";
|
||||
for (const auto& h : hits) {
|
||||
char sc[16];
|
||||
std::snprintf(sc, sizeof sc, "%.2f", h.score);
|
||||
out += " " + h.name + " -> " + h.jid.substr(0, h.jid.find('@')) + " (match " + sc + ")\n";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
auto cts = store.contacts();
|
||||
if (cts.empty()) return "no WhatsApp contacts yet; add one with whatsapp_contacts name=\"Niggel\" number=\"31612345678\"";
|
||||
if (cts.empty())
|
||||
return "no WhatsApp contacts yet. They arrive from the phone after `detz --whatsapp-link` (address book sync); or add one with "
|
||||
"whatsapp_contacts name=\"Niggel\" number=\"31612345678\"";
|
||||
std::string out = std::to_string(cts.size()) + " WhatsApp contact(s):\n";
|
||||
for (const auto& [jid, nm] : cts) out += " " + nm + " -> " + jid.substr(0, jid.find('@')) + "\n";
|
||||
return out;
|
||||
@@ -725,7 +754,24 @@ std::string Tools::run_whatsapp_contacts(const json::Value& args) {
|
||||
std::string Tools::run_whatsapp_send(const json::Value& args) {
|
||||
std::string to = py::strip(py::str_or_empty(args.get("to")));
|
||||
std::string text = py::str_or_empty(args.get("text"));
|
||||
if (to.empty() || text.empty()) return "error: whatsapp_send needs 'to' and 'text'";
|
||||
std::string image_path = py::strip(py::str_or_empty(args.get("image")));
|
||||
if (to.empty() || (text.empty() && image_path.empty())) return "error: whatsapp_send needs 'to' and 'text' or 'image'";
|
||||
// the picture, read and checked before anything touches the network
|
||||
wa::Bytes image;
|
||||
std::string mimetype;
|
||||
if (!image_path.empty()) {
|
||||
std::string ext = py::lower(std::filesystem::path(image_path).extension().string());
|
||||
if (ext == ".png") mimetype = "image/png";
|
||||
else if (ext == ".jpg" || ext == ".jpeg") mimetype = "image/jpeg";
|
||||
else return "error: whatsapp_send image must be a .png, .jpg or .jpeg file (got '" + image_path + "')";
|
||||
std::ifstream in(image_path, std::ios::binary);
|
||||
if (!in) return "error: cannot read image " + image_path;
|
||||
image.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
if (image.empty()) return "error: image " + image_path + " is empty";
|
||||
if (image.size() > wa::WaConsts::kWaImageMax)
|
||||
return "error: image " + image_path + " is " + std::to_string(image.size()) + " bytes, the limit is " +
|
||||
std::to_string(wa::WaConsts::kWaImageMax);
|
||||
}
|
||||
wa::WaStore store(app.config.home, app.config.profile);
|
||||
if (!store.has_device())
|
||||
return "error: WhatsApp is not linked yet. Run `detz --whatsapp-link` in a terminal and scan the QR code with "
|
||||
@@ -739,16 +785,18 @@ std::string Tools::run_whatsapp_send(const json::Value& args) {
|
||||
for (char c : to)
|
||||
if (c >= '0' && c <= '9') digits.push_back(c);
|
||||
bool all_digits = !to.empty() && digits.size() == to.size();
|
||||
auto cts = store.contacts();
|
||||
if (!all_digits && !cts.empty()) {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& c : cts) names.push_back(c.second);
|
||||
double sc = 0;
|
||||
long idx = FuzzyMatcher::Best(to, names, 0.3, &sc);
|
||||
if (idx >= 0) {
|
||||
to_jid = cts[static_cast<size_t>(idx)].first;
|
||||
if (names[static_cast<size_t>(idx)] != to)
|
||||
resolved_note = " (resolved \"" + to + "\" to contact " + names[static_cast<size_t>(idx)] + ")";
|
||||
if (!all_digits) {
|
||||
auto hits = store.search_contacts(to, 3, 0.3);
|
||||
if (!hits.empty()) {
|
||||
bool exact = py::lower(hits[0].name) == py::lower(to);
|
||||
// two nearly equal candidates: never guess who gets the message, report them
|
||||
if (!exact && hits.size() > 1 && hits[0].score - hits[1].score < 0.08) {
|
||||
std::string list;
|
||||
for (const auto& h : hits) list += "\n " + h.name + " -> " + h.jid.substr(0, h.jid.find('@'));
|
||||
return "error: \"" + to + "\" matches several contacts, ask the user which one:" + list;
|
||||
}
|
||||
to_jid = hits[0].jid;
|
||||
if (!exact) resolved_note = " (resolved \"" + to + "\" to contact " + hits[0].name + ")";
|
||||
}
|
||||
}
|
||||
if (to_jid.empty() && digits.size() >= 7) to_jid = digits + "@s.whatsapp.net";
|
||||
@@ -757,18 +805,27 @@ std::string Tools::run_whatsapp_send(const json::Value& args) {
|
||||
"\" to a contact or number. Add it with whatsapp_contacts, or pass an international number.";
|
||||
}
|
||||
|
||||
// hard caps: a floor the agent cannot cross on its own (account-ban exposure, docs/whatsapp.md section 8)
|
||||
if (store.sends_since(60) >= wa::WaConsts::kWaSendPerMinute)
|
||||
return "error: WhatsApp send limit reached (" + std::to_string(wa::WaConsts::kWaSendPerMinute) + " per minute). Wait a minute, or ask the user.";
|
||||
if (store.sends_since(86400) >= wa::WaConsts::kWaSendPerDay)
|
||||
return "error: WhatsApp send limit reached (" + std::to_string(wa::WaConsts::kWaSendPerDay) + " per day). Ask the user before sending more.";
|
||||
|
||||
wa::WaClient client(store);
|
||||
client.set_capture_path(app.config.home + "/whatsapp/last-error.log");
|
||||
std::string log_tail;
|
||||
client.on_log = [&](const std::string& s) { log_tail += "\n " + s; };
|
||||
if (!client.connect()) return "error: could not connect to WhatsApp (check network)" + log_tail;
|
||||
client.run_until([&]() { return client.logged_in(); }, 20);
|
||||
if (!client.logged_in())
|
||||
return "error: WhatsApp login did not complete; the phone may need to be online to re-authorize." + log_tail;
|
||||
auto id = client.send_text(to_jid, text);
|
||||
if (!client.login(wa::WaConsts::kWaLoginTimeout)) {
|
||||
std::string why = client.last_failure_code() ? " (server reason " + std::to_string(client.last_failure_code()) + ")" : "";
|
||||
return "error: WhatsApp login did not complete" + why + ". Run `detz --whatsapp-unlink` then `detz --whatsapp-link` to link again." +
|
||||
log_tail;
|
||||
}
|
||||
auto id = image_path.empty() ? client.send_text(to_jid, text) : client.send_image(to_jid, image, mimetype, text);
|
||||
client.disconnect();
|
||||
if (!id) return "error: WhatsApp send failed." + log_tail;
|
||||
return "sent WhatsApp message to " + to_jid.substr(0, to_jid.find('@')) + resolved_note + " (id " + *id + ")";
|
||||
store.record_send(to_jid);
|
||||
std::string what = image_path.empty() ? "message" : "image " + std::filesystem::path(image_path).filename().string();
|
||||
return "sent WhatsApp " + what + " to " + to_jid.substr(0, to_jid.find('@')) + resolved_note + " (id " + *id + ")";
|
||||
}
|
||||
|
||||
} // namespace detz::agent
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "http_transport.h"
|
||||
#include "py.h"
|
||||
#include "term.h"
|
||||
#include "../repl/interrupt.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -451,7 +452,7 @@ ChatReply ChatClient::complete(const json::Value& messages, const json::Value& t
|
||||
|
||||
ChatReply ChatClient::complete_inner(const json::Value& in_messages, const json::Value& tools, const StreamSink* sink,
|
||||
bool ephemeral, std::optional<double> deadline) {
|
||||
json::Value messages = clean_history(in_messages);
|
||||
json::Value messages = drop_orphan_tool_results(clean_history(in_messages));
|
||||
json::Value payload = json::Value::object();
|
||||
payload.set("model", config_.model);
|
||||
payload.set("messages", messages);
|
||||
@@ -470,9 +471,12 @@ ChatReply ChatClient::complete_inner(const json::Value& in_messages, const json:
|
||||
std::vector<Candidate> cands = round_no == 0 ? ordered_candidates() : all_candidates();
|
||||
if (cands.empty()) { throw BackendError(pin_note()); }
|
||||
for (const auto& cand : cands) {
|
||||
// Ctrl-C ends the turn: the backends that were cut short were not failing, so nothing is recorded for them
|
||||
if (repl::Interrupt::requested()) { throw BackendError("interrupted"); }
|
||||
auto reply = try_candidate(cand, messages, tools, payload, sink, ephemeral, errors);
|
||||
if (reply.has_value()) { return *reply; }
|
||||
}
|
||||
if (repl::Interrupt::requested()) { throw BackendError("interrupted"); }
|
||||
if (!round_transient) {
|
||||
throw BackendError("no backend can take this request, retrying will not help (" + summarize_errors(errors, 6, 900) + ")");
|
||||
}
|
||||
@@ -659,6 +663,7 @@ std::optional<ChatReply> ChatClient::try_candidate(const Candidate& cand, const
|
||||
if (log_enabled()) { log_completion(cand, messages, reply, tries[pos].tools, elapsed * 1000, pos > 0); }
|
||||
return reply;
|
||||
} catch (const BackendError& exc) {
|
||||
if (repl::Interrupt::requested()) { return std::nullopt; } // cut short by the user, not a model failure
|
||||
if (pos == 0 && exc.status() == 400 && tries.size() > 1) { continue; }
|
||||
health.record(label, model, false);
|
||||
bool transient = transient_failure(exc);
|
||||
@@ -673,6 +678,7 @@ std::optional<ChatReply> ChatClient::try_candidate(const Candidate& cand, const
|
||||
log_event("WARNING", "llm", "failed", label + "/" + model + " failed: " + short_error(exc), scope.first, scope.second, meta);
|
||||
return std::nullopt;
|
||||
} catch (const std::exception& exc) {
|
||||
if (repl::Interrupt::requested()) { return std::nullopt; }
|
||||
health.record(label, model, false);
|
||||
round_transient = true;
|
||||
errors.push_back(label + "/" + model + ": " + short_error(exc));
|
||||
@@ -958,7 +964,12 @@ ChatReply ChatClient::muse_persistent(const json::Value& messages, const json::V
|
||||
json::Value pending_v = json::Value::array();
|
||||
for (auto& p : pending) { pending_v.push(p); }
|
||||
muse_write_file(prompt_path, muse_prompt(pending_v, tools));
|
||||
answer = muse_run(muse_argv(config_.explicit_model, schema_path, prompt_path, session_id), folder.path(), sink, timeout, gate);
|
||||
// One stable workspace per home: a session is bound to the workspace it was created in, and a fresh temp
|
||||
// directory per call made every resume print "resuming in workspace ..." and switch workspaces.
|
||||
std::string workspace = home + "/" + kMuseWorkspaceDir;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(workspace, ec);
|
||||
answer = muse_run(muse_argv(config_.explicit_model, schema_path, prompt_path, session_id), workspace, sink, timeout, gate);
|
||||
} catch (const BackendError&) {
|
||||
reset_muse();
|
||||
throw;
|
||||
|
||||
@@ -680,9 +680,10 @@ std::string probe_backend(const std::string& models_url, const std::string& key)
|
||||
try {
|
||||
payload = json::Value::loads(res.body);
|
||||
} catch (const json::ParseError&) {
|
||||
return "bad response";
|
||||
return "bad response: " + py::head(py::strip(res.body), 60);
|
||||
}
|
||||
const json::Value& data = payload.is_object() ? payload.get("data") : json::Value();
|
||||
// OpenAI-style {"data": [...]} or a bare list (pollinations /models)
|
||||
const json::Value& data = payload.is_object() ? payload.get("data") : payload;
|
||||
size_t count = data.is_array() ? data.size() : 0;
|
||||
return "ok, " + std::to_string(count) + " models, " + std::to_string(static_cast<int>((py::now() - started) * 1000)) + "ms";
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ inline constexpr const char* kMuseReasoningEffort = "low"; // measured: 2.8s v
|
||||
inline constexpr double kCliFallbackTimeout = 30.0; // hard cap per opencode run, free rotating models
|
||||
inline constexpr const char* kMuseSessionsFile = "muse_sessions.json";
|
||||
inline constexpr const char* kMuseLocksDir = "muse_locks";
|
||||
inline constexpr const char* kMuseWorkspaceDir = "muse_workspace";
|
||||
inline constexpr int kMuseWorkerTtl = 86400;
|
||||
inline constexpr int kMuseReapGrace = 30;
|
||||
inline constexpr const char* kOpenrouterLabel = "openrouter";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "helpers.h"
|
||||
#include "py.h"
|
||||
#include "ports.h"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <typeinfo>
|
||||
@@ -142,6 +143,32 @@ json::Value clean_history(const json::Value& messages) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// tool_call ids issued so far: a tool result whose call was never issued (history cut, compaction, a dropped
|
||||
// call) is an orphan, and providers answer it with HTTP 400 for the whole request. Drop it loudly. Kept apart from
|
||||
// clean_history, which mirrors tai.py exactly (tests/agent_diff.py compares it case by case).
|
||||
json::Value drop_orphan_tool_results(const json::Value& messages) {
|
||||
json::Value out = json::Value::array();
|
||||
std::vector<json::Value> issued;
|
||||
int orphans = 0;
|
||||
for (const auto& m : messages.items()) {
|
||||
const json::Value& r = m.get("role");
|
||||
if (r.is_string() && r.str() == "assistant" && m.get("tool_calls").truthy()) {
|
||||
for (const auto& call : m.get("tool_calls").items()) { issued.push_back(call.get("id")); }
|
||||
}
|
||||
if (r.is_string() && r.str() == "tool" && std::find(issued.begin(), issued.end(), m.get("tool_call_id")) == issued.end()) {
|
||||
++orphans;
|
||||
continue;
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if (orphans > 0) {
|
||||
json::Value meta = json::Value::object();
|
||||
meta.set("orphans", orphans);
|
||||
log_event("WARNING", "llm", "orphan_tool_result", "dropped " + std::to_string(orphans) + " tool result(s) without their tool call", "", "", meta);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int64_t conversation_tokens(const json::Value& messages, const json::Value& tools) {
|
||||
int64_t total = 0;
|
||||
for (const auto& item : messages.items()) {
|
||||
|
||||
@@ -30,6 +30,8 @@ std::string tool_args_text(const json::Value& raw);
|
||||
std::string action_signature(const std::string& name, const json::Value& raw_arguments);
|
||||
std::string result_digest(const std::string& result);
|
||||
json::Value clean_history(const json::Value& messages);
|
||||
// Drops tool results whose tool call was never issued (providers reject them with HTTP 400); logs a WARNING.
|
||||
json::Value drop_orphan_tool_results(const json::Value& messages);
|
||||
bool transient_failure(const BackendError& exc);
|
||||
bool transient_failure_text(const std::string& what, int status);
|
||||
int64_t conversation_tokens(const json::Value& messages, const json::Value& tools = json::Value());
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
#include "wa_appstate.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
|
||||
#include "../llm/json.h"
|
||||
#include "wa_consts.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
namespace {
|
||||
Bytes be64(uint64_t v) {
|
||||
Bytes out(8);
|
||||
for (int i = 0; i < 8; ++i) out[7 - i] = static_cast<uint8_t>(v >> (8 * i));
|
||||
return out;
|
||||
}
|
||||
Bytes bytes_of(const std::string& s) { return Bytes(s.begin(), s.end()); }
|
||||
Bytes last32(const Bytes& b) {
|
||||
if (b.size() < 32) return {};
|
||||
return Bytes(b.end() - 32, b.end());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------- keys ----------------
|
||||
|
||||
WaAppStateKeys WaAppStateKeys::expand(const Bytes& key_data) {
|
||||
Bytes e = WaCrypto::hkdf_sha256(key_data, Bytes{}, bytes_of("WhatsApp Mutation Keys"), 160);
|
||||
auto piece = [&](size_t off) { return Bytes(e.begin() + off, e.begin() + off + 32); };
|
||||
WaAppStateKeys k;
|
||||
k.index = piece(0);
|
||||
k.value_encryption = piece(32);
|
||||
k.value_mac = piece(64);
|
||||
k.snapshot_mac = piece(96);
|
||||
k.patch_mac = piece(128);
|
||||
return k;
|
||||
}
|
||||
|
||||
// ---------------- LTHash ----------------
|
||||
|
||||
Bytes WaLtHash::zero() { return Bytes(kSize, 0); }
|
||||
|
||||
void WaLtHash::subtract_then_add(Bytes& state, const std::vector<Bytes>& subtract, const std::vector<Bytes>& add) {
|
||||
if (state.size() != kSize) throw std::runtime_error("wa lthash: state must be 128 bytes");
|
||||
auto apply = [&](const Bytes& item, bool sub) {
|
||||
Bytes expanded = WaCrypto::hkdf_sha256(item, Bytes{}, bytes_of("WhatsApp Patch Integrity"), kSize);
|
||||
for (size_t i = 0; i < kSize; i += 2) {
|
||||
uint16_t x = static_cast<uint16_t>(state[i] | (state[i + 1] << 8));
|
||||
uint16_t y = static_cast<uint16_t>(expanded[i] | (expanded[i + 1] << 8));
|
||||
uint16_t r = sub ? static_cast<uint16_t>(x - y) : static_cast<uint16_t>(x + y);
|
||||
state[i] = static_cast<uint8_t>(r);
|
||||
state[i + 1] = static_cast<uint8_t>(r >> 8);
|
||||
}
|
||||
};
|
||||
for (const auto& s : subtract) apply(s, true);
|
||||
for (const auto& a : add) apply(a, false);
|
||||
}
|
||||
|
||||
// ---------------- MACs and values ----------------
|
||||
|
||||
Bytes WaAppStateCrypto::value_mac_of(const Bytes& value_blob) { return last32(value_blob); }
|
||||
|
||||
Bytes WaAppStateCrypto::content_mac(int op, const Bytes& content, const Bytes& key_id, const Bytes& value_mac_key) {
|
||||
Bytes data;
|
||||
data.reserve(1 + key_id.size() + content.size() + 8);
|
||||
data.push_back(static_cast<uint8_t>(op + 1));
|
||||
for (uint8_t b : key_id) data.push_back(b);
|
||||
for (uint8_t b : content) data.push_back(b);
|
||||
for (uint8_t b : be64(key_id.size() + 1)) data.push_back(b);
|
||||
Bytes full = WaCrypto::hmac_sha512(value_mac_key, data);
|
||||
full.resize(32);
|
||||
return full;
|
||||
}
|
||||
|
||||
Bytes WaAppStateCrypto::index_mac(const Bytes& index_key, const Bytes& index_json) {
|
||||
return WaCrypto::hmac_sha256(index_key, index_json);
|
||||
}
|
||||
|
||||
Bytes WaAppStateCrypto::snapshot_mac(const Bytes& snapshot_key, const Bytes& hash, uint64_t version,
|
||||
const std::string& name) {
|
||||
return WaCrypto::hmac_sha256(snapshot_key, WaCrypto::concat({hash, be64(version), bytes_of(name)}));
|
||||
}
|
||||
|
||||
Bytes WaAppStateCrypto::patch_mac(const Bytes& patch_key, const Bytes& snapshot_mac, const std::vector<Bytes>& value_macs,
|
||||
uint64_t version, const std::string& name) {
|
||||
Bytes data = snapshot_mac;
|
||||
for (const auto& v : value_macs) data.insert(data.end(), v.begin(), v.end());
|
||||
Bytes tail = be64(version);
|
||||
data.insert(data.end(), tail.begin(), tail.end());
|
||||
Bytes n = bytes_of(name);
|
||||
data.insert(data.end(), n.begin(), n.end());
|
||||
return WaCrypto::hmac_sha256(patch_key, data);
|
||||
}
|
||||
|
||||
std::optional<Bytes> WaAppStateCrypto::open_value(const WaAppStateKeys& keys, int op, const Bytes& key_id,
|
||||
const Bytes& value_blob, std::string& why) {
|
||||
if (value_blob.size() < 16 + 16 + 32) {
|
||||
why = "value blob too short";
|
||||
return std::nullopt;
|
||||
}
|
||||
Bytes content(value_blob.begin(), value_blob.end() - 32);
|
||||
Bytes mac(value_blob.end() - 32, value_blob.end());
|
||||
if (!WaCrypto::const_time_eq(content_mac(op, content, key_id, keys.value_mac), mac)) {
|
||||
why = "value MAC mismatch";
|
||||
return std::nullopt;
|
||||
}
|
||||
Bytes iv(content.begin(), content.begin() + 16);
|
||||
Bytes ct(content.begin() + 16, content.end());
|
||||
if (ct.empty() || ct.size() % 16 != 0) {
|
||||
why = "value ciphertext is not whole AES blocks";
|
||||
return std::nullopt;
|
||||
}
|
||||
auto plain = WaCrypto::pkcs7_unpad(WaCrypto::aes_cbc_decrypt_nopad(keys.value_encryption, iv, ct));
|
||||
if (!plain) why = "value padding invalid";
|
||||
return plain;
|
||||
}
|
||||
|
||||
// ---------------- sync ----------------
|
||||
|
||||
WaAppStateSync::WaAppStateSync(WaStorePort& store, LogFn log) : store_(store), log_(std::move(log)) {}
|
||||
|
||||
const WaAppStateKeys* WaAppStateSync::keys_for(const Bytes& key_id) {
|
||||
auto it = keys_.find(key_id);
|
||||
if (it != keys_.end()) return &it->second;
|
||||
auto data = store_.app_key_data(key_id);
|
||||
if (!data) return nullptr;
|
||||
return &keys_.emplace(key_id, WaAppStateKeys::expand(*data)).first->second;
|
||||
}
|
||||
|
||||
WaAppStateSync::Decode WaAppStateSync::decode(const std::vector<WaSyncdMutation>& in, std::vector<Mutation>& out,
|
||||
Report& rep, std::string& why) {
|
||||
for (const auto& m : in) {
|
||||
const WaAppStateKeys* k = keys_for(m.record.key_id);
|
||||
if (k == nullptr) {
|
||||
bool seen = false;
|
||||
for (const auto& id : rep.missing_key_ids) seen = seen || id == m.record.key_id;
|
||||
if (!seen) rep.missing_key_ids.push_back(m.record.key_id);
|
||||
why = "no app-state key for id";
|
||||
return Decode::MissingKey;
|
||||
}
|
||||
Mutation mu;
|
||||
mu.remove = m.remove;
|
||||
mu.key_id = m.record.key_id;
|
||||
mu.index_mac = m.record.index_mac;
|
||||
mu.value_mac = WaAppStateCrypto::value_mac_of(m.record.value_blob);
|
||||
if (mu.index_mac.size() != 32 || mu.value_mac.size() != 32) {
|
||||
why = "record index or value has the wrong size";
|
||||
return Decode::Failed;
|
||||
}
|
||||
auto plain = WaAppStateCrypto::open_value(*k, m.remove ? 1 : 0, mu.key_id, m.record.value_blob, why);
|
||||
if (!plain) return Decode::Failed;
|
||||
mu.action = parse_sync_action_data(*plain);
|
||||
Bytes index_json = bytes_of(mu.action.index_json);
|
||||
if (!WaCrypto::const_time_eq(WaAppStateCrypto::index_mac(k->index, index_json), mu.index_mac)) {
|
||||
why = "index MAC mismatch";
|
||||
return Decode::Failed;
|
||||
}
|
||||
detz::json::Value idx = detz::json::Value::loads(mu.action.index_json);
|
||||
if (!idx.is_array()) {
|
||||
why = "index is not a JSON array";
|
||||
return Decode::Failed;
|
||||
}
|
||||
for (const auto& item : idx.items()) {
|
||||
if (!item.is_string()) {
|
||||
why = "index element is not a string";
|
||||
return Decode::Failed;
|
||||
}
|
||||
mu.index.push_back(item.str());
|
||||
}
|
||||
out.push_back(std::move(mu));
|
||||
}
|
||||
return Decode::Ok;
|
||||
}
|
||||
|
||||
void WaAppStateSync::apply_contact(const Mutation& m, Report& rep) {
|
||||
if (m.index.size() < 2 || m.index[0] != "contact") return;
|
||||
const std::string& jid = m.index[1];
|
||||
if (m.remove) {
|
||||
store_.remove_contact(jid);
|
||||
rep.contacts_removed++;
|
||||
return;
|
||||
}
|
||||
const std::string& full = m.action.contact_full_name;
|
||||
const std::string& first = m.action.contact_first_name;
|
||||
const std::string name = !full.empty() ? full : first;
|
||||
if (name.empty()) {
|
||||
rep.contacts_unnamed++;
|
||||
return;
|
||||
}
|
||||
store_.put_contact(jid, name);
|
||||
rep.contacts_saved++;
|
||||
}
|
||||
|
||||
WaAppStateSync::Report WaAppStateSync::sync(const std::string& name, const IqFn& iq, const DownloadFn& download) {
|
||||
Report rep;
|
||||
try {
|
||||
auto st = store_.app_state_version(name);
|
||||
rep.version = st ? st->version : 0;
|
||||
Bytes hash = st && st->hash.size() == WaLtHash::kSize ? st->hash : WaLtHash::zero();
|
||||
bool want_snapshot = rep.version == 0; // whatsmeow: version 0 is a full sync
|
||||
int patches_done = 0;
|
||||
int mutations_done = 0;
|
||||
|
||||
for (int round = 0;; ++round) {
|
||||
if (round >= WaConsts::kWaAppStatePatchMax) {
|
||||
rep.capped = true;
|
||||
log_("app state " + name + ": request limit reached, the rest syncs on a later login");
|
||||
break;
|
||||
}
|
||||
WaNode collection("collection");
|
||||
collection.attrs["name"] = name;
|
||||
collection.attrs["return_snapshot"] = want_snapshot ? "true" : "false";
|
||||
if (!want_snapshot) collection.attrs["version"] = std::to_string(rep.version);
|
||||
WaNode sync_node("sync");
|
||||
sync_node.content = WaNode::Children{collection};
|
||||
WaNode request("iq");
|
||||
request.attrs["to"] = WaConsts::kServerJid;
|
||||
request.attrs["type"] = "set";
|
||||
request.attrs["xmlns"] = "w:sync:app:state";
|
||||
request.content = WaNode::Children{sync_node};
|
||||
|
||||
auto reply = iq(request);
|
||||
if (!reply) {
|
||||
rep.error = "no reply to the app state request";
|
||||
break;
|
||||
}
|
||||
const WaNode* sync_r = reply->child("sync");
|
||||
const WaNode* col = sync_r ? sync_r->child("collection") : nullptr;
|
||||
if (col == nullptr) {
|
||||
rep.error = "app state reply has no collection";
|
||||
break;
|
||||
}
|
||||
if (col->attr("type") == "error") {
|
||||
rep.error = "server returned an error for " + name;
|
||||
break;
|
||||
}
|
||||
bool has_more = col->attr("has_more_patches") == "true";
|
||||
std::string why;
|
||||
|
||||
// snapshot (only when asked for, i.e. at version 0)
|
||||
if (const WaNode* sn = col->child("snapshot"); sn != nullptr && sn->bytes() != nullptr) {
|
||||
if (!want_snapshot) {
|
||||
rep.error = "server sent a snapshot that was not asked for";
|
||||
break;
|
||||
}
|
||||
auto blob = download(parse_external_blob_ref(*sn->bytes()), why);
|
||||
if (!blob) {
|
||||
rep.error = "snapshot download failed: " + why;
|
||||
break;
|
||||
}
|
||||
WaSyncdSnapshot snap = parse_syncd_snapshot(*blob);
|
||||
std::vector<WaSyncdMutation> as_mutations;
|
||||
for (const auto& r : snap.records) as_mutations.push_back(WaSyncdMutation{false, r});
|
||||
std::vector<Mutation> muts;
|
||||
Decode d = decode(as_mutations, muts, rep, why);
|
||||
if (d == Decode::MissingKey) {
|
||||
rep.error = "missing app state key for the snapshot";
|
||||
break;
|
||||
}
|
||||
if (d == Decode::Failed) {
|
||||
rep.rejected++;
|
||||
rep.error = "snapshot rejected: " + why;
|
||||
log_("app state " + name + ": snapshot rejected: " + why);
|
||||
break;
|
||||
}
|
||||
std::vector<Bytes> added;
|
||||
for (const auto& m : muts) added.push_back(m.value_mac);
|
||||
Bytes snap_hash = WaLtHash::zero();
|
||||
WaLtHash::subtract_then_add(snap_hash, {}, added);
|
||||
const WaAppStateKeys* keys = keys_for(snap.key_id);
|
||||
if (keys == nullptr) {
|
||||
rep.error = "missing app state key for the snapshot";
|
||||
break;
|
||||
}
|
||||
if (!WaCrypto::const_time_eq(WaAppStateCrypto::snapshot_mac(keys->snapshot_mac, snap_hash, snap.version, name), snap.mac)) {
|
||||
rep.rejected++;
|
||||
rep.error = "snapshot MAC mismatch";
|
||||
log_("app state " + name + ": snapshot rejected: snapshot MAC mismatch");
|
||||
break;
|
||||
}
|
||||
for (const auto& m : muts) {
|
||||
apply_contact(m, rep);
|
||||
store_.put_app_state_value_mac(name, m.index_mac, m.value_mac);
|
||||
}
|
||||
hash = snap_hash;
|
||||
rep.version = snap.version;
|
||||
store_.put_app_state_version(name, {rep.version, hash});
|
||||
mutations_done += static_cast<int>(muts.size());
|
||||
rep.mutations_applied += static_cast<int>(muts.size());
|
||||
want_snapshot = false;
|
||||
}
|
||||
|
||||
// patches, in version order
|
||||
bool stop = false;
|
||||
if (const WaNode* pn = col->child("patches"); pn != nullptr) {
|
||||
for (const WaNode* pnode : pn->all("patch")) {
|
||||
if (pnode->bytes() == nullptr) continue;
|
||||
if (patches_done >= WaConsts::kWaAppStatePatchMax) {
|
||||
rep.capped = true;
|
||||
log_("app state " + name + ": patch limit reached (" + std::to_string(WaConsts::kWaAppStatePatchMax) + "), the rest syncs on a later login");
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
WaSyncdPatch patch = parse_syncd_patch(*pnode->bytes());
|
||||
if (patch.external_mutations) {
|
||||
auto blob = download(*patch.external_mutations, why);
|
||||
if (!blob) {
|
||||
rep.error = "patch blob download failed: " + why;
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
patch.mutations = parse_syncd_mutations(*blob);
|
||||
if (patch.mutations.empty()) {
|
||||
rep.error = "external patch blob has no mutations";
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mutations_done + static_cast<int>(patch.mutations.size()) > WaConsts::kWaAppStateMutationMax) {
|
||||
rep.capped = true;
|
||||
log_("app state " + name + ": mutation limit reached (" + std::to_string(WaConsts::kWaAppStateMutationMax) + "), the rest syncs on a later login");
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
std::vector<Mutation> muts;
|
||||
Decode d = decode(patch.mutations, muts, rep, why);
|
||||
if (d == Decode::MissingKey) {
|
||||
rep.error = "missing app state key for patch v" + std::to_string(patch.version);
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
if (d == Decode::Failed) {
|
||||
rep.rejected++;
|
||||
rep.error = "patch v" + std::to_string(patch.version) + " rejected: " + why;
|
||||
log_("app state " + name + ": " + rep.error + " (skipped, sync stopped)");
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
// LTHash: subtract the previous value of every touched index, add the new SET values.
|
||||
std::vector<Bytes> removed, added;
|
||||
for (size_t i = 0; i < muts.size(); ++i) {
|
||||
const Mutation& m = muts[i];
|
||||
bool found = false;
|
||||
std::optional<Bytes> prev;
|
||||
for (size_t j = i; j-- > 0;) {
|
||||
if (muts[j].index_mac != m.index_mac) continue;
|
||||
found = true;
|
||||
if (!muts[j].remove) prev = muts[j].value_mac;
|
||||
break;
|
||||
}
|
||||
if (!found) prev = store_.app_state_value_mac(name, m.index_mac);
|
||||
if (prev) removed.push_back(*prev);
|
||||
if (!m.remove) added.push_back(m.value_mac);
|
||||
}
|
||||
Bytes new_hash = hash;
|
||||
WaLtHash::subtract_then_add(new_hash, removed, added);
|
||||
const WaAppStateKeys* keys = keys_for(patch.key_id);
|
||||
if (keys == nullptr) {
|
||||
rep.error = "missing app state key for patch v" + std::to_string(patch.version);
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
if (!WaCrypto::const_time_eq(WaAppStateCrypto::snapshot_mac(keys->snapshot_mac, new_hash, patch.version, name),
|
||||
patch.snapshot_mac)) {
|
||||
rep.rejected++;
|
||||
rep.error = "patch v" + std::to_string(patch.version) + " rejected: snapshot MAC mismatch";
|
||||
log_("app state " + name + ": " + rep.error + " (skipped, sync stopped)");
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
std::vector<Bytes> all_value_macs;
|
||||
for (const auto& m : muts) all_value_macs.push_back(m.value_mac);
|
||||
if (!WaCrypto::const_time_eq(WaAppStateCrypto::patch_mac(keys->patch_mac, patch.snapshot_mac, all_value_macs, patch.version, name),
|
||||
patch.patch_mac)) {
|
||||
rep.rejected++;
|
||||
rep.error = "patch v" + std::to_string(patch.version) + " rejected: patch MAC mismatch";
|
||||
log_("app state " + name + ": " + rep.error + " (skipped, sync stopped)");
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
// verified: persist
|
||||
for (const auto& m : muts) {
|
||||
apply_contact(m, rep);
|
||||
if (m.remove) store_.delete_app_state_value_mac(name, m.index_mac);
|
||||
else store_.put_app_state_value_mac(name, m.index_mac, m.value_mac);
|
||||
}
|
||||
hash = new_hash;
|
||||
rep.version = patch.version;
|
||||
store_.put_app_state_version(name, {rep.version, hash});
|
||||
patches_done++;
|
||||
rep.patches_applied++;
|
||||
mutations_done += static_cast<int>(muts.size());
|
||||
rep.mutations_applied += static_cast<int>(muts.size());
|
||||
}
|
||||
}
|
||||
if (stop || !has_more) break;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
rep.error = std::string("app state sync stopped: ") + e.what();
|
||||
}
|
||||
if (!rep.error.empty()) log_("app state " + name + ": " + rep.error);
|
||||
log_("app state " + name + ": version " + std::to_string(rep.version) + ", " + std::to_string(rep.patches_applied) +
|
||||
" patches, " + std::to_string(rep.contacts_saved) + " contacts saved, " + std::to_string(rep.contacts_removed) +
|
||||
" removed, " + std::to_string(rep.contacts_unnamed) + " unnamed");
|
||||
return rep;
|
||||
}
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
// WaAppState: WhatsApp app-state sync for the collection that carries the phone's
|
||||
// contact list (critical_unblock_low, index "contact"). Ported from whatsmeow
|
||||
// (MPL-2.0): appstate/keys.go (key expansion), appstate/hash.go and
|
||||
// appstate/lthash (LTHash, HKDF-SHA256 flavour), appstate/decode.go (MAC checks,
|
||||
// decryption, patch chain) and appstate.go (the iq and the contact handling).
|
||||
// Golden vectors come from a Go program that calls whatsmeow's own lthash and
|
||||
// protobuf packages: tests/whatsapp_data/appstate_vectors.json. See
|
||||
// docs/whatsapp.md section 14.
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "wa_client.h"
|
||||
#include "wa_crypto.h"
|
||||
#include "wa_node.h"
|
||||
#include "wa_proto.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
// The five keys expanded from one 32-byte app-state sync key (whatsmeow expandAppStateKeys:
|
||||
// HKDF-SHA256, empty salt, info "WhatsApp Mutation Keys", 160 bytes split in 32-byte pieces).
|
||||
struct WaAppStateKeys {
|
||||
Bytes index, value_encryption, value_mac, snapshot_mac, patch_mac;
|
||||
static WaAppStateKeys expand(const Bytes& key_data);
|
||||
};
|
||||
|
||||
// LTHash over a 128-byte state (whatsmeow appstate/lthash WAPatchIntegrity): each 32-byte
|
||||
// item is expanded with HKDF-SHA256 (info "WhatsApp Patch Integrity", 128 bytes) and added
|
||||
// or subtracted per little-endian uint16 lane, so add-then-remove returns the original state.
|
||||
class WaLtHash {
|
||||
public:
|
||||
static constexpr size_t kSize = 128;
|
||||
static Bytes zero();
|
||||
static void subtract_then_add(Bytes& state, const std::vector<Bytes>& subtract, const std::vector<Bytes>& add);
|
||||
};
|
||||
|
||||
// MACs and value decryption of app-state records (whatsmeow appstate/hash.go and decode.go).
|
||||
class WaAppStateCrypto {
|
||||
public:
|
||||
// Value blob layout: iv(16) || AES-256-CBC ciphertext || valueMAC(32). The last 32 bytes are the MAC.
|
||||
static Bytes value_mac_of(const Bytes& value_blob); // empty when the blob is shorter than 32 bytes
|
||||
// HMAC-SHA512(valueMacKey, [op+1] || keyId || content || BE64(len(keyId)+1))[:32]; op 0 = SET, 1 = REMOVE.
|
||||
static Bytes content_mac(int op, const Bytes& content, const Bytes& key_id, const Bytes& value_mac_key);
|
||||
// HMAC-SHA256(indexKey, index JSON).
|
||||
static Bytes index_mac(const Bytes& index_key, const Bytes& index_json);
|
||||
// HMAC-SHA256(snapshotMacKey, hash || BE64(version) || name).
|
||||
static Bytes snapshot_mac(const Bytes& snapshot_key, const Bytes& hash, uint64_t version, const std::string& name);
|
||||
// HMAC-SHA256(patchMacKey, snapshotMac || valueMac of every mutation || BE64(version) || name).
|
||||
static Bytes patch_mac(const Bytes& patch_key, const Bytes& snapshot_mac, const std::vector<Bytes>& value_macs,
|
||||
uint64_t version, const std::string& name);
|
||||
// Verifies the content MAC, then decrypts. nullopt with `why` set on any failure.
|
||||
static std::optional<Bytes> open_value(const WaAppStateKeys& keys, int op, const Bytes& key_id,
|
||||
const Bytes& value_blob, std::string& why);
|
||||
};
|
||||
|
||||
// One collection sync: fetch snapshot and patches over the iq, verify, apply, persist.
|
||||
class WaAppStateSync {
|
||||
public:
|
||||
// Sends an iq (the callee sets its id) and returns the reply, nullopt on timeout.
|
||||
using IqFn = std::function<std::optional<WaNode>(WaNode iq)>;
|
||||
// Fetches and decrypts an external blob reference; nullopt with `why` on failure.
|
||||
using DownloadFn = std::function<std::optional<Bytes>(const WaExternalBlobRef& ref, std::string& why)>;
|
||||
using LogFn = std::function<void(const std::string&)>;
|
||||
|
||||
struct Report {
|
||||
uint64_t version{0}; // collection version after the sync
|
||||
int patches_applied{0};
|
||||
int mutations_applied{0};
|
||||
int contacts_saved{0};
|
||||
int contacts_removed{0};
|
||||
int contacts_unnamed{0}; // SET without fullName or firstName: not stored
|
||||
int rejected{0}; // patch or snapshot failed a MAC or hash check: not stored, sync stopped
|
||||
bool capped{false}; // stopped at kWaAppStatePatchMax or kWaAppStateMutationMax
|
||||
std::vector<Bytes> missing_key_ids; // key ids the data needs and the store does not hold
|
||||
std::string error; // non-empty when the sync stopped early
|
||||
};
|
||||
|
||||
WaAppStateSync(WaStorePort& store, LogFn log);
|
||||
|
||||
static constexpr const char* kContactsCollection = "critical_unblock_low";
|
||||
|
||||
// Runs the sync for one collection until has_more_patches is false or a cap or error stops it.
|
||||
Report sync(const std::string& name, const IqFn& iq, const DownloadFn& download);
|
||||
|
||||
private:
|
||||
WaStorePort& store_;
|
||||
LogFn log_;
|
||||
std::map<Bytes, WaAppStateKeys> keys_; // expanded keys for this run
|
||||
|
||||
struct Mutation {
|
||||
bool remove{false};
|
||||
Bytes index_mac, value_mac, key_id;
|
||||
std::vector<std::string> index;
|
||||
WaSyncActionData action;
|
||||
};
|
||||
enum class Decode { Ok, MissingKey, Failed };
|
||||
// Decrypts and MAC-checks every mutation of a list. MissingKey records the key id in rep.missing_key_ids.
|
||||
Decode decode(const std::vector<WaSyncdMutation>& in, std::vector<Mutation>& out, Report& rep, std::string& why);
|
||||
const WaAppStateKeys* keys_for(const Bytes& key_id);
|
||||
// Contact effects of one mutation (only index[0] == "contact").
|
||||
void apply_contact(const Mutation& m, Report& rep);
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -143,6 +143,8 @@ class Decoder {
|
||||
std::string user = read_string(read_byte());
|
||||
std::string server = read_string(read_byte());
|
||||
if (server.empty()) throw WireError("invalid jid pair");
|
||||
// A server-only JID (user empty, e.g. s.whatsapp.net) is the bare server name, as whatsmeow's JID.String() prints it.
|
||||
if (user.empty()) return server;
|
||||
return user + "@" + server;
|
||||
}
|
||||
std::string read_ad_jid() {
|
||||
@@ -361,6 +363,14 @@ class Encoder {
|
||||
int domain_type{0};
|
||||
bool has_device{false};
|
||||
};
|
||||
// Bare server names (no user part). whatsmeow sends these as JID pairs with an empty user.
|
||||
static bool is_server_only(const std::string& s) {
|
||||
static const char* kServers[] = {"s.whatsapp.net", "g.us", "lid", "broadcast", "call", "newsletter", "hosted", "hosted.lid"};
|
||||
for (const char* srv : kServers) {
|
||||
if (s == srv) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static bool parse_jid(const std::string& jid, JidParts& out) {
|
||||
size_t at = jid.find('@');
|
||||
if (at == std::string::npos) return false;
|
||||
@@ -429,6 +439,12 @@ class Encoder {
|
||||
write_jid(j);
|
||||
return;
|
||||
}
|
||||
if (is_server_only(s)) {
|
||||
push(WaBinary::JID_PAIR);
|
||||
push(WaBinary::LIST_EMPTY);
|
||||
write_string(s);
|
||||
return;
|
||||
}
|
||||
write_string_raw(s);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
|
||||
#include "seal_base64.h"
|
||||
#include "wa_appstate.h"
|
||||
#include "wa_binary.h"
|
||||
#include "wa_consts.h"
|
||||
#include "wa_media.h"
|
||||
#include "wa_proto.h"
|
||||
#include "wa_receiver.h"
|
||||
#include "wa_signal.h"
|
||||
|
||||
namespace detz::wa {
|
||||
@@ -23,6 +29,9 @@ Bytes from_bytes_content(const WaNode* n) {
|
||||
}
|
||||
constexpr std::array<uint8_t, 2> kAdvAccountPrefix = {6, 0};
|
||||
constexpr std::array<uint8_t, 2> kAdvDevicePrefix = {6, 1};
|
||||
// whatsmeow AdvHostedAccountSignaturePrefix: used for hosted (ADVEncryptionType_HOSTED) accounts.
|
||||
constexpr std::array<uint8_t, 2> kAdvHostedAccountPrefix = {6, 5};
|
||||
constexpr int kAdvEncryptionHosted = 1; // ADVEncryptionType_HOSTED
|
||||
} // namespace
|
||||
|
||||
WaClient::WaClient(WaStorePort& store) : store_(store) {
|
||||
@@ -135,6 +144,20 @@ void WaClient::send_ack(const WaNode& node) {
|
||||
}
|
||||
|
||||
void WaClient::dispatch(const WaNode& node) {
|
||||
// DETZ_WA_TRACE=1 logs every node's tag and attributes, so a protocol exchange can be read without a packet capture.
|
||||
static const bool trace = std::getenv("DETZ_WA_TRACE") != nullptr;
|
||||
if (trace) {
|
||||
std::string line = "recv <" + node.tag + ">";
|
||||
for (const auto& kv : node.attrs) line += " " + kv.first + "=" + kv.second;
|
||||
if (const auto* kids = node.children()) {
|
||||
for (const auto& k : *kids) {
|
||||
line += " [" + k.tag;
|
||||
for (const auto& kv : k.attrs) line += " " + kv.first + "=" + kv.second;
|
||||
line += "]";
|
||||
}
|
||||
}
|
||||
log(line);
|
||||
}
|
||||
// A malformed or unexpected node must never abort the process: log and move on.
|
||||
try {
|
||||
dispatch_inner(node);
|
||||
@@ -154,8 +177,15 @@ void WaClient::dispatch_inner(const WaNode& node) {
|
||||
} else if (node.tag == "success") {
|
||||
logged_in_ = true;
|
||||
log("logged in");
|
||||
} else if (node.tag == "failure" || node.tag == "stream:error") {
|
||||
log("server " + node.tag + ": " + node.attr("reason") + node.attr("code"));
|
||||
} else if (node.tag == "failure") {
|
||||
failure_code_ = std::atoi(node.attr("reason").c_str());
|
||||
log("server failure: " + node.attr("reason"));
|
||||
} else if (node.tag == "stream:error") {
|
||||
if (node.attr("code") == "515") restart_seen_ = true;
|
||||
log("server stream:error " + node.attr("code"));
|
||||
} else if (node.tag == "message") {
|
||||
send_ack(node);
|
||||
handle_message(node);
|
||||
} else if (node.tag == "ib" || node.tag == "iq") {
|
||||
// ignored informational nodes
|
||||
}
|
||||
@@ -164,7 +194,7 @@ void WaClient::dispatch_inner(const WaNode& node) {
|
||||
void WaClient::handle_pair_device(const WaNode& iq) {
|
||||
// ack the iq
|
||||
WaNode result("iq");
|
||||
result.attrs["to"] = iq.attr("from");
|
||||
result.attrs["to"] = WaConsts::kServerJid;
|
||||
result.attrs["id"] = iq.attr("id");
|
||||
result.attrs["type"] = "result";
|
||||
send_node(result);
|
||||
@@ -205,8 +235,11 @@ void WaClient::handle_pair_success(const WaNode& iq) {
|
||||
log("pair: bad device identity container");
|
||||
return;
|
||||
}
|
||||
// verify HMAC over details with advSecret (non-hosted path)
|
||||
Bytes mac = WaCrypto::hmac_sha256(Bytes(device_.adv_secret.begin(), device_.adv_secret.end()), container->details);
|
||||
// HMAC over (hosted prefix if the OUTER account_type is HOSTED) || details, keyed with advSecret.
|
||||
Bytes hmac_msg;
|
||||
if (container->account_type == kAdvEncryptionHosted) hmac_msg.assign(kAdvHostedAccountPrefix.begin(), kAdvHostedAccountPrefix.end());
|
||||
hmac_msg.insert(hmac_msg.end(), container->details.begin(), container->details.end());
|
||||
Bytes mac = WaCrypto::hmac_sha256(Bytes(device_.adv_secret.begin(), device_.adv_secret.end()), hmac_msg);
|
||||
if (!WaCrypto::const_time_eq(mac, container->hmac)) {
|
||||
log("pair: HMAC mismatch");
|
||||
return;
|
||||
@@ -221,12 +254,14 @@ void WaClient::handle_pair_success(const WaNode& iq) {
|
||||
log("pair: bad device identity details");
|
||||
return;
|
||||
}
|
||||
// verify account signature: prefix(6,0) || details || our-identity-pub
|
||||
// verify account signature: prefix || details || our-identity-pub. The prefix comes from the INNER
|
||||
// device_type (hosted [6,5], else [6,0]), not the outer account_type (oxidezap whatsapp-rust #1439).
|
||||
if (ident->account_signature_key.size() != 32 || ident->account_signature.size() != 64) {
|
||||
log("pair: bad account signature fields");
|
||||
return;
|
||||
}
|
||||
Bytes acc_msg(kAdvAccountPrefix.begin(), kAdvAccountPrefix.end());
|
||||
Bytes acc_msg = details->device_type == kAdvEncryptionHosted ? Bytes(kAdvHostedAccountPrefix.begin(), kAdvHostedAccountPrefix.end())
|
||||
: Bytes(kAdvAccountPrefix.begin(), kAdvAccountPrefix.end());
|
||||
acc_msg.insert(acc_msg.end(), ident->details.begin(), ident->details.end());
|
||||
acc_msg.insert(acc_msg.end(), device_.identity_key.pub.begin(), device_.identity_key.pub.end());
|
||||
std::array<uint8_t, 32> acc_key{};
|
||||
@@ -263,7 +298,7 @@ void WaClient::handle_pair_success(const WaNode& iq) {
|
||||
WaNode outer("pair-device-sign");
|
||||
outer.content = WaNode::Children{sign};
|
||||
WaNode result("iq");
|
||||
result.attrs["to"] = iq.attr("from");
|
||||
result.attrs["to"] = WaConsts::kServerJid;
|
||||
result.attrs["type"] = "result";
|
||||
result.attrs["id"] = iq.attr("id");
|
||||
result.content = WaNode::Children{outer};
|
||||
@@ -278,12 +313,99 @@ void WaClient::run_until(const std::function<bool()>& stop, int timeout_seconds)
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (stop && stop()) return;
|
||||
if (paired_event_) 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;
|
||||
auto node = recv_node(2);
|
||||
if (node) dispatch(*node);
|
||||
}
|
||||
}
|
||||
|
||||
bool WaClient::await_pair_restart(int timeout_seconds) {
|
||||
restart_seen_ = false;
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (restart_seen_) return true;
|
||||
if (!socket_open()) return true; // the server closed after pairing
|
||||
auto node = recv_node(2);
|
||||
if (node) dispatch(*node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool WaClient::login(int timeout_seconds) {
|
||||
disconnect();
|
||||
logged_in_ = false;
|
||||
failure_code_ = 0;
|
||||
if (!connect()) return false;
|
||||
run_until([&]() { return logged_in_ || failure_code_ != 0; }, timeout_seconds);
|
||||
if (logged_in_) upload_prekeys();
|
||||
if (logged_in_) {
|
||||
try {
|
||||
sync_app_state();
|
||||
} catch (const std::exception& e) {
|
||||
log(std::string("app state sync failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
return logged_in_;
|
||||
}
|
||||
|
||||
bool WaClient::upload_prekeys() {
|
||||
WaNode count_iq("iq");
|
||||
count_iq.attrs["to"] = WaConsts::kServerJid;
|
||||
count_iq.attrs["type"] = "get";
|
||||
count_iq.attrs["xmlns"] = "encrypt";
|
||||
count_iq.attrs["id"] = next_iq_id();
|
||||
count_iq.content = WaNode::Children{WaNode("count")};
|
||||
auto count_reply = send_iq(count_iq, 30);
|
||||
if (!count_reply) {
|
||||
log("prekeys: no count reply");
|
||||
return false;
|
||||
}
|
||||
const WaNode* count = count_reply->child("count");
|
||||
int have = count ? std::atoi(count->attr("value").c_str()) : 0;
|
||||
if (have >= WaConsts::kWaMinPrekeys) return true;
|
||||
|
||||
// 3-byte big-endian key ids, as whatsmeow preKeyNode
|
||||
auto id3 = [](uint32_t id) { return Bytes{static_cast<uint8_t>(id >> 16), static_cast<uint8_t>(id >> 8), static_cast<uint8_t>(id)}; };
|
||||
auto bytes_node = [](const std::string& tag, const Bytes& b) {
|
||||
WaNode n(tag);
|
||||
n.content = b;
|
||||
return n;
|
||||
};
|
||||
WaNode::Children keys;
|
||||
for (int i = 0; i < WaConsts::kWaPrekeyBatch; ++i) {
|
||||
uint32_t id = device_.next_prekey_id++;
|
||||
auto kp = WaCrypto::generate_x25519();
|
||||
store_.put_prekey(id, Bytes(kp.priv.begin(), kp.priv.end()));
|
||||
WaNode key("key");
|
||||
key.content = WaNode::Children{bytes_node("id", id3(id)), bytes_node("value", Bytes(kp.pub.begin(), kp.pub.end()))};
|
||||
keys.push_back(key);
|
||||
}
|
||||
store_.save_device(device_);
|
||||
Bytes reg{static_cast<uint8_t>(device_.registration_id >> 24), static_cast<uint8_t>(device_.registration_id >> 16),
|
||||
static_cast<uint8_t>(device_.registration_id >> 8), static_cast<uint8_t>(device_.registration_id)};
|
||||
WaNode skey("skey");
|
||||
skey.content = WaNode::Children{bytes_node("id", id3(device_.signed_pre_key.key_id)),
|
||||
bytes_node("value", Bytes(device_.signed_pre_key.pair.pub.begin(), device_.signed_pre_key.pair.pub.end())),
|
||||
bytes_node("signature", Bytes(device_.signed_pre_key.signature.begin(), device_.signed_pre_key.signature.end()))};
|
||||
WaNode list("list");
|
||||
list.content = keys;
|
||||
WaNode iq("iq");
|
||||
iq.attrs["to"] = WaConsts::kServerJid;
|
||||
iq.attrs["type"] = "set";
|
||||
iq.attrs["xmlns"] = "encrypt";
|
||||
iq.attrs["id"] = next_iq_id();
|
||||
iq.content = WaNode::Children{bytes_node("registration", reg), bytes_node("type", Bytes{5}),
|
||||
bytes_node("identity", Bytes(device_.identity_key.pub.begin(), device_.identity_key.pub.end())), list, skey};
|
||||
auto reply = send_iq(iq, 30);
|
||||
if (!reply || reply->attr("type") != "result") {
|
||||
log("prekeys: upload rejected");
|
||||
return false;
|
||||
}
|
||||
log("prekeys: uploaded " + std::to_string(WaConsts::kWaPrekeyBatch));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string WaClient::addr_of(const std::string& device_jid) {
|
||||
// "user[:device]@server" -> "user.device" (device 0 when absent)
|
||||
size_t at = device_jid.find('@');
|
||||
@@ -298,6 +420,7 @@ 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;
|
||||
auto node = recv_node(2);
|
||||
if (!node) continue;
|
||||
if (node->tag == "iq" && node->attr("id") == id) return node;
|
||||
@@ -449,8 +572,8 @@ bool WaClient::ensure_sessions(const std::vector<std::string>& device_jids) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<WaNode> WaClient::encrypt_for_devices(const std::vector<std::string>& device_jids,
|
||||
const Bytes& plaintext) {
|
||||
std::vector<WaNode> WaClient::encrypt_for_devices(const std::vector<std::string>& device_jids, const Bytes& plaintext,
|
||||
const std::string& media_type) {
|
||||
std::vector<WaNode> nodes;
|
||||
for (const auto& d : device_jids) {
|
||||
auto blob = store_.load_session(addr_of(d));
|
||||
@@ -462,6 +585,7 @@ std::vector<WaNode> WaClient::encrypt_for_devices(const std::vector<std::string>
|
||||
WaNode enc("enc");
|
||||
enc.attrs["v"] = "2";
|
||||
enc.attrs["type"] = ct.type;
|
||||
if (!media_type.empty()) enc.attrs["mediatype"] = media_type;
|
||||
enc.content = ct.serialized;
|
||||
WaNode to("to");
|
||||
to.attrs["jid"] = d;
|
||||
@@ -471,23 +595,49 @@ std::vector<WaNode> WaClient::encrypt_for_devices(const std::vector<std::string>
|
||||
return nodes;
|
||||
}
|
||||
|
||||
std::vector<std::string> WaClient::own_other_devices() {
|
||||
if (!device_.paired()) return {};
|
||||
std::string me = addr_of(*device_.jid);
|
||||
std::string own_user = device_.jid->substr(0, device_.jid->find('@'));
|
||||
own_user = own_user.substr(0, own_user.find(':'));
|
||||
std::vector<std::string> out;
|
||||
for (const auto& d : usync_devices({own_user + "@s.whatsapp.net"})) {
|
||||
if (addr_of(d) != me) out.push_back(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> WaClient::send_message(const std::string& to_jid, const Bytes& message_plaintext,
|
||||
const Bytes& dsm_plaintext, const std::string& media_type) {
|
||||
(void)dsm_plaintext;
|
||||
const std::string& media_type) {
|
||||
if (!logged_in_) {
|
||||
log("send: not logged in");
|
||||
return std::nullopt;
|
||||
}
|
||||
std::string me = device_.paired() ? addr_of(*device_.jid) : std::string();
|
||||
auto devices = usync_devices({to_jid});
|
||||
devices.erase(std::remove_if(devices.begin(), devices.end(), [&](const std::string& d) { return addr_of(d) == me; }), devices.end());
|
||||
if (devices.empty()) {
|
||||
log("send: recipient not on WhatsApp or no devices");
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!ensure_sessions(devices)) {
|
||||
// our other devices (the phone) get the DeviceSentMessage copy so they show the message as sent
|
||||
auto own = own_other_devices();
|
||||
auto targets = devices;
|
||||
targets.insert(targets.end(), own.begin(), own.end());
|
||||
if (!ensure_sessions(targets)) {
|
||||
log("send: could not establish sessions");
|
||||
return std::nullopt;
|
||||
}
|
||||
auto part_nodes = encrypt_for_devices(devices, WaCrypto::pkcs7_pad(message_plaintext));
|
||||
auto part_nodes = encrypt_for_devices(devices, WaCrypto::pkcs7_pad(message_plaintext), media_type);
|
||||
if (!own.empty()) {
|
||||
WaMessage::DeviceSent ds;
|
||||
ds.destination_jid = to_jid;
|
||||
ds.inner = message_plaintext;
|
||||
WaMessage wrapped;
|
||||
wrapped.device_sent = ds;
|
||||
auto own_nodes = encrypt_for_devices(own, WaCrypto::pkcs7_pad(wrapped.encode()), media_type);
|
||||
part_nodes.insert(part_nodes.end(), own_nodes.begin(), own_nodes.end());
|
||||
}
|
||||
if (part_nodes.empty()) {
|
||||
log("send: no recipients encrypted");
|
||||
return std::nullopt;
|
||||
@@ -519,10 +669,12 @@ std::optional<std::string> WaClient::send_message(const std::string& to_jid, con
|
||||
// wait for the server ack
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (!socket_open()) break;
|
||||
auto node = recv_node(2);
|
||||
if (!node) continue;
|
||||
if (node->tag == "ack" && node->attr("id") == msg_id) return msg_id;
|
||||
if (node->tag == "message" || node->tag == "receipt") send_ack(*node);
|
||||
if (node->tag == "message") dispatch(*node); // acks and handles key shares
|
||||
else if (node->tag == "receipt") send_ack(*node);
|
||||
else dispatch(*node);
|
||||
}
|
||||
log("send: no ack received (message may still have been delivered)");
|
||||
@@ -532,22 +684,189 @@ std::optional<std::string> WaClient::send_message(const std::string& to_jid, con
|
||||
std::optional<std::string> WaClient::send_text(const std::string& to_jid, const std::string& text) {
|
||||
WaMessage m;
|
||||
m.conversation = text;
|
||||
Bytes plain = m.encode();
|
||||
// own-device fanout (DeviceSentMessage) omitted for brevity; the recipient
|
||||
// still receives the message. See docs/whatsapp.md for the full fan-out.
|
||||
return send_message(to_jid, plain, {}, "");
|
||||
return send_message(to_jid, m.encode(), "");
|
||||
}
|
||||
|
||||
std::optional<std::string> WaClient::send_image(const std::string& to_jid, const Bytes& image,
|
||||
const std::string& mimetype, const std::string& caption) {
|
||||
// Image send needs media upload (WaMedia), which is specified but not wired
|
||||
// here; this path returns nullopt until the media layer lands.
|
||||
(void)image;
|
||||
(void)mimetype;
|
||||
(void)caption;
|
||||
(void)to_jid;
|
||||
log("send_image: media upload not yet wired (see docs/whatsapp.md)");
|
||||
return std::nullopt;
|
||||
std::optional<WaClient::MediaConn> WaClient::query_media_conn() {
|
||||
WaNode iq("iq");
|
||||
iq.attrs["to"] = WaConsts::kServerJid;
|
||||
iq.attrs["type"] = "set";
|
||||
iq.attrs["xmlns"] = "w:m";
|
||||
iq.attrs["id"] = next_iq_id();
|
||||
iq.content = WaNode::Children{WaNode("media_conn")};
|
||||
auto reply = send_iq(iq, 30);
|
||||
if (!reply) return std::nullopt;
|
||||
const WaNode* mc = reply->child("media_conn");
|
||||
if (mc == nullptr) return std::nullopt;
|
||||
MediaConn out;
|
||||
out.auth = mc->attr("auth");
|
||||
const WaNode* host = mc->child("host");
|
||||
if (out.auth.empty() || host == nullptr || host->attr("hostname").empty()) return std::nullopt;
|
||||
out.host = host->attr("hostname");
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> WaClient::send_image(const std::string& to_jid, const Bytes& image, const std::string& mimetype,
|
||||
const std::string& caption) {
|
||||
if (image.empty() || image.size() > WaConsts::kWaImageMax) {
|
||||
log("send_image: image is empty or larger than " + std::to_string(WaConsts::kWaImageMax) + " bytes");
|
||||
return std::nullopt;
|
||||
}
|
||||
auto mc = query_media_conn();
|
||||
if (!mc) {
|
||||
log("send_image: media connection query failed");
|
||||
return std::nullopt;
|
||||
}
|
||||
WaMedia::Encrypted enc = WaMedia::encrypt(image, "WhatsApp Image Keys", WaCrypto::random_bytes(32));
|
||||
auto up = WaMedia::upload(mc->host, "mms/image", mc->auth, WaMedia::token(enc.file_enc_sha256), enc.data_to_upload);
|
||||
if (!up) {
|
||||
log("send_image: upload to " + mc->host + " failed");
|
||||
return std::nullopt;
|
||||
}
|
||||
WaMessage m;
|
||||
WaMessage::Image img;
|
||||
img.url = up->url;
|
||||
img.direct_path = up->direct_path;
|
||||
img.mimetype = mimetype;
|
||||
img.caption = caption;
|
||||
img.media_key = enc.media_key;
|
||||
img.file_sha256 = enc.file_sha256;
|
||||
img.file_enc_sha256 = enc.file_enc_sha256;
|
||||
img.file_length = enc.file_length;
|
||||
img.media_key_timestamp = static_cast<int64_t>(std::time(nullptr));
|
||||
m.image = img;
|
||||
return send_message(to_jid, m.encode(), "image");
|
||||
}
|
||||
|
||||
bool WaClient::socket_open() const { return sock_ != nullptr && sock_->connected(); }
|
||||
|
||||
void WaClient::handle_message(const WaNode& node) {
|
||||
if (!device_.paired() || !device_.jid) return;
|
||||
std::string own_user = device_.jid->substr(0, device_.jid->find('@'));
|
||||
own_user = own_user.substr(0, own_user.find(':'));
|
||||
std::string from = node.attr("from");
|
||||
if (from.substr(0, from.find_first_of(":@")) != own_user) return; // only the phone's own messages
|
||||
const WaNode::Children* kids = node.children();
|
||||
if (kids == nullptr) return;
|
||||
for (const auto& enc : *kids) {
|
||||
if (enc.tag != "enc") continue;
|
||||
WaReceiver receiver(store_);
|
||||
auto res = receiver.decrypt(enc, from, device_);
|
||||
if (!res.ok) {
|
||||
log("message from own device not decrypted: " + res.reason);
|
||||
continue;
|
||||
}
|
||||
WaProtocolMessage pm = parse_protocol_message(res.message);
|
||||
if (pm.key_share.empty()) continue;
|
||||
for (const auto& k : pm.key_share) {
|
||||
if (k.key_id.empty() || k.key_data.empty()) continue;
|
||||
store_.put_app_key(k.key_id, k.key_data, k.timestamp);
|
||||
app_keys_received_++;
|
||||
}
|
||||
log("app state keys received: " + std::to_string(pm.key_share.size()));
|
||||
}
|
||||
}
|
||||
|
||||
bool WaClient::send_peer_message(const Bytes& message_plaintext) {
|
||||
if (!logged_in_ || !device_.jid) return false;
|
||||
std::string own_user = device_.jid->substr(0, device_.jid->find('@'));
|
||||
own_user = own_user.substr(0, own_user.find(':'));
|
||||
std::string phone = own_user + "@" + WaConsts::kServerJid; // device 0 is the primary phone
|
||||
// whatsmeow preparePeerMessageNode encrypts to the LID identity of the phone (GetLIDForPN), not the phone number
|
||||
std::string enc_jid = phone;
|
||||
if (device_.lid && !device_.lid->empty()) {
|
||||
std::string lid_user = device_.lid->substr(0, device_.lid->find('@'));
|
||||
lid_user = lid_user.substr(0, lid_user.find(':'));
|
||||
enc_jid = lid_user + "@lid";
|
||||
}
|
||||
if (!ensure_sessions({enc_jid})) return false;
|
||||
auto nodes = encrypt_for_devices({enc_jid}, WaCrypto::pkcs7_pad(message_plaintext), "");
|
||||
if (nodes.empty() || nodes[0].children() == nullptr) {
|
||||
log("peer message: no session with the phone");
|
||||
return false;
|
||||
}
|
||||
WaNode::Children content{WaNode("meta")};
|
||||
content[0].attrs["appdata"] = "default";
|
||||
content.push_back(nodes[0].children()->front());
|
||||
if (!device_.account.empty()) {
|
||||
WaNode di("device-identity");
|
||||
di.content = device_.account;
|
||||
content.push_back(di);
|
||||
}
|
||||
std::string id = "3EB0" + [] {
|
||||
Bytes r = WaCrypto::random_bytes(8);
|
||||
static const char* H = "0123456789ABCDEF";
|
||||
std::string s;
|
||||
for (uint8_t c : r) {
|
||||
s.push_back(H[c >> 4]);
|
||||
s.push_back(H[c & 0xf]);
|
||||
}
|
||||
return s;
|
||||
}();
|
||||
WaNode msg("message");
|
||||
msg.attrs["id"] = id;
|
||||
msg.attrs["to"] = own_user + "@" + WaConsts::kServerJid;
|
||||
msg.attrs["type"] = "text";
|
||||
msg.attrs["category"] = "peer";
|
||||
msg.attrs["push_priority"] = "high";
|
||||
msg.content = content;
|
||||
send_node(msg);
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto node = recv_node(2);
|
||||
if (!node) continue;
|
||||
if (node->tag == "ack" && node->attr("id") == id) return true;
|
||||
dispatch(*node);
|
||||
}
|
||||
log("peer message: no ack received");
|
||||
return false;
|
||||
}
|
||||
|
||||
void WaClient::request_app_state_keys(const std::vector<Bytes>& key_ids) {
|
||||
std::vector<Bytes> due;
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
for (const auto& id : key_ids) {
|
||||
if (due.size() >= WaConsts::kWaAppStateKeyIdsMax) break;
|
||||
auto last = store_.app_key_request_time(id);
|
||||
if (last && now - *last < WaConsts::kWaAppStateKeyRequestInterval) continue;
|
||||
store_.put_app_key_request_time(id, now);
|
||||
due.push_back(id);
|
||||
}
|
||||
if (due.empty()) {
|
||||
log("app state keys: already requested within the last 24 h");
|
||||
return;
|
||||
}
|
||||
log("app state keys: requesting " + std::to_string(due.size()) + " from the phone");
|
||||
send_peer_message(encode_app_state_key_request(due));
|
||||
}
|
||||
|
||||
void WaClient::sync_app_state() {
|
||||
WaAppStateSync sync(store_, [this](const std::string& s) { log(s); });
|
||||
auto iq = [this](WaNode q) {
|
||||
q.attrs["id"] = next_iq_id();
|
||||
return send_iq(q, WaConsts::kWaAppStateTimeout);
|
||||
};
|
||||
auto download = [this](const WaExternalBlobRef& ref, std::string& why) -> std::optional<Bytes> {
|
||||
auto mc = query_media_conn();
|
||||
if (!mc) {
|
||||
why = "no media connection";
|
||||
return std::nullopt;
|
||||
}
|
||||
return WaMedia::download(mc->host, ref.direct_path, "md-app-state", ref.media_key, "WhatsApp App State Keys",
|
||||
ref.file_enc_sha256, ref.file_sha256, WaConsts::kWaAppStateBlobMax, why);
|
||||
};
|
||||
auto rep = sync.sync(WaAppStateSync::kContactsCollection, iq, download);
|
||||
if (rep.missing_key_ids.empty()) return;
|
||||
// The phone holds the keys: ask for them, give it a bounded window to answer, then sync once more.
|
||||
request_app_state_keys(rep.missing_key_ids);
|
||||
int before = app_keys_received_;
|
||||
run_until([&]() { return app_keys_received_ != before; }, WaConsts::kWaAppStateKeyWait);
|
||||
if (app_keys_received_ == before) {
|
||||
log("app state: no key share arrived; contacts will sync on a later login");
|
||||
return;
|
||||
}
|
||||
WaAppStateSync again(store_, [this](const std::string& s) { log(s); });
|
||||
again.sync(WaAppStateSync::kContactsCollection, iq, download);
|
||||
}
|
||||
|
||||
void WaClient::disconnect() {
|
||||
|
||||
@@ -34,6 +34,23 @@ class WaStorePort {
|
||||
// contacts / aliases (name -> jid)
|
||||
virtual void put_contact(const std::string& jid, const std::string& name) = 0;
|
||||
virtual std::vector<std::pair<std::string, std::string>> contacts() = 0; // (jid,name)
|
||||
virtual void remove_contact(const std::string& jid) = 0;
|
||||
|
||||
// app-state sync (docs/whatsapp.md section 14): app keys, per-collection LTHash state, value MACs
|
||||
// needed to undo a SET or REMOVE, and the time of the last key request per key id.
|
||||
virtual void put_app_key(const Bytes& key_id, const Bytes& key_data, int64_t timestamp) = 0;
|
||||
virtual std::optional<Bytes> app_key_data(const Bytes& key_id) = 0;
|
||||
struct AppStateVersion {
|
||||
uint64_t version{0};
|
||||
Bytes hash; // LTHash state, 128 bytes
|
||||
};
|
||||
virtual std::optional<AppStateVersion> app_state_version(const std::string& name) = 0;
|
||||
virtual void put_app_state_version(const std::string& name, const AppStateVersion& v) = 0;
|
||||
virtual std::optional<Bytes> app_state_value_mac(const std::string& name, const Bytes& index_mac) = 0;
|
||||
virtual void put_app_state_value_mac(const std::string& name, const Bytes& index_mac, const Bytes& value_mac) = 0;
|
||||
virtual void delete_app_state_value_mac(const std::string& name, const Bytes& index_mac) = 0;
|
||||
virtual std::optional<int64_t> app_key_request_time(const Bytes& key_id) = 0;
|
||||
virtual void put_app_key_request_time(const Bytes& key_id, int64_t when) = 0;
|
||||
};
|
||||
|
||||
class WaClient {
|
||||
@@ -50,16 +67,36 @@ class WaClient {
|
||||
// when the device is unpaired, login when paired). Returns false on failure.
|
||||
bool connect();
|
||||
bool logged_in() const { return logged_in_; }
|
||||
// true while the stream is up; every receive loop checks it so a closed stream ends the loop
|
||||
bool socket_open() const;
|
||||
|
||||
// Pump the connection: read and dispatch nodes until `until` returns true, a
|
||||
// deadline passes, or the socket closes. Used by the link wizard to wait for
|
||||
// pair-success. Returns when paired (if waiting to pair) or on timeout.
|
||||
// Pump the connection: read and dispatch nodes until `stop` returns true or a
|
||||
// deadline passes. Stateless: every call starts fresh.
|
||||
void run_until(const std::function<bool()>& stop, int timeout_seconds);
|
||||
|
||||
// After pair-success the server expects the pair-device-sign reply to land,
|
||||
// then sends stream:error 515 (restart) and closes. Waits for that restart
|
||||
// (or the close) so the reply is not cut off. Returns false on timeout.
|
||||
bool await_pair_restart(int timeout_seconds);
|
||||
|
||||
// Login on a paired device: reconnect, wait for <success> or <failure>, then
|
||||
// upload prekeys when the server is low (whatsmeow uploadPreKeys). Returns true
|
||||
// when logged in. On failure last_failure_code() holds the server's reason code.
|
||||
bool login(int timeout_seconds);
|
||||
int last_failure_code() const { return failure_code_; }
|
||||
|
||||
// Upload a batch of one-time prekeys when the server holds fewer than
|
||||
// kWaMinPrekeys (iq xmlns="encrypt"). Returns true when enough are present.
|
||||
bool upload_prekeys();
|
||||
// Pull the app-state contact list now (login runs this once). Public for the probe and tests.
|
||||
void sync_contacts() { sync_app_state(); }
|
||||
|
||||
// Send a 1:1 text message. Requires a paired, logged-in client. Returns the
|
||||
// message id on success. Live path, not verified in-tree.
|
||||
std::optional<std::string> send_text(const std::string& to_jid, const std::string& text);
|
||||
// Send a 1:1 image (PNG/JPEG bytes) with optional caption. Live path.
|
||||
// Send a 1:1 image (PNG/JPEG bytes) with optional caption: media_conn, encrypt, upload, then an
|
||||
// image message to the recipient's devices and a DeviceSentMessage copy to our own other devices.
|
||||
// Live path, verified against wa-go golden vectors for the crypto only.
|
||||
std::optional<std::string> send_image(const std::string& to_jid, const Bytes& image, const std::string& mimetype,
|
||||
const std::string& caption);
|
||||
|
||||
@@ -81,6 +118,8 @@ class WaClient {
|
||||
std::unique_ptr<WaNoise> noise_;
|
||||
bool logged_in_{false};
|
||||
bool paired_event_{false};
|
||||
bool restart_seen_{false}; // stream:error 515 received
|
||||
int failure_code_{0}; // reason from the last <failure> node
|
||||
uint64_t iq_counter_{0};
|
||||
Bytes last_plaintext_; // the most recent decrypted frame, for capture
|
||||
std::string capture_path_; // where to record a frame that broke a parser
|
||||
@@ -96,6 +135,16 @@ class WaClient {
|
||||
void handle_pair_device(const WaNode& iq);
|
||||
void handle_pair_success(const WaNode& iq);
|
||||
void send_ack(const WaNode& node);
|
||||
// --- app-state sync and key exchange (docs/whatsapp.md section 14) ---
|
||||
int app_keys_received_{0}; // app-state keys stored by this client instance
|
||||
// Receives a <message>: acked by dispatch; decrypts the phone's own messages and stores key shares.
|
||||
void handle_message(const WaNode& node);
|
||||
// Sends a peer message (category "peer") to our own primary device and waits for its ack.
|
||||
bool send_peer_message(const Bytes& message_plaintext);
|
||||
// Asks the phone for the given app-state key ids, at most one request per key id per 24 h.
|
||||
void request_app_state_keys(const std::vector<Bytes>& key_ids);
|
||||
// Syncs the contact collection (login hook); never throws to the caller.
|
||||
void sync_app_state();
|
||||
std::string next_iq_id();
|
||||
|
||||
// --- send path helpers (live, unverified in-tree) ---
|
||||
@@ -105,13 +154,21 @@ class WaClient {
|
||||
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)
|
||||
bool ensure_sessions(const std::vector<std::string>& device_jids);
|
||||
// encrypt plaintext for each device, returning <to> nodes
|
||||
std::vector<WaNode> encrypt_for_devices(const std::vector<std::string>& device_jids, const Bytes& plaintext);
|
||||
// encrypt plaintext for each device, returning <to> nodes (enc gets mediatype for media messages)
|
||||
std::vector<WaNode> encrypt_for_devices(const std::vector<std::string>& device_jids, const Bytes& plaintext,
|
||||
const std::string& media_type);
|
||||
// our own user's device list without this device (the DeviceSentMessage recipients)
|
||||
std::vector<std::string> own_other_devices();
|
||||
// w:m media_conn query: upload auth token and the first upload host
|
||||
struct MediaConn {
|
||||
std::string auth, host;
|
||||
};
|
||||
std::optional<MediaConn> query_media_conn();
|
||||
// signal store address for a device jid
|
||||
static std::string addr_of(const std::string& device_jid);
|
||||
// the common send core for a prepared Message
|
||||
// the common send core for a prepared Message (unpadded, unwrapped); builds the DeviceSentMessage copy
|
||||
std::optional<std::string> send_message(const std::string& to_jid, const Bytes& message_plaintext,
|
||||
const Bytes& dsm_plaintext, const std::string& media_type);
|
||||
const std::string& media_type);
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
namespace detz::wa {
|
||||
|
||||
struct WaConsts {
|
||||
// The WhatsApp server JID (whatsmeow types.ServerJID). Every pairing and login reply is addressed here.
|
||||
static constexpr const char* kServerJid = "s.whatsapp.net";
|
||||
|
||||
// WhatsApp Web client version (whatsmeow store/clientpayload.go). A stale
|
||||
// value makes the server answer with a 405 "client outdated" failure.
|
||||
static constexpr std::array<uint32_t, 3> kClientVersion = {2, 3000, 1050728964};
|
||||
@@ -21,6 +24,17 @@ struct WaConsts {
|
||||
static constexpr int kWaPairTimeout = 120; // seconds
|
||||
static constexpr size_t kWaImageMax = 16u * 1024 * 1024;
|
||||
static constexpr int kWaContactsMax = 5000;
|
||||
static constexpr int kWaLoginTimeout = 20; // seconds to wait for <success> or <failure> after login
|
||||
static constexpr int kWaPairRestartTimeout = 15; // seconds to wait for the 515 restart after pair-success
|
||||
static constexpr int kWaLinkRetries = 1; // fresh pairings after the server rejects a new device (401/403)
|
||||
// app-state sync (contact list), see docs/whatsapp.md section 14
|
||||
static constexpr int kWaAppStatePatchMax = 50; // patches applied per collection sync (whatsmeow loops until has_more_patches is false)
|
||||
static constexpr int kWaAppStateMutationMax = 10000; // mutations applied per collection sync
|
||||
static constexpr int kWaAppStateTimeout = 30; // seconds per sync iq
|
||||
static constexpr size_t kWaAppStateBlobMax = 16u * 1024 * 1024; // external (downloaded) mutation blob cap
|
||||
static constexpr int64_t kWaAppStateKeyRequestInterval = 24 * 60 * 60; // seconds between key requests for one key id
|
||||
static constexpr int kWaAppStateKeyWait = 20; // seconds to wait for requested keys during login
|
||||
static constexpr size_t kWaAppStateKeyIdsMax = 16; // key ids per key request message
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
|
||||
@@ -67,6 +67,14 @@ Bytes WaCrypto::hmac_sha256(const Bytes& key, const Bytes& data) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Bytes WaCrypto::hmac_sha512(const Bytes& key, const Bytes& data) {
|
||||
Bytes out(SHA512_DIGEST_LENGTH);
|
||||
unsigned int len = 0;
|
||||
HMAC(EVP_sha512(), key.data(), static_cast<int>(key.size()), data.data(), data.size(), out.data(), &len);
|
||||
require(len == SHA512_DIGEST_LENGTH, "hmac failed");
|
||||
return out;
|
||||
}
|
||||
|
||||
Bytes WaCrypto::hkdf_sha256(const Bytes& ikm, const Bytes& salt, const Bytes& info, size_t out_len) {
|
||||
// Manual RFC 5869 HKDF over HMAC-SHA256. Done by hand (not OpenSSL's EVP HKDF)
|
||||
// because WhatsApp's noise finish derives with an empty IKM, which OpenSSL's
|
||||
|
||||
@@ -33,6 +33,7 @@ class WaCrypto {
|
||||
static Bytes sha512(const Bytes& data);
|
||||
static Bytes md5(const Bytes& data);
|
||||
static Bytes hmac_sha256(const Bytes& key, const Bytes& data);
|
||||
static Bytes hmac_sha512(const Bytes& key, const Bytes& data);
|
||||
|
||||
// HKDF-SHA256 (RFC 5869). salt may be empty (treated as zeros of hash len).
|
||||
static Bytes hkdf_sha256(const Bytes& ikm, const Bytes& salt, const Bytes& info, size_t out_len);
|
||||
|
||||
@@ -151,6 +151,7 @@ Bytes WaDevice::serialize() const {
|
||||
put_blob(b, Bytes(lid_s.begin(), lid_s.end()));
|
||||
put_blob(b, Bytes(push_name.begin(), push_name.end()));
|
||||
put_blob(b, account);
|
||||
put_u32(b, next_prekey_id); // appended after v1 fields; deserialize accepts blobs without it
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -179,6 +180,7 @@ std::optional<WaDevice> WaDevice::deserialize(const Bytes& blob) {
|
||||
Bytes pn = get_blob(blob, p);
|
||||
d.push_name = std::string(pn.begin(), pn.end());
|
||||
d.account = get_blob(blob, p);
|
||||
if (p < blob.size()) d.next_prekey_id = get_u32(blob, p);
|
||||
return d;
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
|
||||
@@ -23,6 +23,7 @@ class WaDevice {
|
||||
WaCrypto::KeyPair identity_key; // long-term Signal identity
|
||||
WaSignedPreKey signed_pre_key;
|
||||
uint32_t registration_id{0};
|
||||
uint32_t next_prekey_id{1}; // next one-time prekey id to generate (3-byte ids on the wire)
|
||||
std::array<uint8_t, 32> adv_secret{}; // pairing HMAC secret
|
||||
|
||||
// set once paired:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#include "wa_media.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "../llm/json.h"
|
||||
#include "wa_consts.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
namespace {
|
||||
std::string b64url(const Bytes& b) {
|
||||
static const char* kTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
std::string out;
|
||||
size_t i = 0;
|
||||
for (; i + 2 < b.size(); i += 3) {
|
||||
uint32_t v = (uint32_t(b[i]) << 16) | (uint32_t(b[i + 1]) << 8) | b[i + 2];
|
||||
out += kTable[(v >> 18) & 63];
|
||||
out += kTable[(v >> 12) & 63];
|
||||
out += kTable[(v >> 6) & 63];
|
||||
out += kTable[v & 63];
|
||||
}
|
||||
size_t rest = b.size() - i;
|
||||
if (rest == 1) {
|
||||
uint32_t v = uint32_t(b[i]) << 16;
|
||||
out += kTable[(v >> 18) & 63];
|
||||
out += kTable[(v >> 12) & 63];
|
||||
out += "==";
|
||||
} else if (rest == 2) {
|
||||
uint32_t v = (uint32_t(b[i]) << 16) | (uint32_t(b[i + 1]) << 8);
|
||||
out += kTable[(v >> 18) & 63];
|
||||
out += kTable[(v >> 12) & 63];
|
||||
out += kTable[(v >> 6) & 63];
|
||||
out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string url_encode(const std::string& s) {
|
||||
CURL* c = curl_easy_init();
|
||||
char* e = curl_easy_escape(c, s.c_str(), static_cast<int>(s.size()));
|
||||
std::string out = e ? e : "";
|
||||
curl_free(e);
|
||||
curl_easy_cleanup(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct Sink {
|
||||
std::string body;
|
||||
};
|
||||
size_t on_body(char* p, size_t size, size_t n, void* user) {
|
||||
static_cast<Sink*>(user)->body.append(p, size * n);
|
||||
return size * n;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
WaMedia::Keys WaMedia::expand(const Bytes& media_key, const std::string& app_info) {
|
||||
Bytes info(app_info.begin(), app_info.end());
|
||||
Bytes e = WaCrypto::hkdf_sha256(media_key, Bytes{}, info, 112);
|
||||
Keys k;
|
||||
k.iv.assign(e.begin(), e.begin() + 16);
|
||||
k.cipher_key.assign(e.begin() + 16, e.begin() + 48);
|
||||
k.mac_key.assign(e.begin() + 48, e.begin() + 80);
|
||||
k.ref_key.assign(e.begin() + 80, e.end());
|
||||
return k;
|
||||
}
|
||||
|
||||
WaMedia::Encrypted WaMedia::encrypt(const Bytes& plain, const std::string& app_info, const Bytes& media_key) {
|
||||
Encrypted out;
|
||||
out.media_key = media_key;
|
||||
out.file_length = plain.size();
|
||||
out.file_sha256 = WaCrypto::sha256(plain);
|
||||
Keys k = expand(media_key, app_info);
|
||||
Bytes ct = WaCrypto::aes_cbc_encrypt_nopad(k.cipher_key, k.iv, WaCrypto::pkcs7_pad(plain));
|
||||
Bytes mac_input = WaCrypto::concat({k.iv, ct});
|
||||
Bytes mac = WaCrypto::hmac_sha256(k.mac_key, mac_input);
|
||||
out.data_to_upload = ct;
|
||||
out.data_to_upload.insert(out.data_to_upload.end(), mac.begin(), mac.begin() + 10);
|
||||
out.file_enc_sha256 = WaCrypto::sha256(out.data_to_upload);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string WaMedia::token(const Bytes& file_enc_sha256) { return b64url(file_enc_sha256); }
|
||||
|
||||
std::optional<WaMedia::Uploaded> WaMedia::upload(const std::string& host, const std::string& prefix,
|
||||
const std::string& auth, const std::string& token,
|
||||
const Bytes& data) {
|
||||
if (data.size() > WaConsts::kWaImageMax + 64) return std::nullopt; // the cap covers the plaintext; CBC+MAC adds a little
|
||||
std::string url = "https://" + host + "/" + prefix + "/" + token + "?auth=" + url_encode(auth) + "&token=" + url_encode(token);
|
||||
CURL* c = curl_easy_init();
|
||||
if (c == nullptr) return std::nullopt;
|
||||
Sink sink;
|
||||
struct curl_slist* hdr = nullptr;
|
||||
hdr = curl_slist_append(hdr, ("Origin: " + std::string("https://web.whatsapp.com")).c_str());
|
||||
hdr = curl_slist_append(hdr, "Referer: https://web.whatsapp.com/");
|
||||
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(c, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(data.size()));
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDS, data.data());
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdr);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_body);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &sink);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &status);
|
||||
curl_slist_free_all(hdr);
|
||||
curl_easy_cleanup(c);
|
||||
if (rc != CURLE_OK || status != 200) return std::nullopt;
|
||||
detz::json::Value reply;
|
||||
try {
|
||||
reply = detz::json::Value::loads(sink.body);
|
||||
} catch (const detz::json::ParseError&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!reply.is_object() || !reply.get("url").is_string()) return std::nullopt;
|
||||
Uploaded up;
|
||||
up.url = reply.get("url").str();
|
||||
up.direct_path = reply.get("direct_path").is_string() ? reply.get("direct_path").str() : std::string();
|
||||
return up;
|
||||
}
|
||||
|
||||
std::optional<Bytes> WaMedia::download(const std::string& host, const std::string& direct_path,
|
||||
const std::string& mms_type, const Bytes& media_key,
|
||||
const std::string& app_info, const Bytes& file_enc_sha256,
|
||||
const Bytes& file_sha256, size_t max_bytes, std::string& why) {
|
||||
if (direct_path.empty() || direct_path[0] != '/') {
|
||||
why = "media download path does not start with a slash";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (file_enc_sha256.size() != 32 || file_sha256.size() != 32 || media_key.size() != 32) {
|
||||
why = "media download: hashes or media key have the wrong length";
|
||||
return std::nullopt;
|
||||
}
|
||||
std::string url = "https://" + host + direct_path + "&hash=" + b64url(file_enc_sha256) + "&mms-type=" + mms_type + "&__wa-mms=";
|
||||
CURL* c = curl_easy_init();
|
||||
if (c == nullptr) {
|
||||
why = "curl init failed";
|
||||
return std::nullopt;
|
||||
}
|
||||
struct Capped {
|
||||
std::string body;
|
||||
size_t cap;
|
||||
bool over{false};
|
||||
} sink{{}, max_bytes, false};
|
||||
auto on_capped = [](char* p, size_t size, size_t n, void* user) -> size_t {
|
||||
auto* s = static_cast<Capped*>(user);
|
||||
if (s->body.size() + size * n > s->cap) {
|
||||
s->over = true;
|
||||
return 0; // aborts the transfer
|
||||
}
|
||||
s->body.append(p, size * n);
|
||||
return size * n;
|
||||
};
|
||||
struct curl_slist* hdr = nullptr;
|
||||
hdr = curl_slist_append(hdr, "Origin: https://web.whatsapp.com");
|
||||
hdr = curl_slist_append(hdr, "Referer: https://web.whatsapp.com/");
|
||||
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdr);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, +on_capped);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &sink);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &status);
|
||||
curl_slist_free_all(hdr);
|
||||
curl_easy_cleanup(c);
|
||||
if (sink.over) {
|
||||
why = "media download exceeds " + std::to_string(max_bytes) + " bytes";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (rc != CURLE_OK || status != 200) {
|
||||
why = "media download failed (curl " + std::to_string(static_cast<int>(rc)) + ", HTTP " + std::to_string(status) + ")";
|
||||
return std::nullopt;
|
||||
}
|
||||
Bytes enc(sink.body.begin(), sink.body.end());
|
||||
if (enc.size() <= 10) {
|
||||
why = "media download too short";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (WaCrypto::sha256(enc) != file_enc_sha256) {
|
||||
why = "media download: encrypted file SHA-256 mismatch";
|
||||
return std::nullopt;
|
||||
}
|
||||
Keys k = expand(media_key, app_info);
|
||||
Bytes file(enc.begin(), enc.end() - 10);
|
||||
Bytes mac(enc.end() - 10, enc.end());
|
||||
if (file.empty() || file.size() % 16 != 0) {
|
||||
why = "media download: ciphertext is not a whole number of AES blocks";
|
||||
return std::nullopt;
|
||||
}
|
||||
Bytes mac_input = WaCrypto::concat({k.iv, file});
|
||||
Bytes want = WaCrypto::hmac_sha256(k.mac_key, mac_input);
|
||||
want.resize(10);
|
||||
if (!WaCrypto::const_time_eq(want, mac)) {
|
||||
why = "media download: HMAC mismatch";
|
||||
return std::nullopt;
|
||||
}
|
||||
auto plain = WaCrypto::pkcs7_unpad(WaCrypto::aes_cbc_decrypt_nopad(k.cipher_key, k.iv, file));
|
||||
if (!plain) {
|
||||
why = "media download: bad padding";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (WaCrypto::sha256(*plain) != file_sha256) {
|
||||
why = "media download: plaintext SHA-256 mismatch";
|
||||
return std::nullopt;
|
||||
}
|
||||
return plain;
|
||||
}
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
// WaMedia: WhatsApp media encryption and upload, a static utility class. Transpiled from whatsmeow
|
||||
// upload.go (MPL-2.0) and download.go getMediaKeys; golden vectors from wa-go (MIT) in
|
||||
// tests/whatsapp_data/media_vectors.json. See docs/whatsapp.md section 5 step 7.
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "wa_crypto.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
class WaMedia {
|
||||
public:
|
||||
// The three keys derived from a 32-byte media key for one media type (HKDF-SHA256, 112 bytes).
|
||||
struct Keys {
|
||||
Bytes iv, cipher_key, mac_key, ref_key;
|
||||
};
|
||||
static Keys expand(const Bytes& media_key, const std::string& app_info);
|
||||
|
||||
// What a sender puts on the wire and in the message (ImageMessage fields).
|
||||
struct Encrypted {
|
||||
Bytes media_key; // sent inside the message (ImageMessage.mediaKey)
|
||||
Bytes data_to_upload; // AES-256-CBC(cipherKey, iv, PKCS7(plain)) || HMAC-SHA256(macKey, iv||ct)[:10]
|
||||
Bytes file_sha256; // SHA-256 of the plaintext
|
||||
Bytes file_enc_sha256; // SHA-256 of data_to_upload; also the upload token
|
||||
uint64_t file_length{0};
|
||||
};
|
||||
// `media_key` is passed in for the golden-vector tests; production passes random bytes.
|
||||
static Encrypted encrypt(const Bytes& plain, const std::string& app_info, const Bytes& media_key);
|
||||
|
||||
// Upload token: base64url of file_enc_sha256 (whatsmeow base64.URLEncoding, padded).
|
||||
static std::string token(const Bytes& file_enc_sha256);
|
||||
|
||||
struct Uploaded {
|
||||
std::string url, direct_path;
|
||||
};
|
||||
// POST the encrypted blob to https://<host>/<prefix>/<token>?auth=<auth>&token=<token>.
|
||||
// Returns nullopt on any transport, status or parse failure (the caller logs it).
|
||||
static std::optional<Uploaded> upload(const std::string& host, const std::string& prefix, const std::string& auth,
|
||||
const std::string& token, const Bytes& data);
|
||||
// GET https://<host><direct_path>&hash=<b64url(enc_sha)>&mms-type=<mms_type>&__wa-mms= (whatsmeow
|
||||
// DownloadMediaWithPath), then verify and decrypt as whatsmeow downloadAndDecrypt: SHA-256 of the
|
||||
// encrypted file, HMAC-SHA256(macKey, iv||file)[:10], AES-256-CBC, SHA-256 of the plaintext. Any
|
||||
// failure returns nullopt with the reason in `why`. The body is capped at `max_bytes` while downloading.
|
||||
static std::optional<Bytes> download(const std::string& host, const std::string& direct_path,
|
||||
const std::string& mms_type, const Bytes& media_key,
|
||||
const std::string& app_info, const Bytes& file_enc_sha256,
|
||||
const Bytes& file_sha256, size_t max_bytes, std::string& why);
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -166,6 +166,24 @@ Bytes WaDeviceProps::encode() const {
|
||||
w.message(2, ver.take());
|
||||
w.varint(3, platform_type);
|
||||
w.boolean(4, require_full_sync);
|
||||
if (history_sync_config) {
|
||||
ProtoWriter h;
|
||||
h.varint(3, 10240); // storageQuotaMb
|
||||
h.boolean(4, true); // inlineInitialPayloadInE2EeMsg
|
||||
h.boolean(6, true); // supportCallLogHistory
|
||||
h.boolean(7, true); // supportBotUserAgentChatHistory
|
||||
h.boolean(8, true); // supportCagReactionsAndPolls
|
||||
h.boolean(9, true); // supportBizHostedMsg
|
||||
h.boolean(10, true); // supportRecentSyncChunkMessageCountTuning
|
||||
h.boolean(11, true); // supportHostedGroupMsg
|
||||
h.boolean(12, true); // supportFbidBotChatHistory
|
||||
h.boolean(14, true); // supportMessageAssociation
|
||||
h.boolean(15, true); // supportGroupHistory
|
||||
h.varint(19, 60); // thumbnailSyncDaysLimit
|
||||
h.boolean(21, true); // supportManusHistory
|
||||
h.boolean(22, true); // supportHatchHistory
|
||||
w.message(5, h.take());
|
||||
}
|
||||
return w.take();
|
||||
}
|
||||
|
||||
@@ -418,4 +436,197 @@ std::string extract_text(const Bytes& message_proto) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------- app-state sync and key exchange ----------------
|
||||
namespace {
|
||||
Bytes read_key_id(const Bytes& keyid_msg) { // KeyId / AppStateSyncKeyId: field 1 = bytes
|
||||
ProtoReader r(keyid_msg);
|
||||
ProtoReader::Field f;
|
||||
Bytes out;
|
||||
while (r.next(f))
|
||||
if (f.field == 1 && f.wire == 2) out = f.bytes;
|
||||
return out;
|
||||
}
|
||||
Bytes read_blob(const Bytes& msg) { // SyncdIndex / SyncdValue: field 1 = bytes
|
||||
return read_key_id(msg);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
WaExternalBlobRef parse_external_blob_ref(const Bytes& b) {
|
||||
WaExternalBlobRef out;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 2) out.media_key = f.bytes;
|
||||
else if (f.field == 2 && f.wire == 2) out.direct_path.assign(f.bytes.begin(), f.bytes.end());
|
||||
else if (f.field == 3 && f.wire == 2) out.handle.assign(f.bytes.begin(), f.bytes.end());
|
||||
else if (f.field == 4 && f.wire == 0) out.file_size = f.varint;
|
||||
else if (f.field == 5 && f.wire == 2) out.file_sha256 = f.bytes;
|
||||
else if (f.field == 6 && f.wire == 2) out.file_enc_sha256 = f.bytes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
WaSyncdRecord parse_syncd_record(const Bytes& b) {
|
||||
WaSyncdRecord rec;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 2) rec.index_mac = read_blob(f.bytes);
|
||||
else if (f.field == 2 && f.wire == 2) rec.value_blob = read_blob(f.bytes);
|
||||
else if (f.field == 3 && f.wire == 2) rec.key_id = read_key_id(f.bytes);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
WaSyncdMutation parse_syncd_mutation(const Bytes& b) {
|
||||
WaSyncdMutation m;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 0) m.remove = f.varint == 1;
|
||||
else if (f.field == 2 && f.wire == 2) m.record = parse_syncd_record(f.bytes);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
uint64_t parse_version(const Bytes& b) { // SyncdVersion: field 1 = uint64
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
uint64_t v = 0;
|
||||
while (r.next(f))
|
||||
if (f.field == 1 && f.wire == 0) v = f.varint;
|
||||
return v;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
WaSyncdPatch parse_syncd_patch(const Bytes& b) {
|
||||
WaSyncdPatch out;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 2) out.version = parse_version(f.bytes);
|
||||
else if (f.field == 2 && f.wire == 2) out.mutations.push_back(parse_syncd_mutation(f.bytes));
|
||||
else if (f.field == 3 && f.wire == 2) out.external_mutations = parse_external_blob_ref(f.bytes);
|
||||
else if (f.field == 4 && f.wire == 2) out.snapshot_mac = f.bytes;
|
||||
else if (f.field == 5 && f.wire == 2) out.patch_mac = f.bytes;
|
||||
else if (f.field == 6 && f.wire == 2) out.key_id = read_key_id(f.bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
WaSyncdSnapshot parse_syncd_snapshot(const Bytes& b) {
|
||||
WaSyncdSnapshot out;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 2) out.version = parse_version(f.bytes);
|
||||
else if (f.field == 2 && f.wire == 2) out.records.push_back(parse_syncd_record(f.bytes));
|
||||
else if (f.field == 3 && f.wire == 2) out.mac = f.bytes;
|
||||
else if (f.field == 4 && f.wire == 2) out.key_id = read_key_id(f.bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<WaSyncdMutation> parse_syncd_mutations(const Bytes& b) {
|
||||
std::vector<WaSyncdMutation> out;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f))
|
||||
if (f.field == 1 && f.wire == 2) out.push_back(parse_syncd_mutation(f.bytes));
|
||||
return out;
|
||||
}
|
||||
|
||||
WaSyncActionData parse_sync_action_data(const Bytes& b) {
|
||||
WaSyncActionData out;
|
||||
ProtoReader r(b);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 2) {
|
||||
out.index_json.assign(f.bytes.begin(), f.bytes.end());
|
||||
} else if (f.field == 2 && f.wire == 2) { // SyncActionValue
|
||||
ProtoReader vr(f.bytes);
|
||||
ProtoReader::Field vf;
|
||||
while (vr.next(vf)) {
|
||||
if (vf.field != 3 || vf.wire != 2) continue; // contactAction
|
||||
out.has_contact = true;
|
||||
ProtoReader cr(vf.bytes);
|
||||
ProtoReader::Field cf;
|
||||
while (cr.next(cf)) {
|
||||
if (cf.field == 1 && cf.wire == 2) out.contact_full_name.assign(cf.bytes.begin(), cf.bytes.end());
|
||||
else if (cf.field == 2 && cf.wire == 2) out.contact_first_name.assign(cf.bytes.begin(), cf.bytes.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
WaProtocolMessage parse_protocol_message(const Bytes& message) {
|
||||
WaProtocolMessage out;
|
||||
ProtoReader r(message);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field != 12 || f.wire != 2) continue; // Message.protocolMessage
|
||||
ProtoReader pr(f.bytes);
|
||||
ProtoReader::Field pf;
|
||||
while (pr.next(pf)) {
|
||||
if (pf.field == 2 && pf.wire == 0) {
|
||||
out.type = static_cast<int>(pf.varint);
|
||||
} else if (pf.field == 7 && pf.wire == 2) { // appStateSyncKeyShare
|
||||
ProtoReader kr(pf.bytes);
|
||||
ProtoReader::Field kf;
|
||||
while (kr.next(kf)) {
|
||||
if (kf.field != 1 || kf.wire != 2) continue; // AppStateSyncKey
|
||||
WaAppStateKey key;
|
||||
ProtoReader ar(kf.bytes);
|
||||
ProtoReader::Field af;
|
||||
while (ar.next(af)) {
|
||||
if (af.field == 1 && af.wire == 2) {
|
||||
key.key_id = read_key_id(af.bytes);
|
||||
} else if (af.field == 2 && af.wire == 2) { // AppStateSyncKeyData
|
||||
ProtoReader dr(af.bytes);
|
||||
ProtoReader::Field df;
|
||||
while (dr.next(df)) {
|
||||
if (df.field == 1 && df.wire == 2) key.key_data = df.bytes;
|
||||
else if (df.field == 3 && df.wire == 0) key.timestamp = static_cast<int64_t>(df.varint);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.key_share.push_back(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Bytes encode_app_state_key_request(const std::vector<Bytes>& key_ids) {
|
||||
ProtoWriter req;
|
||||
for (const auto& id : key_ids) {
|
||||
ProtoWriter keyid;
|
||||
keyid.bytes(1, id);
|
||||
req.message(1, keyid.data()); // AppStateSyncKeyRequest.keyIDs
|
||||
}
|
||||
ProtoWriter pm;
|
||||
pm.varint(2, 7); // type = APP_STATE_SYNC_KEY_REQUEST
|
||||
pm.message(8, req.data()); // appStateSyncKeyRequest
|
||||
ProtoWriter msg;
|
||||
msg.message(12, pm.data()); // Message.protocolMessage
|
||||
return msg.take();
|
||||
}
|
||||
|
||||
WaPreKeyHeader parse_pre_key_header(const Bytes& body) {
|
||||
WaPreKeyHeader h;
|
||||
ProtoReader r(body);
|
||||
ProtoReader::Field f;
|
||||
while (r.next(f)) {
|
||||
if (f.field == 1 && f.wire == 0) {
|
||||
h.pre_key_id = static_cast<uint32_t>(f.varint);
|
||||
h.has_pre_key = true;
|
||||
} else if (f.field == 6 && f.wire == 0) {
|
||||
h.signed_pre_key_id = static_cast<uint32_t>(f.varint);
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace detz::wa
|
||||
|
||||
@@ -98,6 +98,10 @@ struct WaDeviceProps {
|
||||
uint32_t ver_primary{0}, ver_secondary{1}, ver_tertiary{0};
|
||||
int platform_type{0}; // UNKNOWN
|
||||
bool require_full_sync{false};
|
||||
// DeviceProps.historySyncConfig (field 5), the values whatsmeow store/clientpayload.go sends. Without it the
|
||||
// phone has no reason to push the initial history and the app-state sync keys to a new companion, so the
|
||||
// address book never arrives. Field numbers: WACompanionReg.proto DeviceProps.HistorySyncConfig.
|
||||
bool history_sync_config{true};
|
||||
Bytes encode() const;
|
||||
};
|
||||
|
||||
@@ -184,4 +188,70 @@ std::optional<Bytes> unpad_random_max16(const Bytes& b);
|
||||
// empty if the message carried no text we understand.
|
||||
std::string extract_text(const Bytes& message_proto);
|
||||
|
||||
// ---- app-state sync and key exchange ----
|
||||
// Field numbers: whatsmeow proto/waServerSync (SyncdPatch, SyncdSnapshot, SyncdRecord, ExternalBlobReference),
|
||||
// proto/waSyncAction (SyncActionData, SyncActionValue.contactAction, ContactAction) and proto/waE2E
|
||||
// (Message.protocolMessage, ProtocolMessage, AppStateSyncKey*). Parsers throw std::runtime_error on malformed
|
||||
// input (the caller logs and skips), never std::length_error: see docs/whatsapp.md section 13.
|
||||
|
||||
struct WaExternalBlobRef {
|
||||
Bytes media_key, file_sha256, file_enc_sha256;
|
||||
std::string direct_path, handle;
|
||||
uint64_t file_size{0};
|
||||
};
|
||||
WaExternalBlobRef parse_external_blob_ref(const Bytes& b);
|
||||
|
||||
struct WaSyncdRecord {
|
||||
Bytes index_mac; // SyncdIndex.blob: HMAC-SHA256(indexKey, index JSON)
|
||||
Bytes value_blob; // SyncdValue.blob: iv(16) || AES-256-CBC ciphertext || valueMAC(32)
|
||||
Bytes key_id; // KeyId.ID
|
||||
};
|
||||
struct WaSyncdMutation {
|
||||
bool remove{false}; // SyncdOperation: SET = 0 (default), REMOVE = 1
|
||||
WaSyncdRecord record;
|
||||
};
|
||||
struct WaSyncdPatch {
|
||||
uint64_t version{0};
|
||||
std::vector<WaSyncdMutation> mutations;
|
||||
std::optional<WaExternalBlobRef> external_mutations; // set when mutations live in a downloaded blob
|
||||
Bytes snapshot_mac, patch_mac, key_id;
|
||||
};
|
||||
struct WaSyncdSnapshot {
|
||||
uint64_t version{0};
|
||||
std::vector<WaSyncdRecord> records;
|
||||
Bytes mac, key_id;
|
||||
};
|
||||
WaSyncdPatch parse_syncd_patch(const Bytes& b);
|
||||
WaSyncdSnapshot parse_syncd_snapshot(const Bytes& b);
|
||||
std::vector<WaSyncdMutation> parse_syncd_mutations(const Bytes& b); // SyncdMutations (external blob body)
|
||||
|
||||
// SyncActionData: the encrypted plaintext of one mutation. Only the fields the contact sync reads.
|
||||
struct WaSyncActionData {
|
||||
std::string index_json; // JSON array of strings, e.g. ["contact","31611111111@s.whatsapp.net"]
|
||||
bool has_contact{false};
|
||||
std::string contact_full_name, contact_first_name;
|
||||
};
|
||||
WaSyncActionData parse_sync_action_data(const Bytes& b);
|
||||
|
||||
// Message.protocolMessage. type is the ProtocolMessage.Type value (-1 when the Message has none).
|
||||
struct WaAppStateKey {
|
||||
Bytes key_id, key_data;
|
||||
int64_t timestamp{0};
|
||||
};
|
||||
struct WaProtocolMessage {
|
||||
int type{-1};
|
||||
std::vector<WaAppStateKey> key_share; // APP_STATE_SYNC_KEY_SHARE (type 6)
|
||||
};
|
||||
WaProtocolMessage parse_protocol_message(const Bytes& message);
|
||||
// Message{protocolMessage{type=APP_STATE_SYNC_KEY_REQUEST, appStateSyncKeyRequest{keyIDs}}}
|
||||
Bytes encode_app_state_key_request(const std::vector<Bytes>& key_ids);
|
||||
|
||||
// PreKeyWhisperMessage fields the receiver needs before X3DH (the rest is parsed by WaSignal).
|
||||
struct WaPreKeyHeader {
|
||||
uint32_t pre_key_id{0};
|
||||
bool has_pre_key{false};
|
||||
uint32_t signed_pre_key_id{0};
|
||||
};
|
||||
WaPreKeyHeader parse_pre_key_header(const Bytes& pkmsg_body); // pkmsg without its version byte
|
||||
|
||||
} // namespace detz::wa
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "wa_receiver.h"
|
||||
|
||||
#include "wa_proto.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
namespace {
|
||||
// "user[:device]@server" -> "user.device" (device 0 when absent), as WaClient::addr_of.
|
||||
std::string signal_addr(const std::string& device_jid) {
|
||||
size_t at = device_jid.find('@');
|
||||
std::string ud = at == std::string::npos ? device_jid : device_jid.substr(0, at);
|
||||
size_t colon = ud.find(':');
|
||||
if (colon == std::string::npos) return ud + ".0";
|
||||
return ud.substr(0, colon) + "." + ud.substr(colon + 1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
WaReceiver::Result WaReceiver::decrypt(const WaNode& enc, const std::string& from_jid, const WaDevice& device) {
|
||||
Result res;
|
||||
const Bytes* body = enc.bytes();
|
||||
if (body == nullptr || body->size() < 2) {
|
||||
res.reason = "enc node has no ciphertext";
|
||||
return res;
|
||||
}
|
||||
std::string type = enc.attr("type");
|
||||
std::string addr = signal_addr(from_jid);
|
||||
std::optional<Bytes> plain;
|
||||
try {
|
||||
if (type == "pkmsg") {
|
||||
WaPreKeyHeader h = parse_pre_key_header(Bytes(body->begin() + 1, body->end()));
|
||||
if (h.signed_pre_key_id != device.signed_pre_key.key_id) {
|
||||
res.reason = "pkmsg uses an unknown signed prekey";
|
||||
return res;
|
||||
}
|
||||
std::optional<WaCrypto::KeyPair> pre;
|
||||
if (h.has_pre_key) {
|
||||
auto priv = store_.get_prekey(h.pre_key_id);
|
||||
if (!priv || priv->size() != 32) {
|
||||
res.reason = "pkmsg uses an unknown one-time prekey";
|
||||
return res;
|
||||
}
|
||||
WaCrypto::KeyPair kp;
|
||||
std::copy(priv->begin(), priv->end(), kp.priv.begin());
|
||||
kp.pub = WaCrypto::x25519_public(kp.priv);
|
||||
pre = kp;
|
||||
}
|
||||
auto out = WaSession::process_prekey_message(*body, device.identity_key, device.signed_pre_key.pair, pre,
|
||||
device.registration_id);
|
||||
if (!out) {
|
||||
res.reason = "pkmsg could not be decrypted";
|
||||
return res;
|
||||
}
|
||||
store_.save_session(addr, out->second.serialize());
|
||||
plain = out->first;
|
||||
} else if (type == "msg") {
|
||||
auto blob = store_.load_session(addr);
|
||||
if (!blob) {
|
||||
res.reason = "no session with " + addr;
|
||||
return res;
|
||||
}
|
||||
auto sess = WaSession::deserialize(*blob);
|
||||
if (!sess) {
|
||||
res.reason = "stored session is unreadable";
|
||||
return res;
|
||||
}
|
||||
plain = sess->decrypt(*body);
|
||||
if (!plain) {
|
||||
res.reason = "msg could not be decrypted";
|
||||
return res;
|
||||
}
|
||||
store_.save_session(addr, sess->serialize());
|
||||
} else {
|
||||
res.reason = "unsupported enc type '" + type + "'";
|
||||
return res;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
res.reason = std::string("decrypt failed: ") + e.what();
|
||||
return res;
|
||||
}
|
||||
auto msg = unpad_random_max16(*plain);
|
||||
if (!msg) {
|
||||
res.reason = "bad message padding";
|
||||
return res;
|
||||
}
|
||||
res.ok = true;
|
||||
res.message = *msg;
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
// WaReceiver: decrypts the incoming 1:1 <enc> nodes that the phone sends to this
|
||||
// linked device (pkmsg for a new session, msg for an existing one). Only the
|
||||
// phone's own messages are decrypted here: those carry the app-state sync keys
|
||||
// (APP_STATE_SYNC_KEY_SHARE). Ported from whatsmeow message.go (MPL-2.0) receive
|
||||
// path; see docs/whatsapp.md section 14.
|
||||
#include <string>
|
||||
|
||||
#include "wa_device.h"
|
||||
#include "wa_node.h"
|
||||
#include "wa_signal.h"
|
||||
#include "wa_client.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
class WaReceiver {
|
||||
public:
|
||||
explicit WaReceiver(WaStorePort& store) : store_(store) {}
|
||||
|
||||
struct Result {
|
||||
bool ok{false};
|
||||
Bytes message; // the unpadded Message protobuf when ok
|
||||
std::string reason; // why it failed, when not ok
|
||||
};
|
||||
// `from_jid` is the sender device JID from the <message> node's from attribute.
|
||||
Result decrypt(const WaNode& enc, const std::string& from_jid, const WaDevice& device);
|
||||
|
||||
private:
|
||||
WaStorePort& store_;
|
||||
};
|
||||
|
||||
} // namespace detz::wa
|
||||
@@ -60,7 +60,10 @@ bool WaSocket::pump_once(int timeout_seconds) {
|
||||
CURLcode rc = curl_ws_recv(c, buf, sizeof buf, &rlen, &meta);
|
||||
if (rc == CURLE_OK) {
|
||||
if (rlen > 0) inbuf_.insert(inbuf_.end(), buf, buf + rlen);
|
||||
if (meta != nullptr && (meta->flags & CURLWS_CLOSE)) return false;
|
||||
if (meta != nullptr && (meta->flags & CURLWS_CLOSE)) {
|
||||
connected_ = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (rc == CURLE_AGAIN) {
|
||||
@@ -72,7 +75,10 @@ bool WaSocket::pump_once(int timeout_seconds) {
|
||||
waited_ms += 20;
|
||||
continue;
|
||||
}
|
||||
if (rc == CURLE_GOT_NOTHING) return false;
|
||||
if (rc == CURLE_GOT_NOTHING) {
|
||||
connected_ = false; // the server closed the stream; callers must stop reading, not spin
|
||||
return false;
|
||||
}
|
||||
throw WaSocketError(std::string("ws_recv: ") + curl_easy_strerror(rc));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
|
||||
#include "fuzzy.h"
|
||||
#include "sqldb.h"
|
||||
#include "wa_consts.h"
|
||||
|
||||
namespace detz::wa {
|
||||
|
||||
@@ -44,6 +50,22 @@ WaStore::WaStore(const std::string& home, const std::string& profile) {
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_session (addr TEXT PRIMARY KEY, blob TEXT)");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_prekey (id INTEGER PRIMARY KEY, priv TEXT)");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_contact (jid TEXT PRIMARY KEY, name TEXT)");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_send_log (ts INTEGER, jid TEXT)");
|
||||
db_->execute("CREATE INDEX IF NOT EXISTS wa_send_log_ts ON wa_send_log (ts)");
|
||||
// trigram tokenizer: substring and typo-tolerant candidates; rebuilt from wa_contact whenever the counts differ
|
||||
db_->execute("CREATE VIRTUAL TABLE IF NOT EXISTS wa_contact_fts USING fts5(jid UNINDEXED, name, number, tokenize='trigram')");
|
||||
{
|
||||
auto a = db_->execute("SELECT count(*) FROM wa_contact");
|
||||
auto b = db_->execute("SELECT count(*) FROM wa_contact_fts");
|
||||
if (a.first() != nullptr && b.first() != nullptr && (*a.first())[0].as_int() != (*b.first())[0].as_int()) {
|
||||
db_->execute("DELETE FROM wa_contact_fts");
|
||||
db_->execute("INSERT INTO wa_contact_fts (jid, name, number) SELECT jid, name, substr(jid, 1, instr(jid, '@') - 1) FROM wa_contact");
|
||||
}
|
||||
}
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_appstate_key (key_id TEXT PRIMARY KEY, data TEXT, timestamp INTEGER)");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_appstate_version (name TEXT PRIMARY KEY, version INTEGER, hash TEXT)");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_appstate_mac (name TEXT, index_mac TEXT, value_mac TEXT, PRIMARY KEY (name, index_mac))");
|
||||
db_->execute("CREATE TABLE IF NOT EXISTS wa_appstate_keyreq (key_id TEXT PRIMARY KEY, requested_at INTEGER)");
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
@@ -96,6 +118,9 @@ std::optional<Bytes> WaStore::get_prekey(uint32_t id) {
|
||||
void WaStore::put_contact(const std::string& jid, const std::string& name) {
|
||||
db_->execute("INSERT INTO wa_contact (jid, name) VALUES (?, ?) ON CONFLICT(jid) DO UPDATE SET name=excluded.name",
|
||||
{store::Cell(jid), store::Cell(name)});
|
||||
db_->execute("DELETE FROM wa_contact_fts WHERE jid=?", {store::Cell(jid)});
|
||||
db_->execute("INSERT INTO wa_contact_fts (jid, name, number) VALUES (?, ?, ?)",
|
||||
{store::Cell(jid), store::Cell(name), store::Cell(jid.substr(0, jid.find('@')))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
@@ -106,4 +131,152 @@ std::vector<std::pair<std::string, std::string>> WaStore::contacts() {
|
||||
return out;
|
||||
}
|
||||
|
||||
void WaStore::remove_contact(const std::string& jid) {
|
||||
db_->execute("DELETE FROM wa_contact WHERE jid=?", {store::Cell(jid)});
|
||||
db_->execute("DELETE FROM wa_contact_fts WHERE jid=?", {store::Cell(jid)});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
std::vector<WaStore::ContactHit> WaStore::search_contacts(const std::string& query, size_t limit, double floor) {
|
||||
std::vector<ContactHit> out;
|
||||
std::string q = query;
|
||||
while (!q.empty() && q.front() == ' ') q.erase(q.begin());
|
||||
while (!q.empty() && q.back() == ' ') q.pop_back();
|
||||
if (q.empty() || limit == 0) return out;
|
||||
auto all = contacts();
|
||||
if (all.size() > static_cast<size_t>(WaConsts::kWaContactsMax)) all.resize(WaConsts::kWaContactsMax);
|
||||
std::map<std::string, double> score; // jid -> best score
|
||||
// 1. fuzzy over "name number" so both a name and a number fragment match
|
||||
std::vector<std::string> texts;
|
||||
for (const auto& c : all) texts.push_back(c.second + " " + c.first.substr(0, c.first.find('@')));
|
||||
for (const auto& r : FuzzyMatcher::Rank(q, texts)) score[all[r.index].first] = std::max(score[all[r.index].first], r.score);
|
||||
// 2. FTS5 trigram candidates: any query token of 3+ chars that occurs in a name or number lifts that contact
|
||||
std::string match;
|
||||
std::string tok;
|
||||
auto flush = [&]() {
|
||||
if (tok.size() >= 3) {
|
||||
if (!match.empty()) match += " OR ";
|
||||
match += "\"" + tok + "\"";
|
||||
}
|
||||
tok.clear();
|
||||
};
|
||||
for (char ch : q) {
|
||||
if (ch == ' ' || ch == '"') flush();
|
||||
else tok.push_back(ch);
|
||||
}
|
||||
flush();
|
||||
if (!match.empty()) {
|
||||
try {
|
||||
auto cur = db_->execute("SELECT jid FROM wa_contact_fts WHERE wa_contact_fts MATCH ? ORDER BY bm25(wa_contact_fts) LIMIT 50",
|
||||
{store::Cell(match)});
|
||||
for (const auto& row : cur.rows) {
|
||||
double& s = score[row[0].text()];
|
||||
s = std::min(1.0, std::max(s, 0.6) + 0.3); // an exact substring is strong evidence; fuzzy decides the order within it
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
// a malformed MATCH expression must not break the fuzzy results
|
||||
}
|
||||
}
|
||||
std::map<std::string, std::string> names(all.begin(), all.end());
|
||||
for (const auto& [jid, sc] : score) {
|
||||
if (sc >= floor && names.count(jid)) out.push_back({jid, names[jid], sc});
|
||||
}
|
||||
std::stable_sort(out.begin(), out.end(), [](const ContactHit& a, const ContactHit& b) { return a.score > b.score; });
|
||||
if (out.size() > limit) out.resize(limit);
|
||||
return out;
|
||||
}
|
||||
|
||||
int WaStore::sends_since(int64_t seconds_ago) {
|
||||
int64_t cutoff = static_cast<int64_t>(std::time(nullptr)) - seconds_ago;
|
||||
auto cur = db_->execute("SELECT count(*) FROM wa_send_log WHERE ts >= ?", {store::Cell(cutoff)});
|
||||
return cur.first() == nullptr ? 0 : static_cast<int>((*cur.first())[0].as_int());
|
||||
}
|
||||
|
||||
void WaStore::record_send(const std::string& jid) {
|
||||
db_->execute("INSERT INTO wa_send_log (ts, jid) VALUES (?, ?)", {store::Cell(static_cast<int64_t>(std::time(nullptr))), store::Cell(jid)});
|
||||
// keep the log bounded: a day of history is all the caps look at
|
||||
db_->execute("DELETE FROM wa_send_log WHERE ts < ?", {store::Cell(static_cast<int64_t>(std::time(nullptr)) - 2 * 86400)});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
void WaStore::put_app_key(const Bytes& key_id, const Bytes& key_data, int64_t timestamp) {
|
||||
db_->execute(
|
||||
"INSERT INTO wa_appstate_key (key_id, data, timestamp) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(key_id) DO UPDATE SET data=excluded.data, timestamp=excluded.timestamp",
|
||||
{store::Cell(tohex(key_id)), store::Cell(tohex(key_data)), store::Cell(static_cast<long long>(timestamp))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
std::optional<Bytes> WaStore::app_key_data(const Bytes& key_id) {
|
||||
auto cur = db_->execute("SELECT data FROM wa_appstate_key WHERE key_id=?", {store::Cell(tohex(key_id))});
|
||||
const auto* row = cur.first();
|
||||
if (row == nullptr || (*row)[0].is_null()) return std::nullopt;
|
||||
return fromhex((*row)[0].text());
|
||||
}
|
||||
|
||||
std::optional<WaStorePort::AppStateVersion> WaStore::app_state_version(const std::string& name) {
|
||||
auto cur = db_->execute("SELECT version, hash FROM wa_appstate_version WHERE name=?", {store::Cell(name)});
|
||||
const auto* row = cur.first();
|
||||
if (row == nullptr) return std::nullopt;
|
||||
AppStateVersion v;
|
||||
v.version = static_cast<uint64_t>((*row)[0].as_int());
|
||||
v.hash = fromhex((*row)[1].text());
|
||||
return v;
|
||||
}
|
||||
|
||||
void WaStore::put_app_state_version(const std::string& name, const AppStateVersion& v) {
|
||||
db_->execute(
|
||||
"INSERT INTO wa_appstate_version (name, version, hash) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET version=excluded.version, hash=excluded.hash",
|
||||
{store::Cell(name), store::Cell(static_cast<long long>(v.version)), store::Cell(tohex(v.hash))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
std::optional<Bytes> WaStore::app_state_value_mac(const std::string& name, const Bytes& index_mac) {
|
||||
auto cur = db_->execute("SELECT value_mac FROM wa_appstate_mac WHERE name=? AND index_mac=?",
|
||||
{store::Cell(name), store::Cell(tohex(index_mac))});
|
||||
const auto* row = cur.first();
|
||||
if (row == nullptr) return std::nullopt;
|
||||
return fromhex((*row)[0].text());
|
||||
}
|
||||
|
||||
void WaStore::put_app_state_value_mac(const std::string& name, const Bytes& index_mac, const Bytes& value_mac) {
|
||||
db_->execute(
|
||||
"INSERT INTO wa_appstate_mac (name, index_mac, value_mac) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(name, index_mac) DO UPDATE SET value_mac=excluded.value_mac",
|
||||
{store::Cell(name), store::Cell(tohex(index_mac)), store::Cell(tohex(value_mac))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
void WaStore::delete_app_state_value_mac(const std::string& name, const Bytes& index_mac) {
|
||||
db_->execute("DELETE FROM wa_appstate_mac WHERE name=? AND index_mac=?", {store::Cell(name), store::Cell(tohex(index_mac))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
std::optional<int64_t> WaStore::app_key_request_time(const Bytes& key_id) {
|
||||
auto cur = db_->execute("SELECT requested_at FROM wa_appstate_keyreq WHERE key_id=?", {store::Cell(tohex(key_id))});
|
||||
const auto* row = cur.first();
|
||||
if (row == nullptr) return std::nullopt;
|
||||
return static_cast<int64_t>((*row)[0].as_int());
|
||||
}
|
||||
|
||||
void WaStore::put_app_key_request_time(const Bytes& key_id, int64_t when) {
|
||||
db_->execute(
|
||||
"INSERT INTO wa_appstate_keyreq (key_id, requested_at) VALUES (?, ?) "
|
||||
"ON CONFLICT(key_id) DO UPDATE SET requested_at=excluded.requested_at",
|
||||
{store::Cell(tohex(key_id)), store::Cell(static_cast<long long>(when))});
|
||||
db_->commit();
|
||||
}
|
||||
|
||||
void WaStore::clear_device() {
|
||||
db_->execute("DELETE FROM wa_device");
|
||||
db_->execute("DELETE FROM wa_session");
|
||||
db_->execute("DELETE FROM wa_prekey");
|
||||
// app-state keys and versions belong to the linked device: a new link must sync from scratch
|
||||
db_->execute("DELETE FROM wa_appstate_key");
|
||||
db_->execute("DELETE FROM wa_appstate_version");
|
||||
db_->execute("DELETE FROM wa_appstate_mac");
|
||||
db_->execute("DELETE FROM wa_appstate_keyreq");
|
||||
}
|
||||
|
||||
} // namespace detz::wa
|
||||
|
||||
@@ -30,9 +30,35 @@ class WaStore : public WaStorePort {
|
||||
std::optional<Bytes> get_prekey(uint32_t id) override;
|
||||
void put_contact(const std::string& jid, const std::string& name) override;
|
||||
std::vector<std::pair<std::string, std::string>> contacts() override;
|
||||
void remove_contact(const std::string& jid) override;
|
||||
|
||||
// One ranked contact search result.
|
||||
struct ContactHit {
|
||||
std::string jid, name;
|
||||
double score{0}; // 0..1, higher is better
|
||||
};
|
||||
// Ranked search over names and numbers: FTS5 trigram candidates plus the fuzzy matcher over every contact
|
||||
// (typos, partial names, "Niggel" for "Nigel"). Hits below `floor` are dropped; at most `limit` are returned.
|
||||
std::vector<ContactHit> search_contacts(const std::string& query, size_t limit = 10, double floor = 0.3);
|
||||
|
||||
// Outbound send log, the floor for the rate caps (WaConsts::kWaSendPerMinute / kWaSendPerDay).
|
||||
int sends_since(int64_t seconds_ago);
|
||||
void record_send(const std::string& jid);
|
||||
void put_app_key(const Bytes& key_id, const Bytes& key_data, int64_t timestamp) override;
|
||||
std::optional<Bytes> app_key_data(const Bytes& key_id) override;
|
||||
std::optional<AppStateVersion> app_state_version(const std::string& name) override;
|
||||
void put_app_state_version(const std::string& name, const AppStateVersion& v) override;
|
||||
std::optional<Bytes> app_state_value_mac(const std::string& name, const Bytes& index_mac) override;
|
||||
void put_app_state_value_mac(const std::string& name, const Bytes& index_mac, const Bytes& value_mac) override;
|
||||
void delete_app_state_value_mac(const std::string& name, const Bytes& index_mac) override;
|
||||
std::optional<int64_t> app_key_request_time(const Bytes& key_id) override;
|
||||
void put_app_key_request_time(const Bytes& key_id, int64_t when) override;
|
||||
|
||||
// true if a paired device is stored (used to decide link vs send).
|
||||
bool has_device();
|
||||
// Drops the device identity, its Signal sessions, prekeys and app-state keys and
|
||||
// versions, so the next link starts from a fresh identity. Contacts are kept.
|
||||
void clear_device();
|
||||
|
||||
private:
|
||||
std::unique_ptr<store::SqlDb> db_;
|
||||
|
||||
@@ -25,63 +25,84 @@ int WaWizard::link(const std::string& home, const std::string& profile, std::ost
|
||||
return 2;
|
||||
}
|
||||
|
||||
WaClient client(store);
|
||||
client.set_capture_path(home + "/whatsapp/last-error.log");
|
||||
bool linked = false;
|
||||
std::string linked_jid;
|
||||
client.on_log = [&](const std::string& s) { out << " " << s << "\n"; };
|
||||
client.on_qr = [&](const std::vector<std::string>& codes) {
|
||||
if (codes.empty()) return;
|
||||
out << "\nScan this in WhatsApp on your phone:\n";
|
||||
out << " Settings -> Linked Devices -> Link a Device\n\n";
|
||||
out << WaQr::render(codes.front()) << "\n";
|
||||
out << "(the code refreshes automatically; keep this window open)\n\n";
|
||||
};
|
||||
client.on_paired = [&](const std::string& jid) {
|
||||
linked = true;
|
||||
linked_jid = jid;
|
||||
};
|
||||
|
||||
out << "Connecting to WhatsApp...\n";
|
||||
if (!client.connect()) {
|
||||
out << "Could not connect to WhatsApp. Check your internet connection and try again.\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto prev = std::signal(SIGINT, on_sigint);
|
||||
g_interrupt.store(false);
|
||||
client.run_until([&]() { return linked || g_interrupt.load(); }, WaConsts::kWaPairTimeout);
|
||||
std::signal(SIGINT, prev);
|
||||
int code = 1;
|
||||
// One pass = pair with the phone, then log in with the saved identity. The server may
|
||||
// reject a fresh pairing (401/403); that pass discards the identity and pairs once more.
|
||||
for (int attempt = 0; attempt <= WaConsts::kWaLinkRetries; ++attempt) {
|
||||
WaClient client(store);
|
||||
client.set_capture_path(home + "/whatsapp/last-error.log");
|
||||
bool linked = false;
|
||||
std::string linked_jid;
|
||||
client.on_log = [&](const std::string& s) { out << " " << s << "\n"; };
|
||||
client.on_qr = [&](const std::vector<std::string>& codes) {
|
||||
if (codes.empty()) return;
|
||||
out << "\nScan this in WhatsApp on your phone:\n";
|
||||
out << " Settings -> Linked Devices -> Link a Device\n\n";
|
||||
out << WaQr::render(codes.front()) << "\n";
|
||||
out << "(the code refreshes automatically; keep this window open)\n\n";
|
||||
};
|
||||
client.on_paired = [&](const std::string& jid) {
|
||||
linked = true;
|
||||
linked_jid = jid;
|
||||
};
|
||||
|
||||
if (g_interrupt.load() && !linked) {
|
||||
out << "\nCancelled.\n";
|
||||
return 1;
|
||||
out << "Connecting to WhatsApp...\n";
|
||||
if (!client.connect()) {
|
||||
out << "Could not connect to WhatsApp. Check your internet connection and try again.\n";
|
||||
break;
|
||||
}
|
||||
client.run_until([&]() { return linked || g_interrupt.load(); }, WaConsts::kWaPairTimeout);
|
||||
if (g_interrupt.load() && !linked) {
|
||||
out << "\nCancelled.\n";
|
||||
break;
|
||||
}
|
||||
if (!linked) {
|
||||
out << "\nTimed out waiting for the phone to scan the code. Try again.\n";
|
||||
break;
|
||||
}
|
||||
out << "\nPaired as " << linked_jid << ". Finishing the link...\n";
|
||||
client.await_pair_restart(WaConsts::kWaPairRestartTimeout);
|
||||
|
||||
out << "Logging in to confirm...\n";
|
||||
if (client.login(WaConsts::kWaLoginTimeout)) {
|
||||
out << "WhatsApp is linked and ready as " << linked_jid << ".\n";
|
||||
code = 0;
|
||||
break;
|
||||
}
|
||||
int reason = client.last_failure_code();
|
||||
client.disconnect();
|
||||
if ((reason == 401 || reason == 403) && attempt < WaConsts::kWaLinkRetries) {
|
||||
out << "WhatsApp rejected the new device (" << reason << "). Discarding it and pairing once more.\n";
|
||||
store.clear_device();
|
||||
continue;
|
||||
}
|
||||
out << "Linked as " << linked_jid << ", but the login was not confirmed"
|
||||
<< (reason ? " (server reason " + std::to_string(reason) + ")" : "")
|
||||
<< ". Run `detz --whatsapp-link` again after unlinking with `detz --whatsapp-unlink`.\n";
|
||||
break;
|
||||
}
|
||||
if (!linked) {
|
||||
out << "\nTimed out waiting for the phone to scan the code. Try again.\n";
|
||||
return 1;
|
||||
}
|
||||
// The server drops the socket right after pairing; reconnect to log in and
|
||||
// confirm the saved identity works.
|
||||
client.disconnect();
|
||||
out << "\nLinked as " << linked_jid << ". Logging in to confirm...\n";
|
||||
if (client.connect()) {
|
||||
client.run_until([&]() { return client.logged_in(); }, 20);
|
||||
}
|
||||
out << (client.logged_in() ? "WhatsApp is linked and ready.\n"
|
||||
: "Linked, but the login confirmation did not complete; it should work on next use.\n");
|
||||
return 0;
|
||||
std::signal(SIGINT, prev);
|
||||
return code;
|
||||
}
|
||||
|
||||
int WaWizard::unlink(const std::string& home, const std::string& profile, std::ostream& out) {
|
||||
std::string path = WaStore::db_path(home, profile);
|
||||
std::error_code ec;
|
||||
if (std::filesystem::remove(path, ec)) {
|
||||
out << "WhatsApp unlinked for profile '" << (profile.empty() ? "default" : profile) << "'.\n";
|
||||
return 0;
|
||||
if (!std::filesystem::exists(WaStore::db_path(home, profile), ec)) {
|
||||
out << "No WhatsApp link found for this profile.\n";
|
||||
return 1;
|
||||
}
|
||||
out << "No WhatsApp link found for this profile.\n";
|
||||
return 1;
|
||||
// Drop the device identity, sessions and prekeys only. The contacts and the send log stay: the send log is the
|
||||
// floor under the rate caps and must not be resettable by unlinking.
|
||||
WaStore store(home, profile);
|
||||
if (!store.has_device()) {
|
||||
out << "No WhatsApp link found for this profile.\n";
|
||||
return 1;
|
||||
}
|
||||
store.clear_device();
|
||||
out << "WhatsApp unlinked for profile '" << (profile.empty() ? "default" : profile) << "'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int WaWizard::status(const std::string& home, const std::string& profile, std::ostream& out) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// clean_history must never hand a provider a tool result whose tool call was not issued: devplace answers
|
||||
// that with HTTP 400 "No function call found for function call output with call_id ..." and the whole turn
|
||||
// fails (everythingwrong.md; reproduced against the live endpoint with an orphan result).
|
||||
#include <cstdio>
|
||||
#include "helpers.h"
|
||||
#include "json.h"
|
||||
using detz::json::Value;
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if (!(cond)) { g_fail++; std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
||||
|
||||
static Value msg(const char* role, const char* content) {
|
||||
Value m = Value::object();
|
||||
m.set("role", role);
|
||||
m.set("content", content);
|
||||
return m;
|
||||
}
|
||||
static Value assistant_calling(const char* id) {
|
||||
Value fn = Value::object();
|
||||
fn.set("name", "fork");
|
||||
fn.set("arguments", "{}");
|
||||
Value call = Value::object();
|
||||
call.set("id", id);
|
||||
call.set("type", "function");
|
||||
call.set("function", fn);
|
||||
Value calls = Value::array();
|
||||
calls.push(call);
|
||||
Value m = Value::object();
|
||||
m.set("role", "assistant");
|
||||
m.set("content", Value());
|
||||
m.set("tool_calls", calls);
|
||||
return m;
|
||||
}
|
||||
static Value tool_result(const char* id) {
|
||||
Value m = msg("tool", "done");
|
||||
m.set("tool_call_id", id);
|
||||
return m;
|
||||
}
|
||||
|
||||
int main() {
|
||||
// a window that starts on a tool result (compaction cut its assistant message away)
|
||||
Value cut = Value::array();
|
||||
cut.push(msg("system", "s"));
|
||||
cut.push(tool_result("call_orphan"));
|
||||
cut.push(msg("user", "go"));
|
||||
cut.push(assistant_calling("call_a"));
|
||||
cut.push(tool_result("call_a"));
|
||||
Value cleaned = detz::llm::drop_orphan_tool_results(detz::llm::clean_history(cut));
|
||||
CHECK(cleaned.size() == 4);
|
||||
bool orphan_left = false;
|
||||
for (const auto& m : cleaned.items()) {
|
||||
if (m.get("role").str() == "tool" && m.get("tool_call_id").str() == "call_orphan") orphan_left = true;
|
||||
}
|
||||
CHECK(!orphan_left);
|
||||
// a healthy exchange is untouched
|
||||
Value ok = Value::array();
|
||||
ok.push(msg("user", "go"));
|
||||
ok.push(assistant_calling("call_b"));
|
||||
ok.push(tool_result("call_b"));
|
||||
CHECK(detz::llm::drop_orphan_tool_results(detz::llm::clean_history(ok)).size() == 3);
|
||||
// clean_history itself stays byte-for-byte tai.py behaviour: it keeps the orphan (agent_diff.py compares it)
|
||||
CHECK(detz::llm::clean_history(cut).size() == 5);
|
||||
std::printf(g_fail ? "llm_history_test: %d FAILED\n" : "llm_history_test: ok\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ STEPS = [
|
||||
("termshot.unit", "termshot", [[b("termshot_test")]], None),
|
||||
("whatsapp.unit", "whatsapp", [[b("whatsapp_test"), "tests/whatsapp_data"]], None),
|
||||
("muse.flags", "llm", [[b("muse_flags_test")]], None),
|
||||
("llm.history", "llm", [[b("llm_history_test")]], None),
|
||||
# orchestration (sub agent panels on the multiplexer)
|
||||
("orch.unit", "orch", [[b("orch_test")]], None),
|
||||
("orch.mplex_e2e", "orch", [[PY, "-I", t("orch_mplex_e2e.py"), b("detz"), b("orch_live")]], None),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 José Felipe Leal
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,289 @@
|
||||
// Oracle generator for detz::wa app-state sync vectors.
|
||||
//
|
||||
// Every formula is copied from whatsmeow (MPL-2.0) appstate/keys.go, hash.go,
|
||||
// decode.go and encode of the lthash package, and every wire message is
|
||||
// produced by whatsmeow's own protobuf types, so the C++ decoder is checked
|
||||
// against the reference bytes, not against our own encoder.
|
||||
//
|
||||
// Run from a module that requires go.mau.fi/whatsmeow:
|
||||
// go run appstate_oracle.go > appstate_vectors.json (inside a module that requires go.mau.fi/whatsmeow)
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
|
||||
"go.mau.fi/whatsmeow/appstate/lthash"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/proto/waServerSync"
|
||||
"go.mau.fi/whatsmeow/proto/waSyncAction"
|
||||
"go.mau.fi/whatsmeow/util/hkdfutil"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const collection = "critical_unblock_low"
|
||||
|
||||
type keys struct{ index, valueEnc, valueMAC, snapshotMAC, patchMAC []byte }
|
||||
|
||||
func expand(keyData []byte) keys {
|
||||
e := hkdfutil.SHA256(keyData, nil, []byte("WhatsApp Mutation Keys"), 160)
|
||||
return keys{e[0:32], e[32:64], e[64:96], e[96:128], e[128:160]}
|
||||
}
|
||||
|
||||
func u64(v uint64) []byte { b := make([]byte, 8); binary.BigEndian.PutUint64(b, v); return b }
|
||||
|
||||
func concatHMAC(alg func() hash.Hash, key []byte, data ...[]byte) []byte {
|
||||
h := hmac.New(alg, key)
|
||||
for _, d := range data {
|
||||
h.Write(d)
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func contentMAC(op int, data, keyID, key []byte) []byte {
|
||||
return concatHMAC(sha512.New, key, []byte{byte(op) + 1}, keyID, data, u64(uint64(len(keyID)+1)))[:32]
|
||||
}
|
||||
|
||||
func aesCBCEncrypt(key, iv, pt []byte) []byte {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pad := 16 - len(pt)%16
|
||||
padded := append(append([]byte{}, pt...), bytes.Repeat([]byte{byte(pad)}, pad)...)
|
||||
out := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
|
||||
return out
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
op int // 0 SET, 1 REMOVE
|
||||
index []string
|
||||
action *waSyncAction.SyncActionValue
|
||||
valMAC []byte // value MAC of this entry (SET only, used for the hash)
|
||||
idxMAC []byte
|
||||
valBlob []byte
|
||||
}
|
||||
|
||||
var ivSeed byte = 1
|
||||
|
||||
func encrypt(k keys, keyID []byte, op int, index []string, action *waSyncAction.SyncActionValue) entry {
|
||||
idxJSON, _ := json.Marshal(index)
|
||||
sad := &waSyncAction.SyncActionData{Index: idxJSON, Value: action, Version: proto.Int32(2)}
|
||||
pt, err := proto.Marshal(sad)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
iv := bytes.Repeat([]byte{ivSeed}, 16)
|
||||
ivSeed++
|
||||
ct := aesCBCEncrypt(k.valueEnc, iv, pt)
|
||||
content := append(append([]byte{}, iv...), ct...)
|
||||
vmac := contentMAC(op, content, keyID, k.valueMAC)
|
||||
blob := append(append([]byte{}, content...), vmac...)
|
||||
idxMAC := concatHMAC(sha256.New, k.index, idxJSON)
|
||||
return entry{op: op, index: index, action: action, valMAC: vmac, idxMAC: idxMAC, valBlob: blob}
|
||||
}
|
||||
|
||||
func rec(e entry, keyID []byte) *waServerSync.SyncdRecord {
|
||||
return &waServerSync.SyncdRecord{
|
||||
Index: &waServerSync.SyncdIndex{Blob: e.idxMAC},
|
||||
Value: &waServerSync.SyncdValue{Blob: e.valBlob},
|
||||
KeyID: &waServerSync.KeyId{ID: keyID},
|
||||
}
|
||||
}
|
||||
|
||||
func contact(full, first string) *waSyncAction.SyncActionValue {
|
||||
return &waSyncAction.SyncActionValue{
|
||||
Timestamp: proto.Int64(1767225600000),
|
||||
ContactAction: &waSyncAction.ContactAction{FullName: proto.String(full), FirstName: proto.String(first)},
|
||||
}
|
||||
}
|
||||
|
||||
type hashState struct {
|
||||
version uint64
|
||||
hash [128]byte
|
||||
}
|
||||
|
||||
func (hs *hashState) apply(removed, added [][]byte) {
|
||||
lthash.WAPatchIntegrity.SubtractThenAddInPlace(hs.hash[:], removed, added)
|
||||
}
|
||||
|
||||
func valueMACOf(e entry) []byte { return e.valMAC }
|
||||
|
||||
func main() {
|
||||
keyID := []byte{0x00, 0x00, 0x00, 0x01, 0x42, 0x10}
|
||||
keyData := make([]byte, 32)
|
||||
for i := range keyData {
|
||||
keyData[i] = byte(0x11 + i)
|
||||
}
|
||||
k := expand(keyData)
|
||||
|
||||
// ---- snapshot v1: four records, all SET ----
|
||||
A := encrypt(k, keyID, 0, []string{"contact", "31611111111@s.whatsapp.net"}, contact("Nigel Dokter", "Nigel"))
|
||||
B := encrypt(k, keyID, 0, []string{"contact", "31622222222@s.whatsapp.net"}, contact("", "Ann"))
|
||||
C := encrypt(k, keyID, 0, []string{"contact", "31633333333@s.whatsapp.net"}, contact("Pieter Bos", "Pieter"))
|
||||
D := encrypt(k, keyID, 0, []string{"setting_pushName"}, &waSyncAction.SyncActionValue{Timestamp: proto.Int64(1767225600000), PushNameSetting: &waSyncAction.PushNameSetting{Name: proto.String("detz")}})
|
||||
snapEntries := []entry{A, B, C, D}
|
||||
|
||||
state := hashState{version: 1}
|
||||
var added [][]byte
|
||||
var snapRecs []*waServerSync.SyncdRecord
|
||||
for _, e := range snapEntries {
|
||||
added = append(added, e.valMAC)
|
||||
snapRecs = append(snapRecs, rec(e, keyID))
|
||||
}
|
||||
state.apply(nil, added)
|
||||
snapMAC := concatHMAC(sha256.New, k.snapshotMAC, state.hash[:], u64(state.version), []byte(collection))
|
||||
snapshot := &waServerSync.SyncdSnapshot{
|
||||
Version: &waServerSync.SyncdVersion{Version: proto.Uint64(state.version)},
|
||||
Records: snapRecs,
|
||||
Mac: snapMAC,
|
||||
KeyID: &waServerSync.KeyId{ID: keyID},
|
||||
}
|
||||
snapBytes, err := proto.Marshal(snapshot)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
snapHash := hex.EncodeToString(state.hash[:])
|
||||
|
||||
// previous value MACs by index MAC, as the store would hold them after the snapshot
|
||||
prev := map[string][]byte{}
|
||||
for _, e := range snapEntries {
|
||||
prev[hex.EncodeToString(e.idxMAC)] = e.valMAC
|
||||
}
|
||||
|
||||
// ---- patch v2 ----
|
||||
// m1: SET A again (new name): removes A's snapshot value MAC, adds the new one
|
||||
A2 := encrypt(k, keyID, 0, []string{"contact", "31611111111@s.whatsapp.net"}, contact("Nigel Dokter (work)", "Nigel"))
|
||||
// m2: REMOVE C: removes C's snapshot value MAC
|
||||
C2 := encrypt(k, keyID, 1, []string{"contact", "31633333333@s.whatsapp.net"}, &waSyncAction.SyncActionValue{Timestamp: proto.Int64(1767225600000)})
|
||||
// m3: SET E with first name only
|
||||
E1 := encrypt(k, keyID, 0, []string{"contact", "31644444444@s.whatsapp.net"}, contact("", "Joop"))
|
||||
// m4: SET E again (in-patch previous value, no DB lookup)
|
||||
E2 := encrypt(k, keyID, 0, []string{"contact", "31644444444@s.whatsapp.net"}, contact("Joop Visser", "Joop"))
|
||||
// m5: SET F with neither name (must be stored as unnamed = skipped by the contact sync)
|
||||
F := encrypt(k, keyID, 0, []string{"contact", "31655555555@s.whatsapp.net"}, contact("", ""))
|
||||
// m6: a non-contact SET with an index the contact sync must ignore
|
||||
M := encrypt(k, keyID, 0, []string{"setting_pushName"}, &waSyncAction.SyncActionValue{Timestamp: proto.Int64(1767225600001), PushNameSetting: &waSyncAction.PushNameSetting{Name: proto.String("detz2")}})
|
||||
|
||||
patchEntries := []entry{A2, C2, E1, E2, F, M}
|
||||
var removed [][]byte
|
||||
var padded [][]byte
|
||||
_ = padded
|
||||
inPatch := map[string]int{} // idxMAC -> index of latest mutation in this patch that touched it
|
||||
var addedP [][]byte
|
||||
for i, e := range patchEntries {
|
||||
key := hex.EncodeToString(e.idxMAC)
|
||||
if j, ok := inPatch[key]; ok {
|
||||
if patchEntries[j].op == 0 {
|
||||
removed = append(removed, patchEntries[j].valMAC)
|
||||
}
|
||||
} else if pv, ok := prev[key]; ok {
|
||||
removed = append(removed, pv)
|
||||
}
|
||||
inPatch[key] = i
|
||||
if e.op == 0 {
|
||||
addedP = append(addedP, e.valMAC)
|
||||
}
|
||||
}
|
||||
// the prior value of an index is consumed once; mirror whatsmeow: removal = prev SET value
|
||||
state.version = 2
|
||||
state.apply(removed, addedP)
|
||||
|
||||
patchSnapMAC := concatHMAC(sha256.New, k.snapshotMAC, state.hash[:], u64(state.version), []byte(collection))
|
||||
var muts []*waServerSync.SyncdMutation
|
||||
var valMACs [][]byte
|
||||
for _, e := range patchEntries {
|
||||
op := waServerSync.SyncdMutation_SET
|
||||
if e.op == 1 {
|
||||
op = waServerSync.SyncdMutation_REMOVE
|
||||
}
|
||||
muts = append(muts, &waServerSync.SyncdMutation{Operation: op.Enum(), Record: rec(e, keyID)})
|
||||
valMACs = append(valMACs, e.valMAC)
|
||||
}
|
||||
// patch MAC: snapshotMAC || value MACs || version || name
|
||||
pmData := [][]byte{patchSnapMAC}
|
||||
pmData = append(pmData, valMACs...)
|
||||
pmData = append(pmData, u64(state.version), []byte(collection))
|
||||
patchMAC := concatHMAC(sha256.New, k.patchMAC, pmData...)
|
||||
patch := &waServerSync.SyncdPatch{
|
||||
Version: &waServerSync.SyncdVersion{Version: proto.Uint64(state.version)},
|
||||
Mutations: muts,
|
||||
SnapshotMAC: patchSnapMAC,
|
||||
PatchMAC: patchMAC,
|
||||
KeyID: &waServerSync.KeyId{ID: keyID},
|
||||
}
|
||||
patchBytes, err := proto.Marshal(patch)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// tampered copy: flip one bit of the value MAC of mutation 2 (E1). The C++ side must reject it.
|
||||
tam := proto.Clone(patch).(*waServerSync.SyncdPatch)
|
||||
tb := tam.Mutations[2].Record.Value.Blob
|
||||
tb[len(tb)-1] ^= 0x01
|
||||
tamBytes, _ := proto.Marshal(tam)
|
||||
|
||||
// ---- key share and key request messages (E2E Message) ----
|
||||
ksMsg := &waE2E.Message{ProtocolMessage: &waE2E.ProtocolMessage{
|
||||
Type: waE2E.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE.Enum(),
|
||||
AppStateSyncKeyShare: &waE2E.AppStateSyncKeyShare{Keys: []*waE2E.AppStateSyncKey{{
|
||||
KeyID: &waE2E.AppStateSyncKeyId{KeyID: keyID},
|
||||
KeyData: &waE2E.AppStateSyncKeyData{
|
||||
KeyData: keyData,
|
||||
Fingerprint: &waE2E.AppStateSyncKeyFingerprint{RawID: proto.Uint32(7), CurrentIndex: proto.Uint32(1), DeviceIndexes: []uint32{0, 3}},
|
||||
Timestamp: proto.Int64(1767225600123),
|
||||
},
|
||||
}}},
|
||||
}}
|
||||
ksBytes, _ := proto.Marshal(ksMsg)
|
||||
krMsg := &waE2E.Message{ProtocolMessage: &waE2E.ProtocolMessage{
|
||||
Type: waE2E.ProtocolMessage_APP_STATE_SYNC_KEY_REQUEST.Enum(),
|
||||
AppStateSyncKeyRequest: &waE2E.AppStateSyncKeyRequest{KeyIDs: []*waE2E.AppStateSyncKeyId{{KeyID: keyID}}},
|
||||
}}
|
||||
krBytes, _ := proto.Marshal(krMsg)
|
||||
|
||||
// final hash must equal a sequential build from the snapshot values
|
||||
out := map[string]any{
|
||||
"collection": collection,
|
||||
"key_id": hex.EncodeToString(keyID),
|
||||
"key_data": hex.EncodeToString(keyData),
|
||||
"expanded_keys": hex.EncodeToString(k.index) + hex.EncodeToString(k.valueEnc) + hex.EncodeToString(k.valueMAC) + hex.EncodeToString(k.snapshotMAC) + hex.EncodeToString(k.patchMAC),
|
||||
"snapshot": hex.EncodeToString(snapBytes),
|
||||
"snapshot_hash_hex": snapHash,
|
||||
"patch": hex.EncodeToString(patchBytes),
|
||||
"patch_tampered": hex.EncodeToString(tamBytes),
|
||||
"patch_hash_hex": hex.EncodeToString(state.hash[:]),
|
||||
"patch_version": state.version,
|
||||
"key_share_message": hex.EncodeToString(ksBytes),
|
||||
"key_request_message": hex.EncodeToString(krBytes),
|
||||
"expect_contacts_after_snapshot": map[string]string{
|
||||
"31611111111@s.whatsapp.net": "Nigel Dokter",
|
||||
"31622222222@s.whatsapp.net": "Ann",
|
||||
"31633333333@s.whatsapp.net": "Pieter Bos",
|
||||
},
|
||||
"expect_contacts_after_patch": map[string]string{
|
||||
"31611111111@s.whatsapp.net": "Nigel Dokter (work)",
|
||||
"31622222222@s.whatsapp.net": "Ann",
|
||||
"31644444444@s.whatsapp.net": "Joop Visser",
|
||||
},
|
||||
"expect_removed_after_patch": []string{"31633333333@s.whatsapp.net"},
|
||||
"expect_unnamed_skipped": []string{"31655555555@s.whatsapp.net"},
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(out); err != nil {
|
||||
panic(fmt.Sprint(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"collection": "critical_unblock_low",
|
||||
"expanded_keys": "cd8d4c08d549d621e238e4521ba9b83efa46bf5525ec7347f2d9af05bf30c551e147faaf6d0f241eb80a501f8718ea0c045b32581233cece26bc4b742ef0d56ee66c766dc82e65ca0ad76d43e859c4a0785abfb3f2d91d23349464d5fd4e4d18b831b21038203d3de16e909dcf537d9054b1bbe126abe795968996226c192a0e311ecad924ebd534c372ff80516959a179f4ef9a65bf11d88e995bb139f9282c",
|
||||
"expect_contacts_after_patch": {
|
||||
"31611111111@s.whatsapp.net": "Nigel Dokter (work)",
|
||||
"31622222222@s.whatsapp.net": "Ann",
|
||||
"31644444444@s.whatsapp.net": "Joop Visser"
|
||||
},
|
||||
"expect_contacts_after_snapshot": {
|
||||
"31611111111@s.whatsapp.net": "Nigel Dokter",
|
||||
"31622222222@s.whatsapp.net": "Ann",
|
||||
"31633333333@s.whatsapp.net": "Pieter Bos"
|
||||
},
|
||||
"expect_removed_after_patch": [
|
||||
"31633333333@s.whatsapp.net"
|
||||
],
|
||||
"expect_unnamed_skipped": [
|
||||
"31655555555@s.whatsapp.net"
|
||||
],
|
||||
"key_data": "1112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30",
|
||||
"key_id": "000000014210",
|
||||
"key_request_message": "620e1007420a0a080a06000000014210",
|
||||
"key_share_message": "624510063a410a3f0a080a0600000001421012330a201112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f301208080710011a02000318fbd0eab6b733",
|
||||
"patch": "0a02080212c901080012c4010a220a20fb70a76ccd2e83fd6676a021389dc1f435199a478ac43bab5c1384f3ddc209a91293010a9001050505050505050505050505050505051e3550be94e098243afc8ad308ff35e5a9ffa33626f4af1a67c122751d3c4e260103f19baa2eca076704fb13c76fc71c5046656b56c64a2a1525e6be93c958ce3dc0fa0aa9960428a98e9584bb233e173fb220e5268c2ad66a90a21f453d91f5f1563729f0ab74c3d039b15e0b24181ee05be9adb716e934a9cf8ae9a946d7c41a080a0600000001421012a701080112a2010a220a2019605d94a9b2875981501d0805664425f7242e0b0325bb2b16cd9c6c45c11e7112720a7006060606060606060606060606060606401ab713c69ad69e09d699ea7778a16920034fe2bde1e70e6fdfd3e05dbc545c1c5143278003b549dbd9320c886aedd333dff0ffabed6360450f7d6cb57529e72b9e9232d736f3c01ec093b42e301f674358aab982892b2a7e39fb6afad0def81a080a0600000001421012a701080012a2010a220a20d9df8cd984c860eb0e8f8e792a9a079c94dafecfd66eaec67de5681a846d389d12720a70070707070707070707070707070707078c14596c705bf42658a67581425b825fd94f946a5ba9647cb015b23bb41b0340e7edc21b4c9f6842824541b20616222f21787255527eea17288babe818e496a15ff8e219b9431efa7b5bbdad7ca72ec0c87c9ff4b926f6697e5664483c5c6ef91a080a0600000001421012b901080012b4010a220a20d9df8cd984c860eb0e8f8e792a9a079c94dafecfd66eaec67de5681a846d389d1283010a8001080808080808080808080808080808089149cc2abddd4b6b0ee629b85fff882077823a7fe524cac00acd12acf4f4e2ce10a90a25116085c4d539cddf8c6c301b85c052ff242957c587bb2057d0cdc8dc4528295ac7631e580742cbc0f09220099ab34e2c6795c8b772dae8c4dfd7549efddb1b05cbc6aec318cd77d2593e3a161a080a0600000001421012a701080012a2010a220a207ffec716397a4c3b916318eeeafcd6b3a670dc60fc7abb854afb2bda04258b2012720a7009090909090909090909090909090909f0666f275343cbcf3a6ff4497693cc406860037f496c1c498220f9856a9dc381255d355eecc27c5a58d953f3390b7461e6dccca3d74837d6b711861c0543361753b9e82057c2c86ccb1eb7dbff30bf0f8e6d262c0a1a015f666ec6cc951270f31a080a0600000001421012970108001292010a220a20db51e692c7be8aa6a4b3983c3b47e4129aa309081fddfbcb3ff5551141a4cf4612620a600a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0ab680bec5b55a236e3fea50f5101c0fd3028b33e68bfc9b8e81e5be62be98f37acad956885527a3f04e6898388cb43ff51e6cbbcbc1495bd0b3ccf5d413043029e35dcf01c2500584208bee8ceb5afc521a080a060000000142102220f49ac90fe873351cf971f99dbca797888bbfb887f1918137d77d04255b654dca2a2004253ae1745f29e61f6a4ffe39993c5791355e2659945c4f738dd4f9c842f17e32080a06000000014210",
|
||||
"patch_hash_hex": "09e15626722d21e6e7d648d078029502b43638c1dd8da470b8845fec05b4db67e9ece5d1e124c619f31348870c9d3aa6bbbb1670557080d361cb703934094d0e0523cc54524d47871c732c16fcce3be5ebd96b24477ec10277fafecfec11fc51b0cebfa7297d4b2f863e2cca79823c87017913abbcdca07d388b0722f8a9c364",
|
||||
"patch_tampered": "0a02080212c901080012c4010a220a20fb70a76ccd2e83fd6676a021389dc1f435199a478ac43bab5c1384f3ddc209a91293010a9001050505050505050505050505050505051e3550be94e098243afc8ad308ff35e5a9ffa33626f4af1a67c122751d3c4e260103f19baa2eca076704fb13c76fc71c5046656b56c64a2a1525e6be93c958ce3dc0fa0aa9960428a98e9584bb233e173fb220e5268c2ad66a90a21f453d91f5f1563729f0ab74c3d039b15e0b24181ee05be9adb716e934a9cf8ae9a946d7c41a080a0600000001421012a701080112a2010a220a2019605d94a9b2875981501d0805664425f7242e0b0325bb2b16cd9c6c45c11e7112720a7006060606060606060606060606060606401ab713c69ad69e09d699ea7778a16920034fe2bde1e70e6fdfd3e05dbc545c1c5143278003b549dbd9320c886aedd333dff0ffabed6360450f7d6cb57529e72b9e9232d736f3c01ec093b42e301f674358aab982892b2a7e39fb6afad0def81a080a0600000001421012a701080012a2010a220a20d9df8cd984c860eb0e8f8e792a9a079c94dafecfd66eaec67de5681a846d389d12720a70070707070707070707070707070707078c14596c705bf42658a67581425b825fd94f946a5ba9647cb015b23bb41b0340e7edc21b4c9f6842824541b20616222f21787255527eea17288babe818e496a15ff8e219b9431efa7b5bbdad7ca72ec0c87c9ff4b926f6697e5664483c5c6ef81a080a0600000001421012b901080012b4010a220a20d9df8cd984c860eb0e8f8e792a9a079c94dafecfd66eaec67de5681a846d389d1283010a8001080808080808080808080808080808089149cc2abddd4b6b0ee629b85fff882077823a7fe524cac00acd12acf4f4e2ce10a90a25116085c4d539cddf8c6c301b85c052ff242957c587bb2057d0cdc8dc4528295ac7631e580742cbc0f09220099ab34e2c6795c8b772dae8c4dfd7549efddb1b05cbc6aec318cd77d2593e3a161a080a0600000001421012a701080012a2010a220a207ffec716397a4c3b916318eeeafcd6b3a670dc60fc7abb854afb2bda04258b2012720a7009090909090909090909090909090909f0666f275343cbcf3a6ff4497693cc406860037f496c1c498220f9856a9dc381255d355eecc27c5a58d953f3390b7461e6dccca3d74837d6b711861c0543361753b9e82057c2c86ccb1eb7dbff30bf0f8e6d262c0a1a015f666ec6cc951270f31a080a0600000001421012970108001292010a220a20db51e692c7be8aa6a4b3983c3b47e4129aa309081fddfbcb3ff5551141a4cf4612620a600a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0ab680bec5b55a236e3fea50f5101c0fd3028b33e68bfc9b8e81e5be62be98f37acad956885527a3f04e6898388cb43ff51e6cbbcbc1495bd0b3ccf5d413043029e35dcf01c2500584208bee8ceb5afc521a080a060000000142102220f49ac90fe873351cf971f99dbca797888bbfb887f1918137d77d04255b654dca2a2004253ae1745f29e61f6a4ffe39993c5791355e2659945c4f738dd4f9c842f17e32080a06000000014210",
|
||||
"patch_version": 2,
|
||||
"snapshot": "0a02080112b4010a220a20fb70a76ccd2e83fd6676a021389dc1f435199a478ac43bab5c1384f3ddc209a91283010a80010101010101010101010101010101010141a7c88cb5b3ed2568af4d0bcde254a4297aa05f2e66927051856578a0fa49adfe2c34c13b46803b1eebae9a40c047fc4190cbcb8bd4a0f72582f471fb595dd0d028eecaad2418f25769237ae1a3cfdb9a800ba36c321ae146c231f3fc5318ab271b07a649fd9a11a830a965fce375941a080a0600000001421012a2010a220a20409c584d6581b70b6b5b3418aca3c88017cfefaea3048a6c7b3c65ef29b8cca912720a700202020202020202020202020202020267a519dd453181150c6f6d3950f665812244cc3469b456f16da2ab46c571932099b170788dfdf016108467d3beb19ea209b8fd830faefa6a7b057c13d709e495bca4b7977101dab1638296667ed4033fd8f6df4fdca31b4cea7223106f2a175c1a080a0600000001421012b4010a220a2019605d94a9b2875981501d0805664425f7242e0b0325bb2b16cd9c6c45c11e711283010a800103030303030303030303030303030303d9781cd0d2eb765aa9c1e49a22c512a6a6c8592cab67b986811737297d71b532938f70d44bc2b0eead1602d57767831277587ab9e12c6434eb5ab2b53a132541a2d28dceeaf610a4c7bb0f5532aa224ad176da3e14453da247e5bb11d43f027569506097194a72c95048e1b26dad5f741a080a060000000142101292010a220a20db51e692c7be8aa6a4b3983c3b47e4129aa309081fddfbcb3ff5551141a4cf4612620a60040404040404040404040404040404048c2d9a50c1d5bc18c174ae3cd612c1d4c59597fffc20e3786446d7e682373d5d8c803ddd2b91ad1777984f62428b83b660535d2c18f594089e72d47b43175d69d4d059249a3fc98d72b3ef1f432cd67e1a080a060000000142101a202747693d4d70f0fd07687f2e1bbecbe7afdc2a5d760ed7514dc0d8ebeaa1c8a922080a06000000014210",
|
||||
"snapshot_hash_hex": "394dff58d81d16ae663a3660c76d694c93bdb729af13ad3262f69899ebb176a6107663299c0ec2a03126dd6543703029ba053f5ede70148289b616d32e2d54c9ac0c3a8b4d1469346a74e33db3b30b4f4e790ce88167003d92b0007529d63231d2c9160de8474fb69a6edc8d1a85af1eecb2785ade97db629a913924548bb281"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -4,8 +4,13 @@
|
||||
// checks of the wire codec, crypto, Noise handshake and Signal session. The
|
||||
// live pairing/send paths need a phone and are not covered here. See
|
||||
// docs/whatsapp.md.
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "json.h"
|
||||
@@ -15,6 +20,10 @@
|
||||
#include "wa_noise.h"
|
||||
#include "wa_proto.h"
|
||||
#include "wa_signal.h"
|
||||
#include "wa_appstate.h"
|
||||
#include "wa_media.h"
|
||||
#include "wa_receiver.h"
|
||||
#include "wa_store.h"
|
||||
|
||||
using namespace detz::wa;
|
||||
using detz::json::Value;
|
||||
@@ -362,15 +371,306 @@ static void test_device() {
|
||||
check(ub && !ub->paired(), "unpaired device serialize/deserialize round-trip");
|
||||
}
|
||||
|
||||
// Regression (docs/whatsapp.md section 14): a server-only JID decoded to "@s.whatsapp.net", and the
|
||||
// pairing reply copied it as `to`, so the server refused pair-device-sign and login ended in 401.
|
||||
static void test_server_jid() {
|
||||
WaNode n("iq");
|
||||
n.attrs["to"] = "s.whatsapp.net";
|
||||
n.attrs["from"] = "31630045827@s.whatsapp.net";
|
||||
Bytes wire{0}; // 1-byte flags header (0 = uncompressed), as WaClient::send_node frames it
|
||||
Bytes body = WaBinary::encode(n);
|
||||
wire.insert(wire.end(), body.begin(), body.end());
|
||||
WaNode back = WaBinary::decode(WaBinary::unpack(wire));
|
||||
check(back.attr("to") == "s.whatsapp.net", "server-only JID round-trips as s.whatsapp.net");
|
||||
check(back.attr("from") == "31630045827@s.whatsapp.net", "user JID round-trips unchanged");
|
||||
}
|
||||
|
||||
// WaMedia against wa-go's golden vectors (Baileys-generated, MIT): every IV, derived key,
|
||||
// ciphertext+MAC and both hashes must match byte for byte.
|
||||
static void test_media() {
|
||||
Value d = Value::loads(slurp(path("media_vectors.json")));
|
||||
Bytes media_key = fromhex(d.get("mediaKeyHex").str());
|
||||
int ok = 0, total = 0;
|
||||
for (const auto& c : d.get("cases").items()) {
|
||||
const Value& pt = d.get("plaintexts").get(c.get("plaintext").str());
|
||||
Bytes plain = fromhex(pt.get("plaintextHex").str());
|
||||
std::string info = c.get("info").str();
|
||||
WaMedia::Keys k = WaMedia::expand(media_key, info);
|
||||
WaMedia::Encrypted e = WaMedia::encrypt(plain, info, media_key);
|
||||
bool keys_ok = tohex(k.iv) == c.get("iv").str() && tohex(k.cipher_key) == c.get("cipherKey").str() &&
|
||||
tohex(k.mac_key) == c.get("macKey").str() && tohex(k.ref_key) == c.get("refKey").str();
|
||||
bool blob_ok = tohex(e.data_to_upload) == c.get("ciphertextHex").str();
|
||||
bool hash_ok = tohex(e.file_sha256) == c.get("fileSha256").str() && tohex(e.file_enc_sha256) == c.get("fileEncSha256").str();
|
||||
bool len_ok = e.file_length == plain.size();
|
||||
++total;
|
||||
if (keys_ok && blob_ok && hash_ok && len_ok) ++ok;
|
||||
else std::printf(" mismatch: %s %s keys=%d blob=%d hash=%d len=%d\n", c.get("plaintext").str().c_str(), info.c_str(), keys_ok, blob_ok, hash_ok, len_ok);
|
||||
}
|
||||
check(total > 0 && ok == total, "media encrypt matches wa-go golden vectors (" + std::to_string(ok) + "/" + std::to_string(total) + ")");
|
||||
check(WaMedia::token(WaCrypto::sha256(Bytes{'a'})).size() == 44, "upload token is base64url with padding (44 chars for 32 bytes)");
|
||||
}
|
||||
|
||||
|
||||
// ---- app-state sync (docs/whatsapp.md section 14) ----
|
||||
// Oracle: tests/whatsapp_data/appstate_vectors.json, generated by appstate_oracle.go with whatsmeow's own
|
||||
// lthash, hkdfutil and protobuf packages. Nothing here is produced by the code under test.
|
||||
|
||||
static std::string make_temp_home() {
|
||||
std::string tmpl = "/tmp/wa_test_XXXXXX";
|
||||
char* d = ::mkdtemp(tmpl.data());
|
||||
return d ? std::string(d) : std::string();
|
||||
}
|
||||
|
||||
static void test_lthash_and_keys() {
|
||||
std::printf("[appstate: keys and LTHash]\n");
|
||||
Value v = Value::loads(slurp(path("appstate_vectors.json")));
|
||||
Bytes key_data = fromhex(v.get("key_data").str());
|
||||
WaAppStateKeys k = WaAppStateKeys::expand(key_data);
|
||||
std::string want = v.get("expanded_keys").str();
|
||||
std::string got = tohex(k.index) + tohex(k.value_encryption) + tohex(k.value_mac) + tohex(k.snapshot_mac) + tohex(k.patch_mac);
|
||||
check(got == want, "expanded app-state keys match whatsmeow expandAppStateKeys (160 bytes)");
|
||||
|
||||
// LTHash: add then remove is the identity; the order of items does not matter.
|
||||
Bytes a(32, 1), b(32, 2), c(32, 3);
|
||||
Bytes st = WaLtHash::zero();
|
||||
WaLtHash::subtract_then_add(st, {}, {a, b});
|
||||
WaLtHash::subtract_then_add(st, {a}, {c});
|
||||
Bytes st2 = WaLtHash::zero();
|
||||
WaLtHash::subtract_then_add(st2, {}, {c});
|
||||
WaLtHash::subtract_then_add(st2, {}, {b});
|
||||
check(st == st2, "LTHash: add a,b; remove a, add c equals add c; remove b (order-free, add/remove cancel)");
|
||||
Bytes st3 = WaLtHash::zero();
|
||||
WaLtHash::subtract_then_add(st3, {}, {a});
|
||||
WaLtHash::subtract_then_add(st3, {a}, {});
|
||||
check(st3 == WaLtHash::zero(), "LTHash: add then remove returns the zero state");
|
||||
}
|
||||
|
||||
// Fake collection server: serves one snapshot and the given patches, and answers the external blob download.
|
||||
struct FakeCollection {
|
||||
Bytes snapshot;
|
||||
std::vector<Bytes> patches;
|
||||
int iq_calls = 0;
|
||||
WaNode operator()(WaNode iq) {
|
||||
(void)iq;
|
||||
WaNode col("collection");
|
||||
col.attrs["name"] = WaAppStateSync::kContactsCollection;
|
||||
WaNode::Children kids;
|
||||
if (iq_calls == 0) {
|
||||
WaNode snap("snapshot");
|
||||
snap.content = Bytes{0x0a, 0x00}; // stands in for an ExternalBlobReference; the download hook returns the body
|
||||
kids.push_back(snap);
|
||||
}
|
||||
WaNode patches_node("patches");
|
||||
WaNode::Children pk;
|
||||
for (const auto& p : patches) {
|
||||
WaNode pn("patch");
|
||||
pn.content = p;
|
||||
pk.push_back(pn);
|
||||
}
|
||||
patches_node.content = pk;
|
||||
kids.push_back(patches_node);
|
||||
col.content = kids;
|
||||
++iq_calls;
|
||||
WaNode sync("sync");
|
||||
sync.content = WaNode::Children{col};
|
||||
WaNode reply("iq");
|
||||
reply.content = WaNode::Children{sync};
|
||||
return reply;
|
||||
}
|
||||
};
|
||||
|
||||
static WaAppStateSync::DownloadFn snapshot_download(const Bytes& snapshot) {
|
||||
return [snapshot](const WaExternalBlobRef&, std::string&) -> std::optional<Bytes> { return snapshot; };
|
||||
}
|
||||
|
||||
static void test_appstate_sync() {
|
||||
std::printf("[appstate: snapshot and patch sync]\n");
|
||||
Value v = Value::loads(slurp(path("appstate_vectors.json")));
|
||||
Bytes key_id = fromhex(v.get("key_id").str());
|
||||
Bytes key_data = fromhex(v.get("key_data").str());
|
||||
Bytes snapshot = fromhex(v.get("snapshot").str());
|
||||
Bytes patch = fromhex(v.get("patch").str());
|
||||
Bytes tampered = fromhex(v.get("patch_tampered").str());
|
||||
std::string name = WaAppStateSync::kContactsCollection;
|
||||
std::vector<std::string> logs;
|
||||
auto log = [&](const std::string& s) { logs.push_back(s); };
|
||||
|
||||
// 1. good chain: snapshot then one patch
|
||||
{
|
||||
std::string home = make_temp_home();
|
||||
WaStore store(home, "sync");
|
||||
store.put_app_key(key_id, key_data, 1767225600123);
|
||||
FakeCollection col;
|
||||
col.patches = {patch};
|
||||
WaAppStateSync sync(store, log);
|
||||
auto rep = sync.sync(name, col, snapshot_download(snapshot));
|
||||
check(rep.error.empty() && rep.rejected == 0, "good snapshot+patch chain verifies (snapshot MAC, patch MAC, value and index MACs)");
|
||||
check(rep.patches_applied == 1 && rep.mutations_applied == 10, "applied 1 patch with 10 mutations (4 snapshot + 6 patch)");
|
||||
check(rep.contacts_saved == 6 && rep.contacts_removed == 1 && rep.contacts_unnamed == 1,
|
||||
"contacts: 6 saved (3 snapshot, 3 patch), 1 removed, 1 unnamed skipped");
|
||||
auto contacts = store.contacts();
|
||||
std::map<std::string, std::string> got(contacts.begin(), contacts.end());
|
||||
std::map<std::string, std::string> want = {
|
||||
{"31611111111@s.whatsapp.net", "Nigel Dokter (work)"},
|
||||
{"31622222222@s.whatsapp.net", "Ann"},
|
||||
{"31644444444@s.whatsapp.net", "Joop Visser"},
|
||||
};
|
||||
check(got == want, "wa_contact holds exactly the named contacts after snapshot + patch (C removed, unnamed F and the pushName setting not stored)");
|
||||
auto ver = store.app_state_version(name);
|
||||
check(ver && ver->version == 2 && tohex(ver->hash) == v.get("patch_hash_hex").str(),
|
||||
"stored version 2 and LTHash state equal whatsmeow's state after the patch");
|
||||
// a second sync from the stored version is a no-op on the server side
|
||||
FakeCollection none;
|
||||
none.iq_calls = 1; // the server sends no snapshot once the client is at version 2
|
||||
auto rep2 = sync.sync(name, none, snapshot_download(snapshot));
|
||||
check(rep2.error.empty() && rep2.patches_applied == 0 && rep2.version == 2, "a re-sync from version 2 with no new patches changes nothing");
|
||||
std::filesystem::remove_all(home);
|
||||
}
|
||||
|
||||
// 2. tampered patch: MAC mismatch, the patch is not stored, the snapshot before it is
|
||||
{
|
||||
std::string home = make_temp_home();
|
||||
WaStore store(home, "tamper");
|
||||
store.put_app_key(key_id, key_data, 1767225600123);
|
||||
FakeCollection col;
|
||||
col.patches = {tampered};
|
||||
WaAppStateSync sync(store, log);
|
||||
auto rep = sync.sync(name, col, snapshot_download(snapshot));
|
||||
check(rep.rejected == 1 && rep.patches_applied == 0, "tampered patch is rejected (value MAC) and not applied");
|
||||
auto ver = store.app_state_version(name);
|
||||
check(ver && ver->version == 1, "after a rejected patch the stored version stays at the last verified state (1)");
|
||||
std::filesystem::remove_all(home);
|
||||
}
|
||||
|
||||
// 3. missing key: nothing is stored, the key id is reported for a key request
|
||||
{
|
||||
std::string home = make_temp_home();
|
||||
WaStore store(home, "nokey");
|
||||
FakeCollection col;
|
||||
WaAppStateSync sync(store, log);
|
||||
auto rep = sync.sync(name, col, snapshot_download(snapshot));
|
||||
check(rep.missing_key_ids.size() == 1 && rep.missing_key_ids[0] == key_id && rep.contacts_saved == 0,
|
||||
"missing app-state key: key id reported, no data stored");
|
||||
check(!store.app_state_version(name).has_value(), "missing key leaves the collection version unset");
|
||||
std::filesystem::remove_all(home);
|
||||
}
|
||||
|
||||
// 4. key share message parses to the oracle key; key request encodes a parseable Message
|
||||
{
|
||||
Bytes ks = fromhex(v.get("key_share_message").str());
|
||||
WaProtocolMessage pm = parse_protocol_message(ks);
|
||||
check(pm.type == 6 && pm.key_share.size() == 1 && pm.key_share[0].key_id == key_id && pm.key_share[0].key_data == key_data &&
|
||||
pm.key_share[0].timestamp == 1767225600123,
|
||||
"APP_STATE_SYNC_KEY_SHARE parses: key id, 32-byte key data, timestamp (whatsmeow proto)");
|
||||
Bytes kr = encode_app_state_key_request({key_id});
|
||||
WaProtocolMessage req = parse_protocol_message(kr);
|
||||
check(req.type == 7 && req.key_share.empty() && tohex(kr) == v.get("key_request_message").str(),
|
||||
"APP_STATE_SYNC_KEY_REQUEST encodes byte-exact to whatsmeow's Message");
|
||||
}
|
||||
}
|
||||
|
||||
static void test_receiver() {
|
||||
std::printf("[appstate: key share receipt]\n");
|
||||
// Alice (the phone) starts a session with our device with X3DH and sends the key share as pkmsg.
|
||||
WaDevice bob = WaDevice::generate();
|
||||
WaCrypto::KeyPair alice_id = WaCrypto::generate_x25519();
|
||||
WaCrypto::KeyPair alice_base = WaCrypto::generate_x25519();
|
||||
WaCrypto::KeyPair one_time = WaCrypto::generate_x25519();
|
||||
std::string home = make_temp_home();
|
||||
WaStore store(home, "recv");
|
||||
store.put_prekey(77, Bytes(one_time.priv.begin(), one_time.priv.end()));
|
||||
|
||||
WaSession::InitiatorParams p;
|
||||
p.local_identity = alice_id;
|
||||
p.local_base = alice_base;
|
||||
p.remote_identity = signal_pub(bob.identity_key.pub);
|
||||
p.remote_signed_pre = signal_pub(bob.signed_pre_key.pair.pub);
|
||||
p.remote_pre_key = signal_pub(one_time.pub);
|
||||
p.has_pre_key = true;
|
||||
WaSession s = WaSession::initiate(p, WaCrypto::generate_x25519());
|
||||
s.set_pending(1234, signal_pub(alice_base.pub), bob.signed_pre_key.key_id, true, 77);
|
||||
Bytes plain = fromhex(Value::loads(slurp(path("appstate_vectors.json"))).get("key_share_message").str());
|
||||
CiphertextMessage ct = s.encrypt(WaCrypto::pkcs7_pad(plain));
|
||||
WaNode enc("enc");
|
||||
enc.attrs["v"] = "2";
|
||||
enc.attrs["type"] = ct.type;
|
||||
enc.content = ct.serialized;
|
||||
|
||||
WaReceiver rx(store);
|
||||
auto res = rx.decrypt(enc, "15550001111@s.whatsapp.net", bob);
|
||||
check(ct.type == "pkmsg" && res.ok && res.message == plain, "pkmsg from the phone decrypts to the key-share Message (X3DH responder + unpad)");
|
||||
check(store.load_session("15550001111.0").has_value(), "the new session is stored under the phone's signal address");
|
||||
Bytes bad = ct.serialized;
|
||||
bad.back() ^= 0x01;
|
||||
WaNode enc_bad("enc");
|
||||
enc_bad.attrs["type"] = ct.type;
|
||||
enc_bad.content = bad;
|
||||
auto corrupt = rx.decrypt(enc_bad, "15550001111@s.whatsapp.net", bob);
|
||||
check(!corrupt.ok, "a corrupted pkmsg is refused with a reason, not decrypted");
|
||||
std::filesystem::remove_all(home);
|
||||
}
|
||||
|
||||
// The phone only pushes history and app-state keys to a companion that declares historySyncConfig
|
||||
// (DeviceProps field 5), so registration must carry it.
|
||||
static void test_device_props() {
|
||||
WaDeviceProps dp;
|
||||
Bytes with = dp.encode();
|
||||
dp.history_sync_config = false;
|
||||
Bytes without = dp.encode();
|
||||
check(with.size() > without.size() + 20, "DeviceProps carries historySyncConfig by default");
|
||||
ProtoReader r(with);
|
||||
ProtoReader::Field f;
|
||||
bool found = false;
|
||||
while (r.next(f)) found = found || (f.field == 5 && f.wire == 2);
|
||||
check(found, "DeviceProps field 5 (historySyncConfig) is length-delimited");
|
||||
}
|
||||
|
||||
// Contact search: the user's phone has "Nigel Dokter"; the user types "Nigel", "Niggel", "dokter" or the number.
|
||||
static void test_contact_search() {
|
||||
std::string home = "build/probe/wa_contact_test_home";
|
||||
std::filesystem::remove_all(home);
|
||||
WaStore store(home, "t");
|
||||
store.put_contact("31612345001@s.whatsapp.net", "Nigel Dokter");
|
||||
store.put_contact("31612345002@s.whatsapp.net", "Nina Smit");
|
||||
store.put_contact("31612345003@s.whatsapp.net", "Peter de Vries");
|
||||
auto top = [&](const std::string& q) {
|
||||
auto h = store.search_contacts(q, 3, 0.3);
|
||||
return h.empty() ? std::string("(none)") : h[0].name;
|
||||
};
|
||||
check(top("Nigel") == "Nigel Dokter", "contact search: first name");
|
||||
check(top("Niggel") == "Nigel Dokter", "contact search: typo (Niggel -> Nigel Dokter)");
|
||||
check(top("nigel dokter") == "Nigel Dokter", "contact search: full name, any case");
|
||||
check(top("dokter") == "Nigel Dokter", "contact search: surname only");
|
||||
check(top("12345003") == "Peter de Vries", "contact search: number fragment");
|
||||
check(store.search_contacts("zzzzqq", 3, 0.3).empty(), "contact search: nonsense matches nothing");
|
||||
store.remove_contact("31612345001@s.whatsapp.net");
|
||||
check(top("Nigel") != "Nigel Dokter", "contact search: removed contact is gone from the index");
|
||||
WaStore reopened(home, "t");
|
||||
check(reopened.search_contacts("Nina", 3, 0.3).size() >= 1, "contact search: index survives reopening the database");
|
||||
// send log: the floor under the rate caps
|
||||
check(store.sends_since(60) == 0, "send log starts empty");
|
||||
store.record_send("31612345002@s.whatsapp.net");
|
||||
store.record_send("31612345002@s.whatsapp.net");
|
||||
check(store.sends_since(60) == 2 && store.sends_since(86400) == 2, "send log counts sends in the window");
|
||||
std::filesystem::remove_all(home);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
DIR = argc > 1 ? argv[1] : "tests/whatsapp_data";
|
||||
test_contact_search();
|
||||
test_device_props();
|
||||
test_crypto();
|
||||
test_codec();
|
||||
test_media();
|
||||
test_server_jid();
|
||||
test_client_payload();
|
||||
test_noise();
|
||||
test_signal();
|
||||
test_device();
|
||||
test_hostile();
|
||||
test_lthash_and_keys();
|
||||
test_appstate_sync();
|
||||
test_receiver();
|
||||
std::printf("%s\n", g_fail == 0 ? "whatsapp: ALL OK" : "whatsapp: FAILURES");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user