M mctl-telegram
Security model

Security model

Threat model, cryptographic invariants, and known coupling for this deployment.

Not zero-knowledge (hosted mode). In the default hosted mode, mctl-telegram makes outbound MTProto calls to Telegram on your behalf, so the server process necessarily handles plaintext Telegram data while serving your request. You are trusting both the operator of this deployment and the integrity of this code. For a stronger trust model, see the Local Bridge mode section below.

Reporting vulnerabilities

Email [email protected]. Please do not open public GitHub issues for unpatched vulnerabilities. Acknowledgement target: 72 hours. Fixes are disclosed in the CHANGELOG once a release ships.

End-user support

For product help (setup, disconnect, billing questions): email [email protected]. Do not use the security address for general support.

Trust boundaries

Per user, the service brokers two trust relationships:

BoundaryTrusted credentialLives
Inbound HTTP (ChatGPT / Claude / MCP clients → /mcp) OAuth JWT issued and verified by this server (local-jwt mode); shared-hmac-legacy mode uses api.mctl.ai Bearer header, per-request
Outbound MTProto (gotd/td → Telegram) Encrypted session blob Postgres telegram_accounts.session_encrypted

Compromise of either boundary compromises only the affected operator's account — not other users — provided the per-user session key derivation is intact (see Cryptographic invariants below).

What the server sees

Local Bridge mode

When your account is set to mode='local', the security guarantees change significantly:

What the server NEVER persists

The slog redaction handler scrubs every log line, and the audit-log schema does not contain columns for any of the following:

Known coupling: shared-hmac-legacy mode

The AUTH_MODE=shared-hmac-legacy auth mode validates JWTs by re-implementing mctl-api's HMAC-SHA256 verifier and reading the same OAUTH_JWT_SECRET from Vault (secret/platform/oauth-jwt-secret). This applies only to the legacy mode, not to the default local-jwt mode.

Cryptographic invariants

Send-gate (defense in depth)

On production tg.mctl.ai, ALLOW_SEND=false is the default — every send_message call returns a dry-run preview. send_message takes no mode argument; the gate alone decides. Real Telegram sends require all of:

  1. Server flag ALLOW_SEND=true (Helm values).
  2. Identity has telegram:messages:send scope (group → scope map).
  3. telegram_accounts.send_enabled = true for that operator.
  4. Per-peer send rate limit not exhausted.

Any condition false → response is a dry-run preview (sent=false) containing the proposed text and the failing-condition reason in dry_reason; nothing reaches the Telegram API.

Authentication-required mode

Rate limiting

Per-identity token bucket, default 30 requests/minute, capped at the same burst. send_message (direct) and prepare_pin_message also consume a per-(identity, peer) bucket — 20 actions per peer per hour — so a single bad actor cannot fan a payload at one recipient up to the global ceiling. Anonymous traffic to /healthz, /readyz, /.well-known/*, /, /security, and /privacy is exempt and limited only at the ingress level.

Session TTL

Each MTProto session carries two expiry knobs, enforced by the request path and a background sweeper:

Tamper-evident audit log

Every audit_logs row gains prev_hash + entry_hash columns. Each entry is anchored by the SHA-256 of the previous entry's hash plus a canonical encoding of the current row. You can ask the server to recompute and verify your chain at any time:

GET /api/account/audit/verify
{
  "ok": true,
  "verified": 42
}

A non-OK response includes first_bad_id and a human-readable reason. Pre-M3.1 legacy rows are skipped (they pre-date the chain).

Prompt-injection content boundary

Read tools (get_unread_messages, get_messages) wrap every Telegram message body in <telegram-content origin="telegram" peer="redacted" untrusted="true">…</telegram-content>. A top-level notice field repeats the same guidance in prose. Adversarial closing-tag literals inside a message body are escaped to the underscore form so a sender cannot break out of the untrusted block. The wrapping is best-effort signalling — clients are free to ignore it — but it gives an LLM a clear data-vs-instructions boundary that prompt-injection text cannot remove on its own.