Threat model, cryptographic invariants, and known coupling for this deployment.
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.
For product help (setup, disconnect, billing questions): email [email protected]. Do not use the security address for general support.
Per user, the service brokers two trust relationships:
| Boundary | Trusted credential | Lives |
|---|---|---|
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).
audit_logs with the redaction policy below.When your account is set to mode='local', the security guarantees change significantly:
session_encrypted column is NULL for local-mode accounts. The MTProto session lives entirely on your local machine, encrypted at rest by the daemon.call_path column is set to 'local' so you can distinguish relay-forwarded calls from hosted calls in your audit log.The slog redaction handler scrubs every log line, and the audit-log schema does not contain columns for any of the following:
Authorization: Bearer headersThe 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.
OAUTH_JWT_SECRET for both services in the same change. Failing to do so will lock out users on whichever side is stale.aud claim that is checked against the resource server's expected audience, so a JWT minted for a sibling service cannot be replayed against tg-mcp once both sides have rolled out aud emission and enforcement./oauth/jwks to mctl-api; switch mctl-telegram to JWKS verification.ENCRYPTION_KEY is expected to be 32 random bytes, hex-encoded (64 chars). The service refuses to start with any other length. Production deployments MUST set it; leaving it unset is allowed for local-dev only and emits a startup warning that session bytes will land on disk in cleartext.subkey = HKDF(master, salt=user_id_be64, info="mctl-telegram-session-v2", L=32). Each row has cryptographically independent keys.text, decoded phone digits, 2FA password, MTProto session bytes, JWT secret, or encryption keys. The slog redaction handler strips these keys before any line reaches stdout.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:
ALLOW_SEND=true (Helm values).telegram:messages:send scope (group → scope map).telegram_accounts.send_enabled = true for that operator.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.
AUTH_REQUIRED=false is for local development only. The deployed pod MUST set AUTH_REQUIRED=true and AUTH_MODE=local-jwt.local-dev provider returns a fixed identity with platform-admin scopes and is gated by AUTH_MODE=local-dev. It MUST NOT be reachable from any non-localhost interface in production.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.
Each MTProto session carries two expiry knobs, enforced by the request path and a background sweeper:
last_used_at older than 30 days revokes the row on next Borrow.expires_at (stamped on insert) in the past revokes the row.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).
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.