Helios

ADR-0011 — Callback data signing & validation hygiene

**Status.** Accepted.

Status. Accepted. Date. 2026-05-18.

Context

aiogram's default CallbackData factory serialises payloads as a colon-delimited string up to 64 bytes. Two risks:

  1. A user can forward another user's inline message and press its button; without per-user verification, the handler receives a callback whose payload was crafted for someone else.
  2. A user with a packet capture / inspecting Web app callbacks could replay or mutate a previously-issued button's payload.

We need every callback to be verifiable: emitted by us, intended for this user, not tampered with, not replayable beyond a reasonable window.

Decision — signed CallbackData

Every CallbackData factory in apps/bot/keyboards/factories.py wraps a base payload with a signature segment:

{prefix}:{nonce}:{ts}:{tail}:{sig}

Where:

  • prefix — a short, stable identifier of the action (e.g. tgt.add).
  • nonce — a base64url(6 random bytes) issued at button render time, also stored in Redis keyed cb_nonce:{tg_user_id}:{nonce} with TTL = callback.signature.ttl_seconds (default 600 s).
  • ts — render time (seconds since epoch, last 9 digits).
  • tail — application payload, separated by additional : if multi-field, base64url-encoded.
  • sigbase64url(HMAC_SHA256(callback_secret, f"{prefix}:{nonce}:{ts}:{tail}:{tg_user_id}"))[:8] — truncated, 8 bytes is enough at our scale and fits the 64-byte cap.

The callback_secret is a random 32-byte value held in infrastructure/crypto/aes.MASTER_KEY via HKDF (info=b"helios-cb-v1"). Hot rotation is supported by keeping the previous key for callback.signature.rotation_grace_seconds.

Decision — verification on every dispatch

A single global filter apps/bot/filters/signed_callback.py runs before any handler:

  1. Parse prefix:nonce:ts:tail:sig.
  2. Reject if now - ts > callback.signature.ttl_seconds.
  3. Recompute the HMAC. Reject if not matching (timing-safe compare).
  4. Look up Redis cb_nonce:{tg_user_id}:{nonce}. Reject if absent (replay / cross-user).
  5. Delete the nonce immediately when the action is non-idempotent (the factory marks idempotent actions with idempotent=True).
  6. Pass the typed payload to the handler.

A failed verification answers the callback with a localised error toast and emits bot.callback_rejected with the rejection reason.

Decision — typed payload contracts

The CallbackData factory uses Pydantic v2 dataclasses internally so payload fields are strongly typed at both render and dispatch. Handlers receive a payload: TypedPayload argument, not a raw string.

Decision — wider validation hygiene

Beyond callbacks:

  • Mini App initData — verified server-side on every API request (apps/api/middlewares/initdata.py) with HMAC of data_check_string. Cache freshness limit mini_app.initdata.max_age_seconds, default 86 400 (per Telegram guideline).
  • Web portal JWT (ADR-0007) — Telegram OIDC verified once at login; subsequent requests carry our own short-lived JWT with aud=helios-portal, refreshed via cookie.
  • Webhook signatures — every plugin webhook (payment_provider/) verifies HMAC with a per-route secret stored AES-encrypted in payment_provider_configs.
  • API tokens — sha256-hashed at rest, scoped per route, rate-limited per token, optional IP pin.
  • Pydantic everywhere at the boundary: FastAPI request bodies, JSON columns, plugin settings schemas — all validated.
  • @validated_action decorator at handler entry, layered on top of the role/tier filters, runs a feature-specific Pydantic schema against the parsed payload (extra defence beyond callback HMAC).
  • Rate limit per action categorybot.callback.rate_per_minute (default 60), per-tg-user. Same token-bucket as the existing throttle.

Decision — server-issued ephemeral codes

When a Mini App page issues an action that involves a callback (e.g. "Save settings"), the page calls /v1/actions/issue which returns an opaque token containing the same prefix:nonce:ts:tail:sig shape, allowing the Mini App to round-trip an action through the bot inbox if it needs to (e.g. to send an invoice). This keeps callback semantics identical across surfaces.

Decision — what stays unsigned

Read-only navigation (menu transitions: menu:targets, menu:billing) is also signed even though it's idempotent — uniform behaviour beats special-casing, and the cost is one HMAC per render.

Consequences

  • Cross-user replay is impossible (the HMAC binds to tg_user_id).
  • Time-bounded actions can't be silently mutated.
  • Every reject is observable in the audit log.
  • Key rotation costs five seconds, not a deploy.

See also

  • apps/bot/keyboards/factories.py
  • apps/bot/filters/signed_callback.py
  • infrastructure/crypto/hkdf.py
  • tests/security/test_callback_signing.py

On this page