diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index c465a5da..4040c925 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -33,6 +33,23 @@ jobs: version = Path('VERSION').read_text(encoding='utf-8').strip() if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version): raise SystemExit(f'Invalid VERSION: {version!r}') + if version == '9.9.0': + runtime = Path('src/postmaster/whatsapp_runtime_v990.py').read_text(encoding='utf-8') + required_acceptance = ( + '"protocol_interop_verified": True', + '"signal_multidevice_verified": True', + '"controlled_account_interop_verified": True', + ) + missing_acceptance = [ + item for item in required_acceptance if item not in runtime + ] + if missing_acceptance: + raise SystemExit( + 'Refusing stable v9.9.0 release without reviewed controlled-account ' + f'WhatsApp acceptance flags: {missing_acceptance}' + ) + print('WhatsApp v9.9.0 controlled-account acceptance flags verified.') + changelog = Path('CHANGELOG.md').read_text(encoding='utf-8') match = re.search( rf'^## {re.escape(version)} - .*?\n(?P
.*?)(?=^## |\Z)', diff --git a/.github/workflows/whatsapp-v990-controlled-interop.yml b/.github/workflows/whatsapp-v990-controlled-interop.yml new file mode 100644 index 00000000..e2f96850 --- /dev/null +++ b/.github/workflows/whatsapp-v990-controlled-interop.yml @@ -0,0 +1,113 @@ +name: WhatsApp v9.9 controlled account interop + +on: + workflow_dispatch: + inputs: + require_inbound_echo: + description: Require controlled direct peer to echo the direct probe token + required: true + default: true + type: boolean + require_group_echo: + description: Require controlled peer to echo the group probe token + required: true + default: true + type: boolean + require_inbound_media: + description: Require controlled peer media in the test group and Stored File handoff + required: true + default: true + type: boolean + require_remote_receipt: + description: Require a remote delivery/read receipt for the direct probe text + required: true + default: true + type: boolean + run_media: + description: Exercise direct/group encrypted media upload and send + required: true + default: true + type: boolean + observe_timeout_seconds: + description: Seconds per controlled peer observation window (10-300) + required: true + default: '120' + type: string + +permissions: + contents: read + +concurrency: + group: whatsapp-v990-controlled-interop + cancel-in-progress: false + +jobs: + controlled-account: + name: WhatsApp controlled-account full interoperability + runs-on: ubuntu-latest + timeout-minutes: 14 + environment: whatsapp-v990-controlled + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install repository runtime dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Validate controlled-session secrets are configured + env: + AUTH_DB: ${{ secrets.POSTMASTER_WA_AUTH_DB_B64 }} + AUTH_KEY: ${{ secrets.POSTMASTER_WA_AUTH_KEY_B64 }} + TEST_JID: ${{ secrets.POSTMASTER_WA_TEST_JID }} + TEST_GROUP_JID: ${{ secrets.POSTMASTER_WA_TEST_GROUP_JID }} + run: | + test -n "$AUTH_DB" || { echo "POSTMASTER_WA_AUTH_DB_B64 is not configured in the whatsapp-v990-controlled environment"; exit 2; } + test -n "$AUTH_KEY" || { echo "POSTMASTER_WA_AUTH_KEY_B64 is not configured in the whatsapp-v990-controlled environment"; exit 2; } + test -n "$TEST_JID" || { echo "POSTMASTER_WA_TEST_JID is not configured in the whatsapp-v990-controlled environment"; exit 2; } + test -n "$TEST_GROUP_JID" || { echo "POSTMASTER_WA_TEST_GROUP_JID is not configured in the whatsapp-v990-controlled environment"; exit 2; } + echo "Controlled-session secrets are configured (values not printed)." + + - name: Compile controlled interop harness + run: | + python -m compileall -q src/postmaster/whatsapp_v990 scripts/whatsapp_v990_controlled_interop.py + + - name: Run controlled direct/group text/media/receipt/inbound interoperability + env: + PYTHONPATH: src + POSTMASTER_WA_AUTH_DB_B64: ${{ secrets.POSTMASTER_WA_AUTH_DB_B64 }} + POSTMASTER_WA_AUTH_KEY_B64: ${{ secrets.POSTMASTER_WA_AUTH_KEY_B64 }} + POSTMASTER_WA_TEST_JID: ${{ secrets.POSTMASTER_WA_TEST_JID }} + POSTMASTER_WA_TEST_GROUP_JID: ${{ secrets.POSTMASTER_WA_TEST_GROUP_JID }} + POSTMASTER_WA_REQUIRE_INBOUND_ECHO: ${{ inputs.require_inbound_echo }} + POSTMASTER_WA_REQUIRE_GROUP_ECHO: ${{ inputs.require_group_echo }} + POSTMASTER_WA_REQUIRE_INBOUND_MEDIA: ${{ inputs.require_inbound_media }} + POSTMASTER_WA_REQUIRE_REMOTE_RECEIPT: ${{ inputs.require_remote_receipt }} + POSTMASTER_WA_RUN_MEDIA: ${{ inputs.run_media }} + POSTMASTER_WA_OBSERVE_TIMEOUT_SECONDS: ${{ inputs.observe_timeout_seconds }} + POSTMASTER_WA_EVIDENCE_PATH: whatsapp-v990-controlled-evidence.json + run: python scripts/whatsapp_v990_controlled_interop.py + + - name: Upload non-secret interoperability evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: whatsapp-v990-controlled-evidence + path: whatsapp-v990-controlled-evidence.json + if-no-files-found: ignore + retention-days: 1 + + - name: State acceptance boundary + if: always() + run: | + echo "This workflow never changes source acceptance flags automatically." + echo "A release-qualifying run must leave every full-profile input enabled and must end with" + echo "controlled_account_interop_observed=true in the non-secret evidence artifact." + echo "A successful run must be reviewed before protocol_interop_verified, signal_multidevice_verified," + echo "or controlled_account_interop_verified can change in source." + echo "No QR refs, auth database bytes, auth key, JIDs, private keys, session secrets or media payloads" + echo "are written to the evidence artifact." diff --git a/.github/workflows/whatsapp-v990-offline-validation.yml b/.github/workflows/whatsapp-v990-offline-validation.yml new file mode 100644 index 00000000..811eda69 --- /dev/null +++ b/.github/workflows/whatsapp-v990-offline-validation.yml @@ -0,0 +1,219 @@ +name: WhatsApp v9.9 offline validation + +on: + pull_request: + branches: + - main + paths: + - 'src/postmaster/whatsapp_v990/**' + - 'src/postmaster/whatsapp_runtime_v990.py' + - 'src/postmaster/webgui_whatsapp_v990.py' + - 'tests/test_whatsapp_*_v990.py' + - 'scripts/whatsapp_v990_network_smoke.py' + - 'scripts/whatsapp_v990_noise_server_hello_probe.py' + - 'scripts/whatsapp_v990_noise_registration_probe.py' + - 'scripts/whatsapp_v990_pair_device_probe.py' + - 'scripts/whatsapp_v990_controlled_interop.py' + - '.github/workflows/whatsapp-v990-offline-validation.yml' + - '.github/workflows/whatsapp-v990-controlled-interop.yml' + - '.github/workflows/stable-release.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: whatsapp-v990-offline-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + offline-protocol: + name: WhatsApp v9.9 offline protocol/runtime + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install repository runtime dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Compile WhatsApp candidate + run: | + python -m compileall -q \ + src/postmaster/whatsapp_v990 \ + src/postmaster/whatsapp_runtime_v990.py \ + src/postmaster/webgui_whatsapp_v990.py \ + scripts/whatsapp_v990_controlled_interop.py \ + tests + + - name: Run WhatsApp offline regression suite + env: + PYTHONPATH: src + run: | + python -W error::ResourceWarning -m unittest discover -s tests -p 'test_whatsapp_*_v990.py' -v + + - name: Guard controlled-account release boundary + run: | + python - <<'PY' + from pathlib import Path + + runtime = Path('src/postmaster/whatsapp_runtime_v990.py').read_text(encoding='utf-8') + harness = Path('scripts/whatsapp_v990_controlled_interop.py').read_text(encoding='utf-8') + workflow = Path('.github/workflows/whatsapp-v990-controlled-interop.yml').read_text(encoding='utf-8') + stable_release = Path('.github/workflows/stable-release.yml').read_text(encoding='utf-8') + + expected_false = ( + '"protocol_interop_verified": False', + '"signal_multidevice_verified": False', + '"controlled_account_interop_verified": False', + ) + missing = [item for item in expected_false if item not in runtime] + assert not missing, ( + 'Live acceptance flags must stay false until controlled-account evidence exists: ' + f'{missing}' + ) + + required_harness_checks = ( + 'POSTMASTER_WA_TEST_GROUP_JID', + 'group_sender_key_interop_observed', + 'media_interop_observed', + 'receipt_policy_observed', + 'inbound_group_media_stored_file_observed', + 'controlled_account_interop_observed', + ) + missing = [item for item in required_harness_checks if item not in harness] + assert not missing, f'Controlled interop harness lost release-critical checks: {missing}' + + required_workflow_checks = ( + 'environment: whatsapp-v990-controlled', + 'POSTMASTER_WA_TEST_GROUP_JID', + 'require_group_echo', + 'require_inbound_media', + 'controlled_account_interop_observed=true', + ) + missing = [item for item in required_workflow_checks if item not in workflow] + assert not missing, f'Controlled interop workflow lost release-critical gates: {missing}' + + release_interlock = ( + "version == '9.9.0'", + '"protocol_interop_verified": True', + '"signal_multidevice_verified": True', + '"controlled_account_interop_verified": True', + 'Refusing stable v9.9.0 release', + ) + missing = [item for item in release_interlock if item not in stable_release] + assert not missing, f'Stable-release WhatsApp interlock is incomplete: {missing}' + + print( + 'Offline validation passed; implementation can advance without an account, ' + 'but stable acceptance remains controlled-account gated.' + ) + PY + + network-transport-smoke: + name: WhatsApp WebSocket unauthenticated transport smoke + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Probe current WhatsApp WebSocket endpoint + run: python scripts/whatsapp_v990_network_smoke.py + + - name: State acceptance boundary + if: always() + run: | + echo "This job validates only DNS/TCP/TLS/RFC6455 reachability." + echo "It does not validate QR pairing, Noise authentication, Signal multi-device, account interoperability, messages, media, groups, or receipts." + + live-noise-server-hello: + name: WhatsApp live Noise ServerHello probe + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install repository crypto runtime and CI-only WebSocket driver + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install 'websockets>=15,<16' + + - name: Decrypt live Noise ServerHello and verify certificate chain + env: + PYTHONPATH: src + run: python scripts/whatsapp_v990_noise_server_hello_probe.py + + - name: State acceptance boundary + if: always() + run: | + echo "This job stops before ClientFinish/ClientPayload." + echo "No QR/account/device credentials or authenticated WhatsApp actions are exercised." + echo "Passing does not set protocol_interop_verified, signal_multidevice_verified, or controlled_account_interop_verified." + + live-registration-pair-device: + name: WhatsApp live ClientFinish and pair-device probe + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install repository runtime dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Complete throw-away registration and receive encrypted pair-device + env: + PYTHONPATH: src + run: python scripts/whatsapp_v990_pair_device_probe.py + + - name: State acceptance boundary + if: always() + run: | + echo "This job validates ClientFinish, current ClientPayload, encrypted transport and WABinary pair-device refs." + echo "QR refs are never printed or persisted and no account is paired." + echo "Passing still does not set signal_multidevice_verified or controlled_account_interop_verified." + + + live-noise-client-finish: + name: WhatsApp live Noise ClientFinish registration probe + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install repository crypto runtime and CI-only WebSocket driver + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install 'websockets>=15,<16' + + - name: Send live ClientFinish and authenticate first transport response + env: + PYTHONPATH: src + run: python scripts/whatsapp_v990_noise_registration_probe.py + + - name: State acceptance boundary + if: always() + run: | + echo "This job uses fresh one-run Noise/Signal registration material and stops before QR scan/account pairing." + echo "Passing validates ClientFinish/registration transport compatibility only." + echo "It does not set protocol_interop_verified, signal_multidevice_verified, or controlled_account_interop_verified." diff --git a/docs/whatsapp-v990-release-gate.md b/docs/whatsapp-v990-release-gate.md new file mode 100644 index 00000000..9c84d195 --- /dev/null +++ b/docs/whatsapp-v990-release-gate.md @@ -0,0 +1,109 @@ +# WhatsApp v9.9 release gate + +## Current state + +The clean-room WhatsApp v9.9 implementation is code-complete for the planned Postmaster surface: + +- explicit QR pairing and encrypted auth persistence; +- authenticated reconnect over the current WhatsApp Web companion transport; +- direct multi-device text send/receive; +- encrypted direct media upload/send and inbound media download; +- canonical Postmaster Stored File handoff for media; +- participating group listing; +- Signal SenderKey v3 group text send/receive; +- group media send and inbound group media Stored File handoff; +- remote receipt observation; +- asymmetric read-receipt policy: local reads do not emit receipts, explicit reply actions may; +- local WebGUI pairing QR rendering without remote QR services. + +Automatic CI also performs live unauthenticated/current-protocol probes through ClientFinish and +encrypted pair-device traffic without pairing an account. + +Implementation completeness is **not** the same as controlled-account interoperability acceptance. +The stable v9.9 release remains gated until the manual controlled-account workflow passes. + +## No personal WhatsApp account is required + +Do not use a maintainer's personal WhatsApp account for release acceptance. + +Use dedicated test identities. The full gate is designed for: + +1. one dedicated WhatsApp account that Postmaster pairs as the companion under test; +2. one separate controlled peer WhatsApp account; +3. one test group containing both controlled accounts. + +The peer can be operated manually or by a separate controlled test device. The release gate needs a +distinct peer because it verifies remote multi-device fan-out, remote receipts and inbound direct/group +decryption rather than only self-device synchronization. + +WhatsApp Web/linked-device operation requires an active WhatsApp account on a primary phone. There is +no repository fallback that bypasses this requirement, and the release process must not substitute +unofficial number-verification or disposable-account services. + +## GitHub Environment + +Create a protected GitHub Actions environment named: + +`whatsapp-v990-controlled` + +Configure these environment secrets: + +- `POSTMASTER_WA_AUTH_DB_B64`: Base64 of a previously paired controlled auth SQLite database; +- `POSTMASTER_WA_AUTH_KEY_B64`: Base64 of the corresponding 32-byte auth-store key; +- `POSTMASTER_WA_TEST_JID`: the separate controlled peer JID; +- `POSTMASTER_WA_TEST_GROUP_JID`: a controlled `g.us` group containing the test identities. + +The workflow never prints those values. Its evidence artifact contains only counts and booleans, never +JIDs, QR refs, auth bytes, private keys, session material or media payloads. + +## Release-qualifying workflow + +Run: + +`.github/workflows/whatsapp-v990-controlled-interop.yml` + +For a release-qualifying run, leave all full-profile inputs enabled: + +- direct inbound echo required; +- group inbound echo required; +- inbound group media required; +- remote receipt required; +- media send enabled. + +The controlled peer follows the in-band probe messages. In particular, it echoes the direct/group +tokens and sends one small media attachment to the controlled group with the requested token in its +caption. + +The run is release-qualifying only when the non-secret evidence ends with all of these true: + +- `protocol_interop_observed`; +- `signal_multidevice_observed`; +- `group_sender_key_interop_observed`; +- `media_interop_observed`; +- `receipt_policy_observed`; +- `controlled_account_interop_observed`. + +A diagnostic run with any full-profile requirement disabled is useful for debugging but must not +qualify a stable release. + +## Promotion sequence + +Before the controlled-account gate passes: + +- keep `protocol_interop_verified = false`; +- keep `signal_multidevice_verified = false`; +- keep `controlled_account_interop_verified = false`; +- keep the project VERSION on the current stable release; +- do not create the v9.9.0 stable tag/release. + +After a reviewed full-profile pass: + +1. set the three acceptance flags to true; +2. run the complete repository regression suite again; +3. update VERSION/CHANGELOG/release metadata to v9.9.0; +4. verify the public single-YAML bootstrap and dependency install from a clean environment; +5. merge the release branch; +6. create the v9.9.0 tag and stable GitHub release. + +This separation allows development and CI hardening to reach release-candidate quality without +requiring any maintainer's personal WhatsApp identity. diff --git a/requirements.txt b/requirements.txt index d89d4e25..e25bc490 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ huggingface-hub>=0.24,<2 tokenizers>=0.20,<1 mistune>=3,<4 bleach>=6.2,<7 +qrcode==8.2 diff --git a/scripts/whatsapp_v990_controlled_interop.py b/scripts/whatsapp_v990_controlled_interop.py new file mode 100644 index 00000000..5e0a30f4 --- /dev/null +++ b/scripts/whatsapp_v990_controlled_interop.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +"""Controlled-account WhatsApp v9.9 interoperability harness for GitHub Actions. + +Secrets are supplied only through the workflow environment. The script never prints or writes +QR refs, JIDs, private keys, auth database bytes, auth key material, downloaded media bytes, or +stored-file payloads to the evidence artifact. + +The full acceptance profile restores a previously paired controlled test account and exercises: +- authenticated reconnect and pre-key maintenance, +- participating-group listing, +- direct multi-device text and media send, +- group SenderKey text and media send, +- remote receipt observation, +- inbound direct text decrypt, +- inbound group SenderKey text decrypt, +- inbound group media decrypt/download and canonical Stored File handoff. + +The test peer is instructed in-band using random one-run tokens. This harness never changes source +acceptance flags automatically; a successful run is evidence for a human-reviewed release gate. +""" + +import asyncio +import base64 +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import secrets +import stat +import tempfile +import time +from typing import Any + +from postmaster.whatsapp_v990.adapter import CurrentProtocolAdapter +from postmaster.whatsapp_v990.jid import parse_jid +from postmaster.whatsapp_v990.service import WhatsAppEventStore +from postmaster.whatsapp_v990.store import EncryptedAuthStore + + +class HarnessError(RuntimeError): + pass + + +@dataclass +class Evidence: + reconnect_ok: bool = False + prekey_server_count: int | None = None + + group_listing_ok: bool = False + group_count: int | None = None + target_group_visible: bool = False + + direct_text_send_ok: bool = False + direct_text_server_ack: bool = False + direct_text_device_fanout: int | None = None + direct_text_remote_device_targets: int | None = None + direct_text_own_device_targets: int | None = None + direct_text_used_prekey_message: bool | None = None + direct_text_read_receipt_emitted: bool = False + + direct_media_send_ok: bool = False + direct_media_server_ack: bool = False + direct_media_device_fanout: int | None = None + direct_media_read_receipt_emitted: bool = False + + group_text_send_ok: bool = False + group_text_server_ack: bool = False + group_text_device_fanout: int | None = None + group_text_sender_key_recipients: int | None = None + group_text_read_receipt_emitted: bool = False + + group_media_send_ok: bool = False + group_media_server_ack: bool = False + group_media_device_fanout: int | None = None + group_media_sender_key_recipients: int | None = None + group_media_read_receipt_emitted: bool = False + + remote_receipt_observed: bool = False + inbound_direct_echo_observed: bool = False + inbound_group_echo_observed: bool = False + inbound_group_media_observed: bool = False + inbound_group_media_stored_file_observed: bool = False + inbound_stored_file_count: int = 0 + + protocol_interop_observed: bool = False + signal_multidevice_observed: bool = False + group_sender_key_interop_observed: bool = False + media_interop_observed: bool = False + receipt_policy_observed: bool = False + controlled_account_interop_observed: bool = False + + +class HarnessFileStore: + """In-memory Stored File boundary for a one-run controlled probe. + + Outbound fixture bytes and inbound downloaded bytes stay in process memory only. Evidence + contains counts/booleans, never media bytes, filenames supplied by the peer, or file IDs. + """ + + def __init__(self) -> None: + self._files: dict[str, tuple[dict[str, Any], bytes]] = { + "controlled-interop-document": ( + {"id": "controlled-interop-document", "filename": "postmaster-v990-controlled.txt", "media_type": "text/plain"}, + b"Postmaster MCP v9.9 controlled WhatsApp media interoperability probe.\n", + ) + } + self.inbound_saved = 0 + + def raw_bytes(self, file_id: str): + if file_id not in self._files: + raise KeyError(file_id) + info, data = self._files[file_id] + return dict(info), bytes(data) + + def save_bytes( + self, + *, + owner_id: str, + filename: str, + data: bytes, + project_id: str | None = None, + media_type: str | None = None, + description: str = "", + tags: list[str] | None = None, + file_id: str | None = None, + ) -> dict[str, Any]: + fid = file_id or ("controlled-inbound-" + secrets.token_hex(8)) + info = { + "id": fid, + "owner_id": str(owner_id), + "project_id": str(project_id) if project_id else None, + "filename": str(filename), + "media_type": str(media_type or "application/octet-stream"), + "size_bytes": len(bytes(data)), + "description": str(description or ""), + "tags": list(tags or []), + } + self._files[fid] = (dict(info), bytes(data)) + self.inbound_saved += 1 + return dict(info) + + +def _required(name: str) -> str: + value = str(os.environ.get(name) or "").strip() + if not value: + raise HarnessError(f"Required controlled-interop environment value is missing: {name}") + return value + + +def _bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _decode_secret(name: str) -> bytes: + value = _required(name) + try: + raw = base64.b64decode(value, validate=True) + except Exception as exc: + raise HarnessError(f"{name} is not valid Base64") from exc + if not raw: + raise HarnessError(f"{name} decoded to empty bytes") + return raw + + +def _write_secret(path: Path, data: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + except Exception: + try: + path.unlink(missing_ok=True) + except OSError: + pass + raise + if stat.S_IMODE(path.stat().st_mode) & 0o077: + raise HarnessError("Controlled auth secret file permissions are broader than 0600") + + +async def _wait_for_receipt(events: WhatsAppEventStore, message_id: str, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for row in events.list_receipts(limit=500): + if ( + str(row.get("message_id") or "") == message_id + and str(row.get("source") or "") == "remote" + ): + return True + await asyncio.sleep(1.0) + return False + + +async def _wait_for_message( + events: WhatsAppEventStore, + *, + jid: str, + token: str, + kind: str, + timeout: float, +) -> dict[str, Any] | None: + wanted_jid = str(parse_jid(jid)) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for row in events.list_messages(jid=wanted_jid, limit=500): + if str(row.get("direction") or "") != "in": + continue + if str(row.get("kind") or "") != kind: + continue + if token not in str(row.get("text") or ""): + continue + return row + await asyncio.sleep(1.0) + return None + + +def _int_or_none(value: Any) -> int | None: + return int(value) if value is not None else None + + +def _assert_no_local_read_receipt(label: str, result: dict[str, Any]) -> None: + if bool(result.get("read_receipt_emitted")): + raise HarnessError(f"{label} unexpectedly emitted a local WhatsApp read receipt") + + +async def main() -> int: + auth_db_bytes = _decode_secret("POSTMASTER_WA_AUTH_DB_B64") + auth_key_bytes = _decode_secret("POSTMASTER_WA_AUTH_KEY_B64") + if len(auth_key_bytes) != 32: + raise HarnessError("POSTMASTER_WA_AUTH_KEY_B64 must decode to exactly 32 bytes") + + test_jid = str(parse_jid(_required("POSTMASTER_WA_TEST_JID")).normalized_user()) + test_group_jid = str(parse_jid(_required("POSTMASTER_WA_TEST_GROUP_JID"))) + if not parse_jid(test_group_jid).is_group: + raise HarnessError("POSTMASTER_WA_TEST_GROUP_JID must be a g.us group JID") + + require_receipt = _bool("POSTMASTER_WA_REQUIRE_REMOTE_RECEIPT", True) + require_direct_echo = _bool("POSTMASTER_WA_REQUIRE_INBOUND_ECHO", True) + require_group_echo = _bool("POSTMASTER_WA_REQUIRE_GROUP_ECHO", True) + require_inbound_media = _bool("POSTMASTER_WA_REQUIRE_INBOUND_MEDIA", True) + run_media = _bool("POSTMASTER_WA_RUN_MEDIA", True) + timeout = float(os.environ.get("POSTMASTER_WA_OBSERVE_TIMEOUT_SECONDS") or "120") + timeout = min(max(timeout, 10.0), 300.0) + evidence_path = Path( + os.environ.get("POSTMASTER_WA_EVIDENCE_PATH") + or "whatsapp-v990-controlled-evidence.json" + ) + + evidence = Evidence() + with tempfile.TemporaryDirectory(prefix="postmaster-wa-controlled-") as td: + root = Path(td) + auth_db = root / "auth.db" + auth_key = root / "auth.key" + event_db = root / "events.db" + _write_secret(auth_db, auth_db_bytes) + _write_secret(auth_key, auth_key_bytes) + + auth = EncryptedAuthStore(str(auth_db), key_path=str(auth_key)) + events = WhatsAppEventStore(str(event_db)) + files = HarnessFileStore() + adapter = CurrentProtocolAdapter( + auth, + on_message=lambda **kwargs: events.record_message(**kwargs), + on_receipt=lambda **kwargs: events.record_receipt(**kwargs), + file_store=files, + file_owner_id="controlled-interop", + ) + + try: + connected = dict(await adapter.reconnect()) + evidence.reconnect_ok = bool(connected.get("connected")) + evidence.prekey_server_count = _int_or_none(connected.get("server_pre_key_count")) + if not evidence.reconnect_ok: + raise HarnessError("Controlled WhatsApp session did not reconnect") + + groups = [dict(item) for item in await adapter.list_groups()] + evidence.group_listing_ok = True + evidence.group_count = len(groups) + evidence.target_group_visible = any( + str(parse_jid(str(item.get("id") or ""))) == test_group_jid + for item in groups + if item.get("id") + ) + if not evidence.target_group_visible: + raise HarnessError("Controlled test group is not visible to the paired companion") + + direct_token = "postmaster-direct-" + secrets.token_hex(12) + direct = dict( + await adapter.send_text( + jid=test_jid, + text=f"ECHO_DIRECT:{direct_token}", + ) + ) + evidence.direct_text_send_ok = bool(direct.get("ok")) + evidence.direct_text_server_ack = bool(direct.get("server_ack")) + evidence.direct_text_device_fanout = _int_or_none(direct.get("device_fanout")) + evidence.direct_text_remote_device_targets = _int_or_none( + direct.get("remote_device_targets") + ) + evidence.direct_text_own_device_targets = _int_or_none( + direct.get("own_device_targets") + ) + evidence.direct_text_used_prekey_message = bool(direct.get("used_prekey_message")) + evidence.direct_text_read_receipt_emitted = bool( + direct.get("read_receipt_emitted") + ) + _assert_no_local_read_receipt("Controlled direct text send", direct) + direct_message_id = str(direct.get("message_id") or "") + if ( + not evidence.direct_text_send_ok + or not evidence.direct_text_server_ack + or not direct_message_id + ): + raise HarnessError( + "Controlled direct-text send did not receive a clean server ack" + ) + + if run_media: + direct_media = dict( + await adapter.send_media( + jid=test_jid, + stored_file_id="controlled-interop-document", + caption="Postmaster v9.9 controlled direct media probe", + ) + ) + evidence.direct_media_send_ok = bool(direct_media.get("ok")) + evidence.direct_media_server_ack = bool(direct_media.get("server_ack")) + evidence.direct_media_device_fanout = _int_or_none( + direct_media.get("device_fanout") + ) + evidence.direct_media_read_receipt_emitted = bool( + direct_media.get("read_receipt_emitted") + ) + _assert_no_local_read_receipt( + "Controlled direct media send", + direct_media, + ) + if ( + not evidence.direct_media_send_ok + or not evidence.direct_media_server_ack + ): + raise HarnessError( + "Controlled direct-media send did not receive a clean server ack" + ) + + group_token = "postmaster-group-" + secrets.token_hex(12) + group_text = dict( + await adapter.send_text( + jid=test_group_jid, + text=f"ECHO_GROUP:{group_token}", + ) + ) + evidence.group_text_send_ok = bool(group_text.get("ok")) + evidence.group_text_server_ack = bool(group_text.get("server_ack")) + evidence.group_text_device_fanout = _int_or_none( + group_text.get("device_fanout") + ) + evidence.group_text_sender_key_recipients = _int_or_none( + group_text.get("sender_key_recipients") + ) + evidence.group_text_read_receipt_emitted = bool( + group_text.get("read_receipt_emitted") + ) + _assert_no_local_read_receipt("Controlled group text send", group_text) + if ( + not evidence.group_text_send_ok + or not evidence.group_text_server_ack + ): + raise HarnessError( + "Controlled group-text send did not receive a clean server ack" + ) + + media_token = "postmaster-media-" + secrets.token_hex(12) + await adapter.send_text( + jid=test_group_jid, + text=( + "CONTROLLED_MEDIA_REQUEST:" + + media_token + + " — send one small media attachment to this group with this exact token in its caption." + ), + ) + + if run_media: + group_media = dict( + await adapter.send_media( + jid=test_group_jid, + stored_file_id="controlled-interop-document", + caption="Postmaster v9.9 controlled group media probe", + ) + ) + evidence.group_media_send_ok = bool(group_media.get("ok")) + evidence.group_media_server_ack = bool(group_media.get("server_ack")) + evidence.group_media_device_fanout = _int_or_none( + group_media.get("device_fanout") + ) + evidence.group_media_sender_key_recipients = _int_or_none( + group_media.get("sender_key_recipients") + ) + evidence.group_media_read_receipt_emitted = bool( + group_media.get("read_receipt_emitted") + ) + _assert_no_local_read_receipt( + "Controlled group media send", + group_media, + ) + if ( + not evidence.group_media_send_ok + or not evidence.group_media_server_ack + ): + raise HarnessError( + "Controlled group-media send did not receive a clean server ack" + ) + + receipt_coro = ( + _wait_for_receipt(events, direct_message_id, timeout) + if require_receipt + else asyncio.sleep(0, result=False) + ) + direct_echo_coro = ( + _wait_for_message( + events, + jid=test_jid, + token=direct_token, + kind="text", + timeout=timeout, + ) + if require_direct_echo + else asyncio.sleep(0, result=None) + ) + group_echo_coro = ( + _wait_for_message( + events, + jid=test_group_jid, + token=group_token, + kind="text", + timeout=timeout, + ) + if require_group_echo + else asyncio.sleep(0, result=None) + ) + group_media_coro = ( + _wait_for_message( + events, + jid=test_group_jid, + token=media_token, + kind="media", + timeout=timeout, + ) + if require_inbound_media + else asyncio.sleep(0, result=None) + ) + + ( + receipt_observed, + direct_echo, + group_echo, + group_media_in, + ) = await asyncio.gather( + receipt_coro, + direct_echo_coro, + group_echo_coro, + group_media_coro, + ) + evidence.remote_receipt_observed = bool(receipt_observed) + evidence.inbound_direct_echo_observed = direct_echo is not None + evidence.inbound_group_echo_observed = group_echo is not None + evidence.inbound_group_media_observed = group_media_in is not None + evidence.inbound_group_media_stored_file_observed = bool( + group_media_in and group_media_in.get("stored_file_id") + ) + evidence.inbound_stored_file_count = files.inbound_saved + + evidence.receipt_policy_observed = bool( + not evidence.direct_text_read_receipt_emitted + and not evidence.direct_media_read_receipt_emitted + and not evidence.group_text_read_receipt_emitted + and not evidence.group_media_read_receipt_emitted + ) + evidence.protocol_interop_observed = bool( + evidence.reconnect_ok + and evidence.group_listing_ok + and evidence.target_group_visible + and evidence.direct_text_server_ack + and evidence.group_text_server_ack + and (not run_media or evidence.direct_media_server_ack) + and (not run_media or evidence.group_media_server_ack) + ) + evidence.signal_multidevice_observed = bool( + evidence.direct_text_server_ack + and (evidence.direct_text_device_fanout or 0) > 0 + and (evidence.direct_text_remote_device_targets or 0) > 0 + and evidence.inbound_direct_echo_observed + ) + evidence.group_sender_key_interop_observed = bool( + evidence.group_text_server_ack + and (evidence.group_text_device_fanout or 0) > 0 + and evidence.inbound_group_echo_observed + ) + evidence.media_interop_observed = bool( + run_media + and evidence.direct_media_server_ack + and evidence.group_media_server_ack + and evidence.inbound_group_media_observed + and evidence.inbound_group_media_stored_file_observed + and evidence.inbound_stored_file_count > 0 + ) + evidence.controlled_account_interop_observed = bool( + evidence.protocol_interop_observed + and evidence.signal_multidevice_observed + and evidence.group_sender_key_interop_observed + and evidence.media_interop_observed + and evidence.receipt_policy_observed + and evidence.remote_receipt_observed + ) + + if require_receipt and not evidence.remote_receipt_observed: + raise HarnessError( + "No remote receipt for the controlled direct text was observed before timeout" + ) + if require_direct_echo and not evidence.inbound_direct_echo_observed: + raise HarnessError( + "No inbound controlled direct echo was observed before timeout" + ) + if require_group_echo and not evidence.inbound_group_echo_observed: + raise HarnessError( + "No inbound controlled group echo was observed before timeout" + ) + if require_inbound_media and not evidence.inbound_group_media_stored_file_observed: + raise HarnessError( + "No inbound controlled group media was decrypted and stored before timeout" + ) + if not evidence.receipt_policy_observed: + raise HarnessError( + "A non-reply controlled send unexpectedly emitted a local read receipt" + ) + if not evidence.controlled_account_interop_observed: + raise HarnessError( + "Controlled-account full acceptance conditions were not all satisfied" + ) + finally: + await adapter.close() + + evidence_path.write_text( + json.dumps(asdict(evidence), sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + print( + "Controlled WhatsApp interoperability evidence written without account identifiers, " + "key material or media payloads." + ) + print(f"protocol_interop_observed={evidence.protocol_interop_observed}") + print(f"signal_multidevice_observed={evidence.signal_multidevice_observed}") + print(f"group_sender_key_interop_observed={evidence.group_sender_key_interop_observed}") + print(f"media_interop_observed={evidence.media_interop_observed}") + print(f"receipt_policy_observed={evidence.receipt_policy_observed}") + print( + "controlled_account_interop_observed=" + f"{evidence.controlled_account_interop_observed}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/scripts/whatsapp_v990_network_smoke.py b/scripts/whatsapp_v990_network_smoke.py new file mode 100644 index 00000000..07302e5e --- /dev/null +++ b/scripts/whatsapp_v990_network_smoke.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +"""Unauthenticated WhatsApp Web transport smoke for CI. + +This proves only DNS/TCP/TLS plus RFC6455 Upgrade reachability for the current clean-room +transport endpoint. It never sends a Noise handshake, QR material, credentials, cookies, +account identifiers, messages, or other authenticated protocol data. +""" + +import base64 +import hashlib +import os +import socket +import ssl +import sys + +HOST = "web.whatsapp.com" +PORT = 443 +PATH = "/ws/chat" +ORIGIN = "https://web.whatsapp.com" +GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +MAX_HEADER_BYTES = 64 * 1024 + + +def _headers(raw: bytes) -> tuple[str, dict[str, str]]: + head = raw.decode("iso-8859-1", errors="replace") + lines = head.split("\r\n") + status = lines[0] if lines else "" + headers: dict[str, str] = {} + for line in lines[1:]: + if ":" not in line: + continue + key, value = line.split(":", 1) + headers[key.strip().lower()] = value.strip() + return status, headers + + +def main() -> int: + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET {PATH} HTTP/1.1\r\n" + f"Host: {HOST}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + f"Origin: {ORIGIN}\r\n" + "User-Agent: Postmaster-v9.9-CI-transport-smoke/1.0\r\n" + "\r\n" + ).encode("ascii") + + context = ssl.create_default_context() + with socket.create_connection((HOST, PORT), timeout=15) as tcp: + with context.wrap_socket(tcp, server_hostname=HOST) as tls: + tls.settimeout(15) + tls.sendall(request) + response = bytearray() + while b"\r\n\r\n" not in response: + chunk = tls.recv(4096) + if not chunk: + break + response.extend(chunk) + if len(response) > MAX_HEADER_BYTES: + raise RuntimeError("WebSocket response headers exceeded safety bound") + + header_block = bytes(response).split(b"\r\n\r\n", 1)[0] + status, headers = _headers(header_block) + if not status.startswith("HTTP/1.1 101"): + print(f"WhatsApp WebSocket transport smoke failed: {status or 'no HTTP status'}", file=sys.stderr) + return 2 + + upgrade = headers.get("upgrade", "").lower() + connection = headers.get("connection", "").lower() + expected_accept = base64.b64encode( + hashlib.sha1((key + GUID).encode("ascii")).digest() + ).decode("ascii") + actual_accept = headers.get("sec-websocket-accept", "") + + if upgrade != "websocket" or "upgrade" not in connection: + print("WhatsApp endpoint did not return a valid WebSocket Upgrade response", file=sys.stderr) + return 3 + if actual_accept != expected_accept: + print("WhatsApp endpoint returned an invalid Sec-WebSocket-Accept", file=sys.stderr) + return 4 + + peer = tls.getpeercert() + print("WhatsApp WebSocket transport reachable: HTTP 101") + print(f"TLS version: {tls.version()}") + print(f"Certificate subject present: {bool(peer.get('subject'))}") + print("Authentication/protocol/account interoperability: NOT TESTED") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/whatsapp_v990_noise_registration_probe.py b/scripts/whatsapp_v990_noise_registration_probe.py new file mode 100644 index 00000000..b96c4028 --- /dev/null +++ b/scripts/whatsapp_v990_noise_registration_probe.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +"""Live, unauthenticated WhatsApp Noise ClientFinish/registration probe. + +The probe uses fresh ephemeral Noise + Signal identity material on every run. It sends a +registration-shaped ClientPayload through the current Noise XX handshake and verifies that the +first post-handshake transport frame can be authenticated/decrypted with the derived transport +keys. It deliberately stops before QR display/scan and never pairs an account. + +Passing is evidence for: +- current WebSocket endpoint/origin reachability, +- Noise XX ClientHello/ServerHello/ClientFinish compatibility, +- pinned server certificate-chain verification, +- current registration ClientPayload wire acceptance far enough to enter transport mode. + +Passing is NOT evidence for account pairing, Signal multi-device lifecycle, messaging, media, +groups, receipts, or controlled-account interoperability. +""" + +import asyncio +import hashlib + +from postmaster.whatsapp_v990.client_payload import RegistrationKeys, build_registration_payload +from postmaster.whatsapp_v990.crypto import ( + NOISE_WA_HEADER, + WhatsAppNoiseXX, + frame_noise_payload, + generate_curve_keypair, + split_noise_frames, +) +from postmaster.whatsapp_v990.handshake import decode_handshake, encode_client_finish, encode_client_hello +from postmaster.whatsapp_v990.signal_keys import generate_registration_id, generate_signed_pre_key +from postmaster.whatsapp_v990.websocket_driver import open_whatsapp_websocket + + +async def _recv_frames(ws, *, timeout: float = 15.0) -> list[bytes]: + incoming = await asyncio.wait_for(ws.recv(), timeout=timeout) + if isinstance(incoming, str): + raise RuntimeError("WhatsApp server returned text during binary Noise session") + frames, rest = split_noise_frames(bytes(incoming)) + if rest: + raise RuntimeError(f"partial trailing Noise frame received: {len(rest)} bytes") + if not frames: + raise RuntimeError("WhatsApp returned no complete Noise frame") + return frames + + +async def main() -> int: + ephemeral = generate_curve_keypair() + noise_static = generate_curve_keypair() + identity = generate_curve_keypair() + signed_pre_key = generate_signed_pre_key(identity, 1) + registration = RegistrationKeys( + registration_id=generate_registration_id(), + identity_public_key=identity.public, + signed_pre_key=signed_pre_key, + ) + payload = build_registration_payload(registration) + + ws = await open_whatsapp_websocket() + try: + await ws.send(frame_noise_payload(encode_client_hello(ephemeral.public), intro=NOISE_WA_HEADER)) + server_frames = await _recv_frames(ws) + if len(server_frames) != 1: + raise RuntimeError(f"expected one ServerHello frame, received {len(server_frames)}") + + handshake = decode_handshake(server_frames[0]) + server = handshake.server_hello + if server is None: + raise RuntimeError("WhatsApp did not return ServerHello") + + noise = WhatsAppNoiseXX(ephemeral) + encrypted_client_static, _cert_chain = noise.process_server_hello( + server_ephemeral=server.ephemeral, + encrypted_static=server.static, + encrypted_payload=server.payload, + noise_static=noise_static, + ) + encrypted_payload = noise.encrypt(payload) + finish = encode_client_finish(encrypted_client_static, encrypted_payload) + await ws.send(frame_noise_payload(finish)) + + transport = noise.finish() + post_frames = await _recv_frames(ws) + plaintext = [transport.decrypt(frame) for frame in post_frames] + if not plaintext or not any(item for item in plaintext): + raise RuntimeError("post-ClientFinish transport response decrypted to empty payloads") + + total = sum(len(item) for item in plaintext) + digest = hashlib.sha256(b"".join(plaintext)).hexdigest() + print("WhatsApp live Noise ClientFinish accepted into authenticated transport framing") + print(f"Registration ClientPayload bytes: {len(payload)}") + print(f"Post-handshake transport frames: {len(plaintext)}") + print(f"Post-handshake plaintext bytes: {total}") + print(f"Post-handshake plaintext SHA256: {digest}") + print("QR/account pairing: NOT PERFORMED") + print("Signal multi-device/message/media/group/receipt interoperability: NOT TESTED") + return 0 + finally: + await ws.close() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/scripts/whatsapp_v990_noise_server_hello_probe.py b/scripts/whatsapp_v990_noise_server_hello_probe.py new file mode 100644 index 00000000..0f381478 --- /dev/null +++ b/scripts/whatsapp_v990_noise_server_hello_probe.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +"""Live, unauthenticated WhatsApp Noise server-hello probe. + +This stops before ClientFinish/ClientPayload. It uses only ephemeral/random client keys and +validates the server Noise certificate chain. No QR, account, cookie, message or credential +is sent. Passing this probe is evidence for the initial Noise XX server-hello path only. +""" + +import asyncio +import time + +from postmaster.whatsapp_v990.cert import verify_noise_certificate_chain +from postmaster.whatsapp_v990.crypto import ( + NOISE_WA_HEADER, + WhatsAppNoiseXX, + generate_curve_keypair, + split_noise_frames, + frame_noise_payload, +) +from postmaster.whatsapp_v990.handshake import decode_handshake, encode_client_hello +from postmaster.whatsapp_v990.proto import ProtoField, decode_fields +from postmaster.whatsapp_v990.websocket_driver import open_whatsapp_websocket + + +async def main() -> int: + ephemeral = generate_curve_keypair() + noise_static = generate_curve_keypair() + noise = WhatsAppNoiseXX(ephemeral) + + ws = await open_whatsapp_websocket() + try: + client_hello = encode_client_hello(ephemeral.public) + await ws.send(frame_noise_payload(client_hello, intro=NOISE_WA_HEADER)) + + incoming = await asyncio.wait_for(ws.recv(), timeout=15) + if isinstance(incoming, str): + raise RuntimeError("WhatsApp server returned text during Noise handshake") + frames, rest = split_noise_frames(bytes(incoming)) + if rest: + raise RuntimeError(f"partial trailing Noise frame received: {len(rest)} bytes") + if len(frames) != 1: + raise RuntimeError(f"expected one server Noise frame, received {len(frames)}") + + handshake = decode_handshake(frames[0]) + server = handshake.server_hello + if server is None: + raise RuntimeError("WhatsApp did not return ServerHello") + + _encrypted_client_static, cert_chain = noise.process_server_hello( + server_ephemeral=server.ephemeral, + encrypted_static=server.static, + encrypted_payload=server.payload, + noise_static=noise_static, + ) + cert = verify_noise_certificate_chain(cert_chain) + + print("WhatsApp live Noise ServerHello decrypted successfully") + print(f"Server ephemeral bytes: {len(server.ephemeral)}") + print(f"Server certificate chain bytes: {len(cert_chain)}") + print(f"Certificate signatures verified: {cert.chain_signatures_verified}") + print(f"Issuer serial: {cert.intermediate.issuer_serial}") + print("ClientFinish/account authentication: NOT SENT / NOT TESTED") + return 0 + finally: + await ws.close() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/scripts/whatsapp_v990_pair_device_probe.py b/scripts/whatsapp_v990_pair_device_probe.py new file mode 100644 index 00000000..6442a13d --- /dev/null +++ b/scripts/whatsapp_v990_pair_device_probe.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +"""Live unauthenticated WhatsApp registration -> pair-device probe. + +The probe completes the public Noise XX ClientFinish with a fresh, throw-away registration +bundle and waits for WhatsApp's encrypted WABinary pair-device stanza. It never displays or +persists QR refs, never scans a QR, never authenticates an account, and never sends messages. +""" + +import asyncio +import os + +from postmaster.whatsapp_v990.binary import BinaryNode, BinaryNodeCodec, BinaryNodeError +from postmaster.whatsapp_v990.cert import verify_noise_certificate_chain +from postmaster.whatsapp_v990.client_payload import RegistrationKeys, build_registration_payload +from postmaster.whatsapp_v990.crypto import ( + NOISE_WA_HEADER, + WhatsAppNoiseXX, + frame_noise_payload, + generate_curve_keypair, + split_noise_frames, +) +from postmaster.whatsapp_v990.handshake import decode_handshake, encode_client_finish, encode_client_hello +from postmaster.whatsapp_v990.signal_keys import generate_registration_id, generate_signed_pre_key +from postmaster.whatsapp_v990.tokens import CURRENT_TOKEN_TABLE +from postmaster.whatsapp_v990.websocket_driver import WebSocketDriverConfig, open_whatsapp_websocket + + +async def _recv_frames(ws, pending: bytes, *, timeout: float = 15.0) -> tuple[list[bytes], bytes]: + value = await asyncio.wait_for(ws.recv(), timeout=timeout) + if isinstance(value, str): + raise RuntimeError("WhatsApp returned a text WebSocket frame during binary protocol setup") + return split_noise_frames(pending + bytes(value)) + + +async def main() -> int: + ephemeral = generate_curve_keypair() + noise_static = generate_curve_keypair() + identity = generate_curve_keypair() + signed_pre_key = generate_signed_pre_key(identity, 1) + registration_id = generate_registration_id() + + registration_payload = build_registration_payload( + RegistrationKeys( + registration_id=registration_id, + identity_public_key=identity.public, + signed_pre_key=signed_pre_key, + ) + ) + + noise = WhatsAppNoiseXX(ephemeral) + codec = BinaryNodeCodec(CURRENT_TOKEN_TABLE) + ws = await open_whatsapp_websocket(WebSocketDriverConfig(prefer_native=True)) + pending = b"" + try: + await ws.send(frame_noise_payload(encode_client_hello(ephemeral.public), intro=NOISE_WA_HEADER)) + + frames: list[bytes] = [] + while not frames: + frames, pending = await _recv_frames(ws, pending) + if len(frames) != 1: + raise RuntimeError(f"expected one Noise ServerHello frame, received {len(frames)}") + + hello = decode_handshake(frames[0]).server_hello + if hello is None: + raise RuntimeError("WhatsApp did not return Noise ServerHello") + + encrypted_static, cert_chain = noise.process_server_hello( + server_ephemeral=hello.ephemeral, + encrypted_static=hello.static, + encrypted_payload=hello.payload, + noise_static=noise_static, + ) + cert = verify_noise_certificate_chain(cert_chain) + encrypted_payload = noise.encrypt(registration_payload) + await ws.send(frame_noise_payload(encode_client_finish(encrypted_static, encrypted_payload))) + transport = noise.finish() + + pair_device: BinaryNode | None = None + pair_iq: BinaryNode | None = None + decoded_count = 0 + for _ in range(24): + encrypted_frames, pending = await _recv_frames(ws, pending, timeout=15.0) + for encrypted in encrypted_frames: + plaintext = transport.decrypt(encrypted) + try: + node = codec.decode(plaintext) + except BinaryNodeError as exc: + raise RuntimeError(f"current WABinary token dictionary could not decode live traffic: {exc}") from exc + decoded_count += 1 + candidate = node.child("pair-device") if node.tag == "iq" else None + if candidate is not None: + pair_iq, pair_device = node, candidate + break + if pair_device is not None: + break + + if pair_device is None or pair_iq is None: + raise RuntimeError(f"pair-device stanza not observed after {decoded_count} decrypted WABinary frames") + + refs = pair_device.children("ref") + if not refs: + raise RuntimeError("pair-device stanza contained no QR reference nodes") + if not pair_iq.attrs.get("id"): + raise RuntimeError("pair-device IQ has no stanza id") + + # Acknowledge the stanza exactly as a Web companion is expected to do. This does not + # authenticate or pair an account; it only confirms receipt of the ephemeral QR refs. + ack = BinaryNode( + "iq", + {"to": "s.whatsapp.net", "type": "result", "id": pair_iq.attrs["id"]}, + ) + ack_wire = codec.encode(ack) + await ws.send(frame_noise_payload(transport.encrypt(ack_wire))) + + print("WhatsApp live registration ClientFinish accepted") + print(f"Noise certificate signatures verified: {cert.chain_signatures_verified}") + print(f"Current WABinary token table version: {CURRENT_TOKEN_TABLE.version}") + print(f"Encrypted transport frames decoded before pair-device: {decoded_count}") + print(f"Pair-device QR references received: {len(refs)}") + print("QR reference contents: NOT PRINTED / NOT PERSISTED") + print("Account pairing/authentication: NOT PERFORMED") + return 0 + finally: + await ws.close() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/postmaster/calendar_v990.py b/src/postmaster/calendar_v990.py new file mode 100644 index 00000000..1bdf0279 --- /dev/null +++ b/src/postmaster/calendar_v990.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from email.message import Message +from email.policy import default +from email.parser import BytesParser +from typing import Any, Iterable +from urllib.parse import urlparse + +_CALENDAR_TYPES = { + "text/calendar", + "application/calendar", + "application/ics", + "application/icalendar", + "text/x-vcalendar", +} +_VIDEO_HOSTS = { + "meet.google.com", + "teams.microsoft.com", + "teams.live.com", + "zoom.us", + "www.zoom.us", + "webex.com", + "www.webex.com", + "whereby.com", + "jitsi.org", + "meet.jit.si", +} +_URL_RE = re.compile(r"https?://[^\s<>\"']+", re.I) + + +class CalendarError(ValueError): + pass + + +def _unfold_ical(text: str) -> list[str]: + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + out: list[str] = [] + for line in normalized.split("\n"): + if line.startswith((" ", "\t")) and out: + out[-1] += line[1:] + else: + out.append(line) + return out + + +def _split_property(line: str) -> tuple[str, dict[str, str], str] | None: + if ":" not in line: + return None + left, value = line.split(":", 1) + bits = left.split(";") + name = bits[0].strip().upper() + if not name: + return None + params: dict[str, str] = {} + for bit in bits[1:]: + if "=" in bit: + k, v = bit.split("=", 1) + params[k.strip().upper()] = v.strip().strip('"') + return name, params, value.strip() + + +def _ical_unescape(value: str) -> str: + return ( + value.replace("\\N", "\n") + .replace("\\n", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") + ) + + +def _ical_escape(value: str) -> str: + return ( + str(value) + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\r", "\\n") + .replace("\n", "\\n") + ) + + +def _parse_dt(value: str, params: dict[str, str]) -> dict[str, Any]: + raw = value.strip() + timezone = params.get("TZID") or "" + date_only = params.get("VALUE", "").upper() == "DATE" or bool(re.fullmatch(r"\d{8}", raw)) + dt: datetime | None = None + try: + if date_only: + dt = datetime.strptime(raw[:8], "%Y%m%d").replace(tzinfo=UTC) + elif raw.endswith("Z"): + dt = datetime.strptime(raw, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC) + elif re.fullmatch(r"\d{8}T\d{6}", raw): + # A TZID value is kept explicitly instead of pretending the local wall time is UTC. + dt = datetime.strptime(raw, "%Y%m%dT%H%M%S") + except ValueError: + dt = None + return { + "raw": raw, + "timezone": timezone or None, + "date_only": date_only, + "iso": dt.isoformat() if dt else None, + } + + +def _address(value: str) -> str: + v = value.strip() + if v.lower().startswith("mailto:"): + return v[7:] + return v + + +def _urls(values: Iterable[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for value in values: + for match in _URL_RE.findall(value or ""): + url = match.rstrip(".,);]>") + if url not in seen: + seen.add(url) + out.append(url) + return out + + +def _video_urls(urls: Iterable[str]) -> list[str]: + result: list[str] = [] + for url in urls: + try: + host = (urlparse(url).hostname or "").lower() + except ValueError: + continue + if host in _VIDEO_HOSTS or any(host.endswith("." + item) for item in _VIDEO_HOSTS): + result.append(url) + continue + if any(token in host for token in ("zoom", "meet", "teams", "webex", "whereby", "jitsi")): + result.append(url) + return result + + +def parse_ics(text: str) -> dict[str, Any]: + """Parse VEVENT fields locally without resolving URLs or contacting organizers.""" + lines = _unfold_ical(text) + method = "" + events: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + raw_props: list[tuple[str, dict[str, str], str]] = [] + + for line in lines: + parsed = _split_property(line) + if not parsed: + continue + name, params, value = parsed + if name == "METHOD" and current is None: + method = value.upper() + continue + if name == "BEGIN" and value.upper() == "VEVENT": + current = { + "attendees": [], + "urls": [], + "video_urls": [], + "x_properties": {}, + } + raw_props = [] + continue + if name == "END" and value.upper() == "VEVENT" and current is not None: + text_values = [ + str(current.get("url") or ""), + str(current.get("location") or ""), + str(current.get("description") or ""), + ] + for key, val in current.get("x_properties", {}).items(): + if any(token in key for token in ("CONFERENCE", "MEETING", "HANGOUT", "ONLINE")): + text_values.append(str(val)) + urls = _urls(text_values) + current["urls"] = urls + current["video_urls"] = _video_urls(urls) + events.append(current) + current = None + raw_props = [] + continue + if current is None: + continue + raw_props.append(parsed) + clean = _ical_unescape(value) + if name == "UID": + current["uid"] = clean + elif name == "SUMMARY": + current["summary"] = clean + elif name == "DESCRIPTION": + current["description"] = clean + elif name == "LOCATION": + current["location"] = clean + elif name == "URL": + current["url"] = clean + elif name == "DTSTART": + current["start"] = _parse_dt(value, params) + elif name == "DTEND": + current["end"] = _parse_dt(value, params) + elif name == "DTSTAMP": + current["dtstamp"] = _parse_dt(value, params) + elif name == "SEQUENCE": + try: + current["sequence"] = int(value) + except ValueError: + current["sequence"] = value + elif name == "STATUS": + current["status"] = value.upper() + elif name == "ORGANIZER": + current["organizer"] = { + "address": _address(clean), + "name": params.get("CN") or None, + } + elif name == "ATTENDEE": + current["attendees"].append( + { + "address": _address(clean), + "name": params.get("CN") or None, + "role": params.get("ROLE") or None, + "partstat": params.get("PARTSTAT") or None, + "rsvp": params.get("RSVP") or None, + } + ) + elif name.startswith("X-"): + current["x_properties"][name] = clean + + invitation = bool(events) and method in {"REQUEST", "PUBLISH", "ADD", "CANCEL", "REPLY", "COUNTER", "DECLINECOUNTER"} + return { + "ok": True, + "method": method or None, + "is_calendar": bool(events), + "is_invitation": invitation, + "events": events, + "network_access": False, + "external_actions": False, + } + + +def _part_filename(part: Message) -> str: + try: + return str(part.get_filename() or "") + except Exception: + return "" + + +def detect_calendar_parts(message_or_bytes: Message | bytes) -> dict[str, Any]: + """Inspect headers and MIME parts. This function is deliberately zero-network/read-only.""" + message = ( + BytesParser(policy=default).parsebytes(message_or_bytes) + if isinstance(message_or_bytes, (bytes, bytearray)) + else message_or_bytes + ) + hints: list[str] = [] + headers = {str(k).lower(): str(v) for k, v in message.items()} + for name in ("content-class", "x-microsoft-cdo-message-class", "x-ms-has-attach"): + value = headers.get(name, "") + if "calend" in value.lower() or "appointment" in value.lower(): + hints.append(f"header:{name}") + + parts: list[dict[str, Any]] = [] + walk = list(message.walk()) if message.is_multipart() else [message] + for index, part in enumerate(walk): + content_type = str(part.get_content_type() or "").lower() + filename = _part_filename(part) + calendar_like = content_type in _CALENDAR_TYPES or filename.lower().endswith((".ics", ".ifb", ".vcs")) + if not calendar_like: + continue + try: + payload = part.get_payload(decode=True) + except Exception: + payload = None + if payload is None: + try: + content = part.get_content() + text = content if isinstance(content, str) else str(content) + except Exception: + text = "" + else: + charset = part.get_content_charset() or "utf-8" + try: + text = payload.decode(charset, errors="replace") + except LookupError: + text = payload.decode("utf-8", errors="replace") + parsed = parse_ics(text) if text else {"ok": True, "is_calendar": True, "is_invitation": False, "events": []} + parts.append( + { + "part_index": index, + "content_type": content_type, + "content_disposition": str(part.get_content_disposition() or ""), + "filename": filename or None, + "method_parameter": str(part.get_param("method") or "").upper() or None, + "sha256": hashlib.sha256((payload or text.encode("utf-8", "replace"))).hexdigest(), + "calendar": parsed, + } + ) + return { + "ok": True, + "calendar_candidate": bool(parts or hints), + "calendar_parts": parts, + "header_hints": hints, + "is_invitation": any(bool(p.get("calendar", {}).get("is_invitation")) for p in parts), + "network_access": False, + "external_actions": False, + } + + +def _fold_ical_line(line: str, limit: int = 75) -> list[str]: + # RFC 5545 fold limit is octets. Keep ASCII output where possible; UTF-8-safe fallback. + encoded = line.encode("utf-8") + if len(encoded) <= limit: + return [line] + out: list[str] = [] + current = "" + current_len = 0 + for ch in line: + size = len(ch.encode("utf-8")) + if current and current_len + size > (limit if not out else limit - 1): + out.append(current) + current = " " + ch + current_len = 1 + size + else: + current += ch + current_len += size + if current: + out.append(current) + return out + + +def build_calendar_request( + *, + uid: str, + summary: str, + start_utc: datetime, + end_utc: datetime, + organizer_email: str, + attendees: Iterable[str], + description: str = "", + location: str = "", + video_url: str | None = None, + sequence: int = 0, + prodid: str = "-//Postmaster MCP//v9.9//EN", +) -> str: + if start_utc.tzinfo is None or end_utc.tzinfo is None: + raise CalendarError("Calendar invitation start/end must be timezone-aware") + start = start_utc.astimezone(UTC) + end = end_utc.astimezone(UTC) + if end <= start: + raise CalendarError("Calendar invitation end must be after start") + attendee_list = [str(x).strip() for x in attendees if str(x).strip()] + if not attendee_list: + raise CalendarError("Calendar invitation requires at least one attendee") + if not organizer_email.strip(): + raise CalendarError("Calendar invitation requires organizer email") + + def stamp(dt: datetime) -> str: + return dt.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + + desc = description + if video_url and video_url not in desc: + desc = (desc + "\n\n" if desc else "") + f"Video call: {video_url}" + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + f"PRODID:{_ical_escape(prodid)}", + "CALSCALE:GREGORIAN", + "METHOD:REQUEST", + "BEGIN:VEVENT", + f"UID:{_ical_escape(uid)}", + f"DTSTAMP:{stamp(datetime.now(UTC))}", + f"DTSTART:{stamp(start)}", + f"DTEND:{stamp(end)}", + f"SEQUENCE:{int(sequence)}", + "STATUS:CONFIRMED", + f"SUMMARY:{_ical_escape(summary)}", + f"ORGANIZER:mailto:{organizer_email.strip()}", + ] + for attendee in attendee_list: + lines.append(f"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:{attendee}") + if location: + lines.append(f"LOCATION:{_ical_escape(location)}") + if desc: + lines.append(f"DESCRIPTION:{_ical_escape(desc)}") + if video_url: + lines.append(f"URL:{video_url}") + lines.append(f"X-POSTMASTER-VIDEOCALL:{video_url}") + lines.extend(["END:VEVENT", "END:VCALENDAR"]) + folded: list[str] = [] + for line in lines: + folded.extend(_fold_ical_line(line)) + return "\r\n".join(folded) + "\r\n" + + +def job_calendar_window(job: dict[str, Any], *, default_duration_minutes: int = 30) -> tuple[datetime, datetime]: + """Resolve explicit calendar payload first, then one-time ISO schedule metadata.""" + payload = dict(job.get("payload") or {}) + cal = dict(payload.get("calendar") or {}) + start_raw = cal.get("start") or payload.get("calendar_start") + end_raw = cal.get("end") or payload.get("calendar_end") + if not start_raw and str(job.get("schedule_type") or "").lower() in {"once", "at", "datetime"}: + start_raw = job.get("schedule_value") + if not start_raw: + raise CalendarError("Task does not contain a concrete calendar start time") + + def parse(value: Any) -> datetime: + text = str(value or "").strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + raise CalendarError("Calendar task times must include a timezone offset") + return dt + + start = parse(start_raw) + end = parse(end_raw) if end_raw else start + timedelta(minutes=int(cal.get("duration_minutes") or default_duration_minutes)) + if end <= start: + raise CalendarError("Calendar task end must be after start") + return start, end diff --git a/src/postmaster/email_search_v990.py b/src/postmaster/email_search_v990.py new file mode 100644 index 00000000..98698d45 --- /dev/null +++ b/src/postmaster/email_search_v990.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +from contextlib import closing + +import hashlib +import io +import json +import math +import re +import sqlite3 +import threading +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from email.message import Message +from email.parser import BytesParser +from email.policy import default +from html import unescape +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Sequence + +import numpy as np + +try: + from pypdf import PdfReader +except Exception: # pragma: no cover - optional at import time in unit environments + PdfReader = None # type: ignore[assignment] + + +class EmailSearchError(ValueError): + pass + + +_TEXT_TYPES = { + "text/plain", + "text/csv", + "text/markdown", + "text/x-markdown", + "application/json", + "application/xml", + "text/xml", + "application/yaml", + "application/x-yaml", +} +_TOKEN_RE = re.compile(r"[\w@.+-]{2,}", re.UNICODE) +_TAG_RE = re.compile(r"<[^>]+>") + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _clean_html(text: str) -> str: + text = re.sub(r"(?is)<(script|style)\b.*?>.*?\1>", " ", text) + text = re.sub(r"(?i)Scan from WhatsApp → Linked devices. QR rendering is local and never uses a third-party endpoint.
No active pairing QR. Start pairing explicitly to generate one.
' + return ( + 'Clean-room companion session supervision.
Status: {html.escape(state)}
' + 'Reading messages here never sends read receipts. A read receipt is permitted only inside an explicit outbound reply flow.
' + f'{qr_block}' + '